visar.log
Technical notes from building things
← all posts

I Gave an Agent Control of My iPhone Userscript From Linux

The Test Suite I Kept Postponing

I write userscripts that replace websites with interfaces built around how I use them. One of them, gallery-reader, turns supported gallery sites into a small application: Favorites and searches become paginated rows of thumbnails, and tapping a thumbnail opens a vertical reader at that exact image.

The code is written on Linux. The application runs in Safari on my iPhone. There is no Mac in between.

That last fact made proper testing feel conceptually blocked. Desktop tests could check functions and DOM output, but not the behavior that repeatedly caused real bugs:

  • Safari history and Back;
  • bfcache on one site and full reloads on another;
  • scroll restoration after a page is rebuilt;
  • userscript execution at the start of every route;
  • iPhone timing and layout;
  • the Favorites and saved searches already stored on my phone.

Apple’s supported Safari WebDriver path requires a macOS host. Appium’s iOS driver also depends on macOS and Xcode. Cloud devices are somebody else’s clean phones, not my Safari installation with my userscript manager and local data. Turning gallery-reader into a hosted PWA would avoid the testing problem by changing the application into something I did not want.

So I kept manually checking changes and postponing the real suite.

The Missing Piece Was Another Userscript

The breakthrough was to stop trying to control the iPhone from below, through Apple’s device tooling, and control the application from inside the browser instead.

I installed a second userscript whose only job is debugging. On every page it:

  1. connects over my LAN to a small HTTPS bridge on the Linux machine;
  2. identifies its page with a unique client ID;
  3. polls for JavaScript commands;
  4. executes a command in the page;
  5. sends the result or error back.

The Linux test runner can now ask the active Safari tab what URL it is on, navigate it, inject the latest gallery-reader bundle, click elements, scroll, wait, and inspect the resulting DOM.

Linux agent and test runner
          |
          | HTTPS commands and results
          v
repository-local bridge :38888
          |
          | userscript polling over Wi-Fi
          v
debug userscript in iPhone Safari
          |
          | JavaScript in the live page
          v
gallery-reader under test

This is not full iPhone automation. It cannot press a Settings button or unlock the phone. It is something narrower and more useful for this project: a tiny WebDriver at the userscript layer.

Specific Was Better Than General

My first instinct when making development infrastructure shareable is usually to parameterize it. In this case that was actively unhelpful.

This bridge exists for one laptop and one phone. Its debugger is deliberately fixed to 192.168.1.197:38888. The repository contains the exact script I install. The bridge serves that file verbatim. If I clone the project to a different laptop, I change the address and port in the documented places.

During setup, attempts to make the script more general made the fragile parts harder to reason about. Once we accepted that this was a personal development appliance, the contract became simple:

  • one known address;
  • one known port;
  • one trusted local certificate;
  • one checked-in debugger userscript;
  • no rewriting during installation.

Generality is valuable when there are multiple real consumers. Here it was a hypothetical feature competing with the one setup that needed to work.

Freezing Behavior Before Automating It

Before writing the harness, I wrote test.txt.

It is not a list of selectors. It describes what I consider the application: how gallery takeover should look, what pagination should do, what Back should restore, how reader position is encoded in the URL, and what Favorites must preserve. It also contains the real searches used as entry points.

The suite assumes the phone has at least three pages of Favorites. Those Favorites are useful test data, but they are not disposable fixtures. Tests may temporarily toggle a Favorite or change saved navigation state, then restore what they changed.

We started with one smoke case. The harness would only take control if Safari was showing example.com or one of the explicitly supported sites. At the end, it returned the controlled tab to example.com. That gave the agent a safe starting gate and an obvious neutral finishing state.

Only after the smoke case worked did we run Favorites, Hitomi Search, and imhentai Search as a full suite.

The First “Hang” Was the Test Harness

The full run passed Favorites and appeared to hang while restoring a reader after reload.

Watching the bridge state exposed a lifecycle mistake. A reload briefly leaves two debugger clients alive:

  • the old page, which is dying;
  • the new page, which has the same URL and is starting.

The harness identified pages by URL, so it accepted the old client immediately and sent the new userscript injection to it. Then it saw a fresh debugger client appear and assumed injection had succeeded there. The next assertion waited for gallery-reader DOM that could never appear, because the new route had never received the application.

Fixing that revealed a second version of the same bug: injection succeeded on the new client, but the still-polling old client was then mistaken for a newer one and reclaimed.

The rule became explicit:

Every navigation creates a new application, and control belongs to the debugger client that actually acknowledged the injection.

That rule sounds obvious now. It was not obvious when all clients reported the same reader URL.

Then the Tests Found a Real Bug

After the harness reliably reinjected on every route, Hitomi passed. imhentai failed at the final Back assertion:

Back restored scroll 0, expected 1096

This was the moment the whole setup justified itself.

Search initialization did this:

void paginate(query, page)
applyPendingScroll()

The gallery request started in the background, and scroll restoration ran immediately. On imhentai the page did not yet contain enough rendered content, so Safari clamped scrollTo(1096) to 0. Hitomi passed the same test because its underlying document happened to be tall enough at that moment. One provider was concealing a race in the shared application code.

The fix was one line in spirit:

await paginate(query, page)
applyPendingScroll()

Render the gallery, then restore its viewport.

The complete suite passed on the physical iPhone after that change. The test was not written specifically to find this bug. It emerged from enforcing the behavior in test.txt against two sites with different Safari lifecycles.

Why This Changed My Development Loop

Previously, an agent could write the code and run local checks, but I remained the integration layer. I had to copy the userscript, open the right page, perform the gesture, report what happened, and repeat. Difficult changes ended with “this should work on iPhone.”

Now the loop can close:

behavior in test.txt
    → implementation
    → type-check and build
    → inject into each real Safari route
    → operate the phone visibly
    → inspect a failure while it is happening
    → fix either harness or application
    → rerun from a safe gate

The agent is not merely firing synthetic assertions at a detached DOM. I can watch it scroll to the pagination numbers, pause for a second, tap forward, return backward, enter the reader, reload it, and press Back. When something stalls, the bridge exposes the last command, current clients, page URL, errors, and results so the agent can take over the investigation instead of waiting blindly for a timeout.

The visible, slightly slower test is a feature. It tests the rhythm of the application I actually use.

What I Think I Built

The individual components are not novel. Userscripts can run on iPhone Safari. JavaScript consoles can report to remote servers. Linux projects can translate Apple’s Web Inspector protocol. Browser test runners have executed commands remotely for decades.

The useful combination is specific:

  • a Linux machine with no Mac;
  • my physical iPhone and its real browser-local state;
  • a userscript that must restart on every route;
  • a second userscript acting as the control plane;
  • a behavioral text file acting as the product contract;
  • an agent able to implement, operate, observe, diagnose, and rerun.

There are broader tools, but none fit these constraints as directly. This bridge controls exactly the layer where the application exists and no more.

I had postponed this because I imagined “iPhone automation” as a large platform problem. It became tractable when I reduced it to “execute JavaScript in the correct Safari page, and never lose track of which page that is.”

That was the breakthrough: not automating an iPhone in general, but giving an agent enough control to be responsible for the userscript it creates.