on master help sign in
Update without clicking, Chrome Web Store package, clearer setup errors
d0ca350Claude
.github/workflows/nix.ymlnew file+22
@@ -0,0 +1,22 @@
1+name: Nix
2+
3+# Builds the flake's packages on Linux and on Apple silicon, so flake.nix doesn't rot unnoticed.
4+on:
5+ push:
6+ branches: [master]
7+ pull_request:
8+
9+jobs:
10+ build:
11+ strategy:
12+ fail-fast: false
13+ matrix:
14+ os: [ubuntu-latest, macos-latest]
15+ runs-on: ${{ matrix.os }}
16+ steps:
17+ - uses: actions/checkout@v4
18+ - uses: cachix/install-nix-action@v31
19+ - name: Build the host and the extension
20+ run: nix build .#diagonal-host .#extension -L --no-link --print-out-paths
21+ - name: Evaluate the flake
22+ run: nix flake check --no-build
.github/workflows/release.yml+26−2
@@ -50,6 +50,8 @@ jobs:
50 # Homebrew cask (iko-soy/homebrew-tap, Casks/diagonal.rb) downloads.
50 # Homebrew cask (iko-soy/homebrew-tap, Casks/diagonal.rb) downloads.
51 cp "diagonal-extension-${DIAGONAL_VERSION}.zip" diagonal-extension.zip
51 cp "diagonal-extension-${DIAGONAL_VERSION}.zip" diagonal-extension.zip
52 cat dist/manifest.json | head -5
52 cat dist/manifest.json | head -5
53+ # The Chrome Web Store package: no pinned key, no bundled host (docs/store/listing.md).
54+ npm run zip:store
53
55
54 - name: Publish release
56 - name: Publish release
55 env:
57 env:
@@ -60,7 +62,29 @@ jobs:
60 notes="Built from ${GITHUB_SHA::7}. Install with: brew install iko-soy/tap/diagonal. Or by hand: unzip, load the folder at brave://extensions (Developer mode → Load unpacked), then install the native host once from Terminal: bash <that folder>/install-host.command"
62 notes="Built from ${GITHUB_SHA::7}. Install with: brew install iko-soy/tap/diagonal. Or by hand: unzip, load the folder at brave://extensions (Developer mode → Load unpacked), then install the native host once from Terminal: bash <that folder>/install-host.command"
61 if gh release view "$VERSION" >/dev/null 2>&1; then
63 if gh release view "$VERSION" >/dev/null 2>&1; then
62 # Two commits in the same minute share a version: refresh the asset on the existing release.
64 # Two commits in the same minute share a version: refresh the asset on the existing release.
63− gh release upload "$VERSION" "$asset" diagonal-extension.zip --clobber
65+ gh release upload "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --clobber
64 else
66 else
65− gh release create "$VERSION" "$asset" diagonal-extension.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
67+ gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
66 fi
68 fi
69+
70+ # Uploads the release to the Chrome Web Store and submits it for review, once the listing exists and
71+ # its API credentials are set as repository secrets (docs/store/listing.md). Skipped until then.
72+ - name: Upload to the Chrome Web Store
73+ env:
74+ CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
75+ CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
76+ CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
77+ CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
78+ run: |
79+ if [ -z "$CWS_EXTENSION_ID" ] || [ -z "$CWS_REFRESH_TOKEN" ]; then
80+ echo "Chrome Web Store secrets not set; skipping upload."
81+ exit 0
82+ fi
83+ token=$(curl -fsS https://oauth2.googleapis.com/token \
84+ -d client_id="$CWS_CLIENT_ID" -d client_secret="$CWS_CLIENT_SECRET" \
85+ -d refresh_token="$CWS_REFRESH_TOKEN" -d grant_type=refresh_token | jq -r .access_token)
86+ curl -fsS -X PUT -H "Authorization: Bearer $token" -H "x-goog-api-version: 2" \
87+ -T diagonal-store.zip "https://www.googleapis.com/upload/chromewebstore/v1.1/items/$CWS_EXTENSION_ID" | tee upload.json
88+ jq -e '.uploadState == "SUCCESS"' upload.json
89+ curl -fsS -X POST -H "Authorization: Bearer $token" -H "x-goog-api-version: 2" -H "Content-Length: 0" \
90+ "https://www.googleapis.com/chromewebstore/v1.1/items/$CWS_EXTENSION_ID/publish" | tee publish.json
.gitignore+2
@@ -6,3 +6,5 @@ diagonal-extension.zip
6host/tests/__pycache__/
6host/tests/__pycache__/
7host/__pycache__/
7host/__pycache__/
8__pycache__/
8__pycache__/
9+dist-store/
10+diagonal-store.zip
PRIVACY.mdnew file+26
@@ -0,0 +1,26 @@
1+# Diagonal privacy policy
2+
3+Diagonal groups and names your browser tabs. This page says what it reads, where that goes, and what it keeps.
4+
5+## What Diagonal reads
6+
7+For each normal (not incognito) tab: its title, its address and the page's `<meta name="description">`. In settings you can leave out descriptions, or send only the site name instead of the full address.
8+
9+## Where it goes
10+
11+- **To Apple's on-device model on your Mac.** Diagonal passes that text to Apple's `fm` tool through a small helper program on your Mac (the native messaging host installed with `brew install iko-soy/tap/diagonal`). By default the model runs on your Mac and nothing leaves it.
12+- **To Apple's Private Cloud Compute, only if you choose it.** Settings → Privacy → Model offers Apple's larger server model. With that picked, the same text goes to Apple under Apple's Private Cloud Compute terms. It is off by default.
13+
14+Diagonal has no server of its own. It sends nothing to its developers and has no analytics. The only other request is the browser loading each archived tab's site icon from that site when you open the popup.
15+
16+## What it keeps
17+
18+In the browser's extension storage on your computer: its settings, which tab groups it manages and their titles, and archived tabs (title, address, group) so you can restore them. The helper writes a log of its own errors to `~/Library/Logs/Diagonal` on your Mac. Uninstalling removes the extension's storage; `brew uninstall --zap diagonal` removes the helper's files and logs.
19+
20+## Sharing
21+
22+Diagonal does not sell, share or transfer your data to anyone, and does not use it for anything other than grouping and naming your tabs.
23+
24+## Contact
25+
26+Open an issue at https://github.com/iko-soy/diagonal/issues.
README.md+5−4
@@ -39,9 +39,9 @@ Then load the extension once, in each browser you use:
391. Open `chrome://extensions` (it works in Brave, Edge and the rest too) and turn on **Developer mode**.
391. Open `chrome://extensions` (it works in Brave, Edge and the rest too) and turn on **Developer mode**.
402. Click **Load unpacked**, press **Cmd+Shift+G** and paste `~/Library/Application Support/Diagonal/extension`.
402. Click **Load unpacked**, press **Cmd+Shift+G** and paste `~/Library/Application Support/Diagonal/extension`.
41
41
42−To update: `brew upgrade --cask --greedy diagonal`, then click reload on Diagonal at `chrome://extensions`. `brew uninstall diagonal` removes the extension folder, the host and its manifests from every browser; `brew uninstall --zap diagonal` also removes the fm schemas and logs.
42+Updates come with a plain `brew upgrade`: the tap's cask moves to each new release within half an hour (`.github/workflows/diagonal.yml` in iko-soy/homebrew-tap). Diagonal then notices the newer files on disk and reloads itself within a couple of minutes, or as soon as you open its popup, so there is nothing to click. `brew uninstall diagonal` removes the extension folder, the host and its manifests from every browser; `brew uninstall --zap diagonal` also removes the fm schemas and logs.
43
43
44−Chromium browsers only install extensions on their own from a web store, so the one Load unpacked step stays until Diagonal is published there.
44+Chromium browsers only install extensions on their own from a web store, so the one Load unpacked step stays until Diagonal is published there. Everything the Chrome Web Store asks for is ready in [docs/store/listing.md](docs/store/listing.md), with the policy in [PRIVACY.md](PRIVACY.md); a store install still needs the Homebrew helper above.
45
45
46### From a release zip
46### From a release zip
47
47
@@ -103,7 +103,8 @@ npm run watch # rebuild on change
103npm run typecheck
103npm run typecheck
104npm test # vitest: engine, naming, organize, tidy, url, property tests
104npm test # vitest: engine, naming, organize, tidy, url, property tests
105npm run test:host # unittest: framing, origin, every error code, prompts, validators
105npm run test:host # unittest: framing, origin, every error code, prompts, validators
106−npm run smoke # Linux: loads dist/ in Chromium with the real host and a fake fm
106+npm run smoke # Linux: loads dist/ in Chromium with the real host and a fake fm, then checks self-update
107+npm run zip:store # diagonal-store.zip for the Chrome Web Store (no pinned key, no bundled host)
107npm run check # typecheck + both unit suites
108npm run check # typecheck + both unit suites
108```
109```
109
110
@@ -138,4 +139,4 @@ Everything was tested against a fake `fm`. These are open until someone runs it
138- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
139- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
139- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
140- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
140- The context window. `CHAR_BUDGET` is 10,000 characters, assuming 4,096 tokens. Double it (and `NAME_ITEM_CAP` / `ORGANIZE_ITEM_CAP`) once 8,192 is confirmed.
141- The context window. `CHAR_BUDGET` is 10,000 characters, assuming 4,096 tokens. Double it (and `NAME_ITEM_CAP` / `ORGANIZE_ITEM_CAP`) once 8,192 is confirmed.
141−- The Nix flake has not been built. `importNpmLock` avoids an `npmDepsHash`.
142+- The Nix flake builds both packages on x86_64-linux, and `.github/workflows/nix.yml` builds them on Linux and Apple silicon on every push. The home-manager module has not been activated on a real Mac.
docs/store/listing.mdnew file+51
@@ -0,0 +1,51 @@
1+# Chrome Web Store listing
2+
3+Everything the Chrome Web Store developer dashboard asks for, ready to paste. The package is `diagonal-store.zip` from `npm run zip:store` (every release also attaches it).
4+
5+## Store listing
6+
7+- **Name:** Diagonal
8+- **Summary (132 chars max):** Groups your tabs as you browse and names each group with Apple's on-device model. No cloud, nothing to press.
9+- **Category:** Workflow & Planning
10+- **Language:** English
11+- **Description:**
12+
13+ > Diagonal keeps your tabs in named groups without you doing anything.
14+ >
15+ > Open and close tabs as usual. A link you open from a tab joins it in a group. Loose tabs are sorted into matching groups once you stop for a moment. Each group gets a short title with an emoji, written by Apple's on-device model on your Mac. Tabs you haven't touched in a day move to a collapsed Parked group, and you can restore anything Diagonal archived.
16+ >
17+ > Your choices win: groups you make yourself are left alone, a title you type is kept, and a tab you pull out of a group stays out.
18+ >
19+ > Requirements: a Mac with Apple silicon running macOS 27 with Apple Intelligence on, plus Diagonal's small helper program, installed with: brew install iko-soy/tap/diagonal
20+ >
21+ > Private by design: the model runs on your Mac and sees only each tab's title, address and description. Diagonal has no server and no analytics.
22+
23+- **Icon:** `icons/128.png`
24+- **Screenshots (1280×800):** `docs/store/screenshot-popup.png`, `docs/store/screenshot-settings.png`
25+- **Small promo tile (440×280):** `docs/store/promo-small.png`
26+- **Homepage:** https://github.com/iko-soy/diagonal
27+- **Support:** https://github.com/iko-soy/diagonal/issues
28+
29+## Privacy practices
30+
31+- **Single purpose:** Organizes the browser's tabs into named tab groups.
32+- **Permission justifications:**
33+ - `tabs`: reads tab titles and addresses to decide which tabs belong together and to name groups.
34+ - `tabGroups`: creates, names, collapses and removes the tab groups it manages.
35+ - `storage`: keeps settings, which groups it manages, and archived tabs, on this computer.
36+ - `alarms`: waits for tabs to settle before grouping, and runs the idle-tab tidy on a schedule.
37+ - `nativeMessaging`: talks to Diagonal's helper on the Mac, which runs Apple's on-device model.
38+ - `scripting`: reads the meta description of tabs that were already open when Diagonal was installed.
39+ - Host permissions `http://*/*`, `https://*/*`: the content script reads each page's meta description, which helps the model name groups.
40+- **Remote code:** No, it does not use remote code.
41+- **Data usage:** collects "Web history" (tab titles and addresses) and "Website content" (page descriptions). Check the three certifications: not sold to third parties, not used for unrelated purposes, not used for creditworthiness or lending.
42+- **Privacy policy URL:** https://github.com/iko-soy/diagonal/blob/master/PRIVACY.md
43+
44+## Distribution
45+
46+- **Visibility:** Public (or Unlisted to share by link only)
47+- **Regions:** All
48+
49+## After the first upload
50+
51+The store gives the listing its own extension ID. Put it in `STORE_ORIGIN` in `host/diagonal-host.py` as `chrome-extension://<id>/` so the helper accepts the store install, and set these repository secrets so every release uploads itself: `CWS_EXTENSION_ID`, `CWS_CLIENT_ID`, `CWS_CLIENT_SECRET`, `CWS_REFRESH_TOKEN` (see Google's guide, "Use the Chrome Web Store API").
docs/store/promo-small.pngnew file

Binary file not shown.

docs/store/screenshot-popup.pngnew file

Binary file not shown.

docs/store/screenshot-settings.pngnew file

Binary file not shown.

flake.nix+9−3
@@ -1,5 +1,5 @@
1{
1{
2− description = "Diagonal: Dia-style tab groups for Brave, named by Apple's on-device model";
2+ description = "Diagonal: Dia-style tab groups for Chromium browsers, named by Apple's on-device model";
3
3
4 inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
4 inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
5
5
@@ -58,7 +58,8 @@
58 description = "Diagonal: names tab groups with Apple's on-device model";
58 description = "Diagonal: names tab groups with Apple's on-device model";
59 path = "${host}/bin/diagonal-host";
59 path = "${host}/bin/diagonal-host";
60 type = "stdio";
60 type = "stdio";
61− allowed_origins = [ "chrome-extension://${cfg.extensionId}/" ];
61+ allowed_origins = map (id: "chrome-extension://${id}/")
62+ ([ cfg.extensionId ] ++ lib.optional (cfg.storeExtensionId != "") cfg.storeExtensionId);
62 };
63 };
63 in
64 in
64 {
65 {
@@ -69,9 +70,14 @@
69 default = extensionId;
70 default = extensionId;
70 description = "Pinned extension ID (from scripts/gen-key.sh). The host itself checks the ID baked into host/diagonal-host.py, so change both together by re-running gen-key.sh.";
71 description = "Pinned extension ID (from scripts/gen-key.sh). The host itself checks the ID baked into host/diagonal-host.py, so change both together by re-running gen-key.sh.";
71 };
72 };
73+ storeExtensionId = lib.mkOption {
74+ type = lib.types.str;
75+ default = "";
76+ description = "The Chrome Web Store listing's extension ID, if you installed Diagonal from the store. Must match STORE_ORIGIN in host/diagonal-host.py.";
77+ };
72 browsers = lib.mkOption {
78 browsers = lib.mkOption {
73 type = lib.types.listOf lib.types.str;
79 type = lib.types.listOf lib.types.str;
74− default = [ "BraveSoftware/Brave-Browser" ];
80+ default = [ "BraveSoftware/Brave-Browser" "BraveSoftware/Brave-Origin" "Google/Chrome" ];
75 example = [ "BraveSoftware/Brave-Origin" "Google/Chrome" "Microsoft Edge" "Arc/User Data" ];
81 example = [ "BraveSoftware/Brave-Origin" "Google/Chrome" "Microsoft Edge" "Arc/User Data" ];
76 description = "Browser profile folders under ~/Library/Application Support to install the host manifest into.";
82 description = "Browser profile folders under ~/Library/Application Support to install the host manifest into.";
77 };
83 };
host/diagonal-host.py+29−10
@@ -25,9 +25,12 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
25import prompts # noqa: E402
25import prompts # noqa: E402
26import validate # noqa: E402
26import validate # noqa: E402
27
27
28−HOST_VERSION = "0.1.0"
28+HOST_VERSION = "0.1.0" # release builds stamp the extension version here (scripts/build.mjs)
29HOST_NAME = "io.diagonal.host"
29HOST_NAME = "io.diagonal.host"
30ALLOWED_ORIGIN = "chrome-extension://mpnodlalikgeehnlnofdkpgapkmbkjdf/"
30ALLOWED_ORIGIN = "chrome-extension://mpnodlalikgeehnlnofdkpgapkmbkjdf/"
31+# The Chrome Web Store gives the listed extension its own ID. Once the listing exists, put its origin here
32+# ("chrome-extension://<store id>/") so the store install and the unpacked one can both use the host.
33+STORE_ORIGIN = ""
31FM = os.environ.get("DIAGONAL_FM", "/usr/bin/fm") # tests point this at host/tests/fake_fm.py
34FM = os.environ.get("DIAGONAL_FM", "/usr/bin/fm") # tests point this at host/tests/fake_fm.py
32SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/Application Support/Diagonal"))
35SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/Application Support/Diagonal"))
33SCHEMAS = os.path.join(SUPPORT, "schemas")
36SCHEMAS = os.path.join(SUPPORT, "schemas")
@@ -247,11 +250,18 @@ def op_ping(_payload, _opts):
247 license_required, msg = True, LICENSE_MESSAGE
250 license_required, msg = True, LICENSE_MESSAGE
248 except subprocess.TimeoutExpired:
251 except subprocess.TimeoutExpired:
249 ok, msg = False, "fm available timed out"
252 ok, msg = False, "fm available timed out"
253+ schema_message = None
250 if ok:
254 if ok:
255+ try:
251− ensure_schemas()
256+ ensure_schemas()
257+ except Fail as f:
258+ schema_message = f.message
252 mode = organize_mode()
259 mode = organize_mode()
253− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
260+ reply = {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
254− "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
261+ "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
262+ if schema_message:
263+ reply["schemaMessage"] = schema_message
264+ return reply
255
265
256
266
257def schemas_ok():
267def schemas_ok():
@@ -263,17 +273,22 @@ def ensure_schemas():
263 accepted after the installer ran). stdout is the native-messaging channel: progress lines are swallowed."""
273 accepted after the installer ran). stdout is the native-messaging channel: progress lines are swallowed."""
264 if schemas_ok():
274 if schemas_ok():
265 return
275 return
276+ out = io.StringIO()
266− with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
277+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
267 rc = install_schemas()
278 rc = install_schemas()
268 if rc == INSTALL_LICENSE_EXIT:
279 if rc == INSTALL_LICENSE_EXIT:
269 raise Fail("LICENSE_REQUIRED", LICENSE_MESSAGE)
280 raise Fail("LICENSE_REQUIRED", LICENSE_MESSAGE)
281+ if not schemas_ok():
282+ lines = [ln for ln in out.getvalue().splitlines() if ln.strip()]
283+ why = lines[-1] if lines else f"fm schema exited {rc}"
284+ raise Fail("SCHEMA_MISSING", f"fm could not write Diagonal's schema files: {why}"[:500], raw=out.getvalue()[-2000:])
270
285
271
286
272def op_name(payload, opts):
287def op_name(payload, opts):
273 ensure_schemas()
288 ensure_schemas()
274 schema = schema_path("name.json")
289 schema = schema_path("name.json")
275 if not schema:
290 if not schema:
276− raise Fail("SCHEMA_MISSING", os.path.join(SCHEMAS, "name.json"))
291+ raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
277 prompt = prompts.build_name_prompt(payload, strict=opts.get("strict", False))
292 prompt = prompts.build_name_prompt(payload, strict=opts.get("strict", False))
278 check_budget(prompt, len(payload["items"]))
293 check_budget(prompt, len(payload["items"]))
279 out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
294 out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
@@ -304,7 +319,7 @@ def op_organize(payload, opts):
304 return validate.organize_from_two_calls(labels, assign, payload), len(p1) + len(p2), "two-call"
319 return validate.organize_from_two_calls(labels, assign, payload), len(p1) + len(p2), "two-call"
305 except validate.ValidationError as e:
320 except validate.ValidationError as e:
306 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True)
321 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True)
307− raise Fail("SCHEMA_MISSING", os.path.join(SCHEMAS, "organize.json"))
322+ raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'organize.json')}")
308
323
309
324
310def handle(req):
325def handle(req):
@@ -508,7 +523,7 @@ def manifest_problems(path, host_path):
508 problems.append(f"path is {m.get('path')!r}")
523 problems.append(f"path is {m.get('path')!r}")
509 elif not os.access(host_path, os.X_OK):
524 elif not os.access(host_path, os.X_OK):
510 problems.append(f"host {host_path} is missing or not executable")
525 problems.append(f"host {host_path} is missing or not executable")
511− if m.get("allowed_origins") != [ALLOWED_ORIGIN]:
526+ if m.get("allowed_origins") != allowed_origins():
512 problems.append(f"allowed_origins is {m.get('allowed_origins')}")
527 problems.append(f"allowed_origins is {m.get('allowed_origins')}")
513 return problems
528 return problems
514
529
@@ -569,11 +584,15 @@ def unregister():
569
584
570def manifest_for(path):
585def manifest_for(path):
571 return {"name": HOST_NAME, "description": "Diagonal: names tab groups with Apple's on-device model",
586 return {"name": HOST_NAME, "description": "Diagonal: names tab groups with Apple's on-device model",
572− "path": os.path.abspath(path), "type": "stdio", "allowed_origins": [ALLOWED_ORIGIN]}
587+ "path": os.path.abspath(path), "type": "stdio", "allowed_origins": allowed_origins()}
588+
589+
590+def allowed_origins():
591+ return [o for o in (ALLOWED_ORIGIN, STORE_ORIGIN) if o]
573
592
574
593
575def origin_ok(arg):
594def origin_ok(arg):
576− return isinstance(arg, str) and arg.rstrip("/") + "/" == ALLOWED_ORIGIN
595+ return isinstance(arg, str) and arg.rstrip("/") + "/" in allowed_origins()
577
596
578
597
579def main(argv=None):
598def main(argv=None):
host/tests/test_host.py+20
@@ -55,6 +55,17 @@ class Origin(HostCase):
55 code, reply, _ = self.run_host({"v": 1, "id": "p", "op": "ping", "payload": {}}, origin=host.ALLOWED_ORIGIN.rstrip("/"))
55 code, reply, _ = self.run_host({"v": 1, "id": "p", "op": "ping", "payload": {}}, origin=host.ALLOWED_ORIGIN.rstrip("/"))
56 self.assertTrue(reply["ok"])
56 self.assertTrue(reply["ok"])
57
57
58+ def test_store_origin_is_accepted_once_set(self):
59+ store = "chrome-extension://" + "b" * 32 + "/"
60+ self.assertFalse(host.origin_ok(store))
61+ saved, host.STORE_ORIGIN = host.STORE_ORIGIN, store
62+ try:
63+ self.assertTrue(host.origin_ok(store))
64+ self.assertTrue(host.origin_ok(host.ALLOWED_ORIGIN))
65+ self.assertEqual(host.manifest_for("/x/diagonal-host")["allowed_origins"], [host.ALLOWED_ORIGIN, store])
66+ finally:
67+ host.STORE_ORIGIN = saved
68+
58 def test_origin_is_pinned(self):
69 def test_origin_is_pinned(self):
59 self.assertRegex(host.ALLOWED_ORIGIN, r"^chrome-extension://[a-p]{32}/$")
70 self.assertRegex(host.ALLOWED_ORIGIN, r"^chrome-extension://[a-p]{32}/$")
60
71
@@ -81,6 +92,15 @@ class Ping(HostCase):
81 self.assertTrue(r["result"]["licenseRequired"])
92 self.assertTrue(r["result"]["licenseRequired"])
82 self.assertIn("sudo fm license", r["result"]["fmMessage"])
93 self.assertIn("sudo fm license", r["result"]["fmMessage"])
83
94
95+ def test_ping_says_why_schemas_could_not_be_written(self):
96+ for f in os.listdir(os.path.join(self.support, "schemas")):
97+ os.remove(os.path.join(self.support, "schemas", f))
98+ self.control({"schema_fail": True})
99+ r = self.call("ping")
100+ self.assertTrue(r["ok"], r)
101+ self.assertFalse(r["result"]["schemasOk"])
102+ self.assertIn("fm could not write", r["result"]["schemaMessage"])
103+
84 def test_ping_writes_missing_schemas_once_fm_works(self):
104 def test_ping_writes_missing_schemas_once_fm_works(self):
85 for f in os.listdir(os.path.join(self.support, "schemas")):
105 for f in os.listdir(os.path.join(self.support, "schemas")):
86 os.remove(os.path.join(self.support, "schemas", f))
106 os.remove(os.path.join(self.support, "schemas", f))
package.json+2−1
@@ -12,7 +12,8 @@
12 "test:host": "cd host && python3 -m unittest discover -s tests -v",
12 "test:host": "cd host && python3 -m unittest discover -s tests -v",
13 "gen-emoji": "node scripts/gen-emoji.mjs",
13 "gen-emoji": "node scripts/gen-emoji.mjs",
14 "zip": "node scripts/build.mjs && cd dist && zip -qr ../diagonal-extension.zip .",
14 "zip": "node scripts/build.mjs && cd dist && zip -qr ../diagonal-extension.zip .",
15+ "zip:store": "node scripts/build.mjs --store && cd dist-store && zip -qr ../diagonal-store.zip .",
15− "smoke": "node scripts/build.mjs && node test/e2e/smoke.mjs",
16+ "smoke": "node scripts/build.mjs && node test/e2e/smoke.mjs && node test/e2e/selfupdate.mjs",
16 "icons": "python3 scripts/render-icons.py",
17 "icons": "python3 scripts/render-icons.py",
17 "check": "tsc --noEmit && vitest run && cd host && python3 -m unittest discover -s tests"
18 "check": "tsc --noEmit && vitest run && cd host && python3 -m unittest discover -s tests"
18 },
19 },
scripts/build.mjs+16−2
@@ -1,11 +1,14 @@
1// Bundles src/ into dist/ (the unpacked extension). `--watch` rebuilds on change.
1// Bundles src/ into dist/ (the unpacked extension). `--watch` rebuilds on change.
2+// `--store` writes dist-store/ for the Chrome Web Store instead: no pinned key (the store assigns the ID),
3+// no bundled host or installer, and a version without leading zeros.
2import * as esbuild from "esbuild";
4import * as esbuild from "esbuild";
3import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4import { dirname, join } from "node:path";
6import { dirname, join } from "node:path";
5import { fileURLToPath } from "node:url";
7import { fileURLToPath } from "node:url";
6
8
7const root = join(dirname(fileURLToPath(import.meta.url)), "..");
9const root = join(dirname(fileURLToPath(import.meta.url)), "..");
10+const store = process.argv.includes("--store");
8−const dist = join(root, "dist");
11+const dist = join(root, store ? "dist-store" : "dist");
9const watch = process.argv.includes("--watch");
12const watch = process.argv.includes("--watch");
10
13
11rmSync(dist, { recursive: true, force: true });
14rmSync(dist, { recursive: true, force: true });
@@ -30,14 +33,25 @@ function copyStatic() {
30 if (!/^\d{4}\.\d{2}\.\d{2}\.\d{4}$/.test(stamp)) throw new Error(`DIAGONAL_VERSION must be YYYY.MM.DD.HHMM, got ${stamp}`);
33 if (!/^\d{4}\.\d{2}\.\d{2}\.\d{4}$/.test(stamp)) throw new Error(`DIAGONAL_VERSION must be YYYY.MM.DD.HHMM, got ${stamp}`);
31 manifest.version = stamp;
34 manifest.version = stamp;
32 }
35 }
36+ if (store) {
37+ delete manifest.key;
38+ manifest.version = manifest.version.split(".").map((n) => String(Number(n))).join(".");
39+ }
33 writeFileSync(join(dist, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
40 writeFileSync(join(dist, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
34 cpSync(join(root, "src/popup/popup.html"), join(dist, "popup.html"));
41 cpSync(join(root, "src/popup/popup.html"), join(dist, "popup.html"));
35 cpSync(join(root, "src/options/options.html"), join(dist, "options.html"));
42 cpSync(join(root, "src/options/options.html"), join(dist, "options.html"));
36 cpSync(join(root, "src/shared/ui.css"), join(dist, "ui.css"));
43 cpSync(join(root, "src/shared/ui.css"), join(dist, "ui.css"));
37 cpSync(join(root, "icons"), join(dist, "icons"), { recursive: true, filter: (p) => !p.endsWith("icon.png") });
44 cpSync(join(root, "icons"), join(dist, "icons"), { recursive: true, filter: (p) => !p.endsWith("icon.png") });
45+ if (store) return; // the store package is the extension alone; the host comes from Homebrew
38 // The native host and its installer ride along, so the release zip is all a user needs.
46 // The native host and its installer ride along, so the release zip is all a user needs.
39 mkdirSync(join(dist, "host"));
47 mkdirSync(join(dist, "host"));
40− for (const f of ["diagonal-host.py", "prompts.py", "validate.py", "emoji.txt"]) cpSync(join(root, "host", f), join(dist, "host", f));
48+ for (const f of ["prompts.py", "validate.py", "emoji.txt"]) cpSync(join(root, "host", f), join(dist, "host", f));
49+ // The host reports the same version as the extension it shipped with.
50+ const hostSrc = readFileSync(join(root, "host/diagonal-host.py"), "utf8");
51+ const stamped = hostSrc.replace(/^HOST_VERSION = "[^"]*"/m, `HOST_VERSION = "${manifest.version}"`);
52+ if (stamped === hostSrc && !hostSrc.includes(`HOST_VERSION = "${manifest.version}"`)) throw new Error("HOST_VERSION line not found in host/diagonal-host.py");
53+ writeFileSync(join(dist, "host/diagonal-host.py"), stamped);
54+ chmodSync(join(dist, "host/diagonal-host.py"), 0o755);
41 cpSync(join(root, "extension-id"), join(dist, "extension-id"));
55 cpSync(join(root, "extension-id"), join(dist, "extension-id"));
42 cpSync(join(root, "scripts/install-manifest.sh"), join(dist, "install-host.command"));
56 cpSync(join(root, "scripts/install-manifest.sh"), join(dist, "install-host.command"));
43 chmodSync(join(dist, "install-host.command"), 0o755);
57 chmodSync(join(dist, "install-host.command"), 0o755);
scripts/store-assets.mjsnew file+29
@@ -0,0 +1,29 @@
1+// Renders the Chrome Web Store images into docs/store/ from the popup and settings screenshots the smoke
2+// test takes: SMOKE_SHOTS=/tmp/shots npm run smoke && node scripts/store-assets.mjs /tmp/shots
3+import { chromium } from "playwright-core";
4+import { readFileSync } from "node:fs";
5+import { dirname, join } from "node:path";
6+import { fileURLToPath } from "node:url";
7+
8+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
9+const shots = process.argv[2] || "/tmp/shots";
10+const img = (p) => `data:image/png;base64,${readFileSync(p).toString("base64")}`;
11+const icon = img(join(root, "icons/icon.png"));
12+const css = `body{margin:0;font:16px -apple-system,system-ui,sans-serif;background:linear-gradient(135deg,#0d3b2e,#1f6f55);color:#fff;display:flex;align-items:center;justify-content:center;gap:56px;height:100vh;overflow:hidden}
13+h1{font-size:40px;margin:0 0 16px;line-height:1.15} p{font-size:20px;line-height:1.45;margin:0;opacity:.9;max-width:460px}
14+.shot{border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.35);background:#fff}`;
15+
16+const pages = [
17+ { file: "screenshot-popup.png", w: 1280, h: 800, html: `<div><h1>Tabs group themselves</h1><p>Open and close tabs as usual. Diagonal groups related tabs and names each group with Apple's on-device model.</p></div><img class="shot" src="${img(join(shots, "popup.png"))}" style="height:560px">` },
18+ { file: "screenshot-settings.png", w: 1280, h: 800, html: `<div><h1>Private by design</h1><p>The model runs on your Mac. It sees a tab's title, address and description, and nothing leaves the Mac unless you pick Apple's Private Cloud Compute.</p></div><div class="shot" style="width:560px;height:680px;overflow:hidden"><img src="${img(join(shots, "options.png"))}" style="width:560px;display:block"></div>` },
19+ { file: "promo-small.png", w: 440, h: 280, html: `<img src="${icon}" style="width:120px"><div style="font-size:34px;font-weight:700">Diagonal</div>`, gap: 24 },
20+];
21+
22+const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || "/opt/pw-browsers/chromium-1194/chrome-linux/chrome" });
23+for (const p of pages) {
24+ const page = await browser.newPage({ viewport: { width: p.w, height: p.h } });
25+ await page.setContent(`<style>${css}${p.gap ? `body{gap:${p.gap}px}` : ""}</style>${p.html}`);
26+ await page.screenshot({ path: join(root, "docs/store", p.file) });
27+ console.log("wrote docs/store/" + p.file);
28+}
29+await browser.close();
src/background/host.ts+1−1
@@ -119,7 +119,7 @@ export function explain(error: HostError | undefined, extensionId = ""): string
119 case "LICENSE_REQUIRED":
119 case "LICENSE_REQUIRED":
120 return "Apple's fm tool needs its terms accepted once on this Mac. In Terminal, run: sudo fm license. Diagonal picks up from there on its own.";
120 return "Apple's fm tool needs its terms accepted once on this Mac. In Terminal, run: sudo fm license. Diagonal picks up from there on its own.";
121 case "SCHEMA_MISSING":
121 case "SCHEMA_MISSING":
122− return "Schemas missing. Run: diagonal-host --install-schemas";
122+ return `${error.message}. Diagonal retries on its own; to see the full reason, run in Terminal: ~/.local/bin/diagonal-host --selftest`;
123 case "MODEL_UNAVAILABLE":
123 case "MODEL_UNAVAILABLE":
124 return `On-device model unavailable: ${error.message}. Check System Settings → Apple Intelligence & Siri.`;
124 return `On-device model unavailable: ${error.message}. Check System Settings → Apple Intelligence & Siri.`;
125 case "RATE_LIMITED":
125 case "RATE_LIMITED":
src/background/index.ts+24−1
@@ -6,6 +6,7 @@ import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE
6import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
6import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
7import { withDefaults, type Settings } from "./settings";
7import { withDefaults, type Settings } from "./settings";
8import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
8import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
9+import { readDisk, shouldReload, TRIED_KEY, UPDATE_CHECK_MINUTES } from "./selfupdate";
9import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
10import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
10
11
11/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
12/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
@@ -16,6 +17,7 @@ const ALARM_NAMING = "naming-fallback";
16const ALARM_TIDY = "tidy-sweep";
17const ALARM_TIDY = "tidy-sweep";
17const ALARM_HOST = "host-retry";
18const ALARM_HOST = "host-retry";
18const ALARM_AUTO = "auto-organize";
19const ALARM_AUTO = "auto-organize";
20+const ALARM_UPDATE = "self-update";
19const HOST_RETRY_MS = 600_000;
21const HOST_RETRY_MS = 600_000;
20
22
21// ----- state cache ------------------------------------------------------------------------------
23// ----- state cache ------------------------------------------------------------------------------
@@ -131,7 +133,7 @@ async function ping(): Promise<HostReply<PingResult>> {
131 } else if (!reply.result.fmAvailable) {
133 } else if (!reply.result.fmAvailable) {
132 recordHostError({ code: "MODEL_UNAVAILABLE", message: reply.result.fmMessage || "fm reports the model unavailable" });
134 recordHostError({ code: "MODEL_UNAVAILABLE", message: reply.result.fmMessage || "fm reports the model unavailable" });
133 } else if (!reply.result.schemasOk) {
135 } else if (!reply.result.schemasOk) {
134− recordHostError({ code: "SCHEMA_MISSING", message: "schema files missing" });
136+ recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
135 } else {
137 } else {
136 h.pausedUntil = undefined;
138 h.pausedUntil = undefined;
137 h.lastError = undefined;
139 h.lastError = undefined;
@@ -466,8 +468,28 @@ if (GROUPS_SUPPORTED) {
466 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
468 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
467}
469}
468
470
471+// ----- self-update -------------------------------------------------------------------------------
472+
473+/** After `brew upgrade`, reload into the new files on disk so nobody has to click reload. */
474+async function checkForUpdate(): Promise<void> {
475+ const self = await chrome.management.getSelf().catch(() => undefined);
476+ if (self?.installType !== "development") return; // store installs update themselves
477+ if (busyCount > 0) return; // a model call is in flight; the next check picks it up
478+ const disk = await readDisk(fetch, (p) => chrome.runtime.getURL(p));
479+ const lastTried = (await chrome.storage.local.get(TRIED_KEY))[TRIED_KEY] as string | undefined;
480+ if (!shouldReload({ running: chrome.runtime.getManifest().version, lastTried, ...disk })) return;
481+ await chrome.storage.local.set({ [TRIED_KEY]: disk.onDisk });
482+ console.info(`[diagonal] reloading into ${disk.onDisk} from disk`);
483+ chrome.runtime.reload();
484+}
485+
486+void (async () => {
487+ if (!(await chrome.alarms.get(ALARM_UPDATE))) chrome.alarms.create(ALARM_UPDATE, { periodInMinutes: UPDATE_CHECK_MINUTES, delayInMinutes: 1 });
488+})();
489+
469chrome.alarms.onAlarm.addListener((alarm) => {
490chrome.alarms.onAlarm.addListener((alarm) => {
470 void (async () => {
491 void (async () => {
492+ if (alarm.name === ALARM_UPDATE) return void checkForUpdate().catch((e) => logError("self-update", e));
471 await ready;
493 await ready;
472 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
494 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
473 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
495 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
@@ -537,6 +559,7 @@ chrome.runtime.onMessage.addListener((msg: Msg, sender, sendResponse) => {
537async function handleCommand(msg: Msg): Promise<unknown> {
559async function handleCommand(msg: Msg): Promise<unknown> {
538 switch (msg.cmd) {
560 switch (msg.cmd) {
539 case "status":
561 case "status":
562+ void checkForUpdate().catch((e) => logError("self-update", e)); // opening the popup checks too
540 return statusFor(msg.windowId);
563 return statusFor(msg.windowId);
541 case "ping":
564 case "ping":
542 return ping();
565 return ping();
src/background/selfupdate.tsnew file+53
@@ -0,0 +1,53 @@
1+/**
2+ * `brew upgrade` swaps the unpacked extension's folder for the new release, but the browser keeps running
3+ * the old code until someone clicks reload. An unpacked extension serves its files straight from disk, so
4+ * the worker can read the manifest that is on disk now and reload itself when it is newer.
5+ */
6+export const UPDATE_CHECK_MINUTES = 2;
7+export const TRIED_KEY = "selfUpdateTried";
8+
9+export interface DiskCheck {
10+ /** Version of the code running now. */
11+ running: string;
12+ /** Version in manifest.json on disk, if it could be read. */
13+ onDisk?: string;
14+ /** background.js on disk could be read too, so the folder is not mid-copy. */
15+ filesReady: boolean;
16+ /** On-disk version a reload was already tried for; never try the same one twice. */
17+ lastTried?: string;
18+}
19+
20+/** Dotted numeric versions ("2026.9.24.512" vs "2026.09.24.0512"): <0, 0 or >0. */
21+export function compareVersions(a: string, b: string): number {
22+ const pa = a.split(".").map((n) => Number(n));
23+ const pb = b.split(".").map((n) => Number(n));
24+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
25+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
26+ if (d) return d;
27+ }
28+ return 0;
29+}
30+
31+const VERSION = /^\d+(\.\d+){0,3}$/;
32+
33+export function shouldReload(c: DiskCheck): boolean {
34+ if (!c.filesReady || !c.onDisk || !VERSION.test(c.onDisk) || !VERSION.test(c.running)) return false;
35+ if (c.lastTried === c.onDisk) return false;
36+ return compareVersions(c.onDisk, c.running) > 0;
37+}
38+
39+/** Reads the on-disk manifest and background.js, bypassing any cache. */
40+export async function readDisk(fetchImpl: typeof fetch, url: (path: string) => string): Promise<Pick<DiskCheck, "onDisk" | "filesReady">> {
41+ try {
42+ const [m, bg] = await Promise.all([
43+ fetchImpl(url("manifest.json"), { cache: "no-store" }),
44+ fetchImpl(url("background.js"), { cache: "no-store" }),
45+ ]);
46+ if (!m.ok || !bg.ok) return { filesReady: false };
47+ const manifest = (await m.json()) as { version?: unknown };
48+ const body = await bg.text();
49+ return { onDisk: typeof manifest.version === "string" ? manifest.version : undefined, filesReady: body.length > 0 };
50+ } catch {
51+ return { filesReady: false };
52+ }
53+}
src/background/state.ts+1
@@ -86,6 +86,7 @@ export interface PingResult {
86 fmMessage: string;
86 fmMessage: string;
87 licenseRequired?: boolean;
87 licenseRequired?: boolean;
88 schemasOk: boolean;
88 schemasOk: boolean;
89+ schemaMessage?: string;
89 organizeMode?: string;
90 organizeMode?: string;
90}
91}
91
92
src/shared/health.ts+7−2
@@ -47,7 +47,8 @@ export function notices(s: Health): Notice[] {
47 out.push({
47 out.push({
48 tone: "bad", title: "Finish setting up Diagonal",
48 tone: "bad", title: "Finish setting up Diagonal",
49 body: "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal.",
49 body: "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal.",
50+ // A Web Store install has an update_url and may never have had the cask; an unpacked one came from it.
50− command: "brew reinstall diagonal",
51+ command: chrome.runtime.getManifest().update_url ? "brew install iko-soy/tap/diagonal" : "brew reinstall diagonal",
51 });
52 });
52 break;
53 break;
53 case "LICENSE_REQUIRED":
54 case "LICENSE_REQUIRED":
@@ -69,7 +70,11 @@ export function notices(s: Health): Notice[] {
69 out.push({ tone: "bad", title: "The helper doesn't recognize this extension", body: "Reinstalling registers it again.", command: "brew reinstall diagonal" });
70 out.push({ tone: "bad", title: "The helper doesn't recognize this extension", body: "Reinstalling registers it again.", command: "brew reinstall diagonal" });
70 break;
71 break;
71 case "SCHEMA_MISSING":
72 case "SCHEMA_MISSING":
72− out.push({ tone: "bad", title: "The helper is missing files", body: "Run this in Terminal to restore them.", command: "diagonal-host --install-schemas" });
73+ out.push({
74+ tone: "bad", title: "The model isn't set up yet",
75+ body: `${e.message}. Diagonal keeps retrying on its own. This shows the full reason:`,
76+ command: "~/.local/bin/diagonal-host --selftest",
77+ });
73 break;
78 break;
74 default:
79 default:
75 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
80 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
test/e2e/selfupdate.mjsnew file+89
@@ -0,0 +1,89 @@
1+// End-to-end check of self-update: loads dist/ the way "Load unpacked" does (CDP Extensions.loadUnpacked,
2+// so the browser treats it as a development install), swaps the folder for a newer version the way the
3+// Homebrew cask does, opens the popup, and expects the worker to reload itself into the new version.
4+// node test/e2e/selfupdate.mjs (after npm run build)
5+import { spawn } from "node:child_process";
6+import { cpSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7+import { tmpdir } from "node:os";
8+import { dirname, join } from "node:path";
9+import { fileURLToPath } from "node:url";
10+
11+const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
12+const tmp = mkdtempSync(join(tmpdir(), "diagonal-selfupdate-"));
13+const ext = join(tmp, "extension");
14+cpSync(join(root, "dist"), ext, { recursive: true });
15+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16+
17+const chrome = spawn(process.env.CHROMIUM_PATH || "/opt/pw-browsers/chromium-1194/chrome-linux/chrome", [
18+ "--headless=new", "--no-sandbox", "--remote-debugging-pipe", "--enable-unsafe-extension-debugging",
19+ `--user-data-dir=${join(tmp, "profile")}`, "--no-first-run", "about:blank",
20+], { stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"] });
21+const [, , , toChrome, fromChrome] = chrome.stdio;
22+let buf = "";
23+let nextId = 1;
24+const pending = new Map();
25+fromChrome.on("data", (d) => {
26+ buf += d.toString();
27+ for (let i; (i = buf.indexOf("\0")) >= 0; buf = buf.slice(i + 1)) {
28+ const msg = JSON.parse(buf.slice(0, i));
29+ pending.get(msg.id)?.(msg);
30+ pending.delete(msg.id);
31+ }
32+});
33+const send = (method, params = {}, sessionId) =>
34+ new Promise((resolve) => {
35+ const id = nextId++;
36+ pending.set(id, resolve);
37+ toChrome.write(JSON.stringify({ id, method, params, sessionId }) + "\0");
38+ });
39+
40+async function inPage(path, expression, keepOpen = false) {
41+ const { result: t } = await send("Target.createTarget", { url: `chrome-extension://${id}/${path}` });
42+ await sleep(1000);
43+ const { result: a } = await send("Target.attachToTarget", { targetId: t.targetId, flatten: true });
44+ if (!a) return undefined; // the page already went away (the extension reloaded under it)
45+ const r = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, a.sessionId);
46+ if (!keepOpen) await send("Target.closeTarget", { targetId: t.targetId });
47+ return r.result?.result?.value;
48+}
49+const version = () => inPage("options.html", "chrome.runtime.getManifest().version");
50+
51+let failed = false;
52+const check = (name, ok, detail = "") => {
53+ failed ||= !ok;
54+ console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
55+};
56+
57+await sleep(1500);
58+const loaded = await send("Extensions.loadUnpacked", { path: ext });
59+const id = loaded.result?.id;
60+check("loads unpacked", !!id, JSON.stringify(loaded.error ?? ""));
61+const before = await version();
62+check("runs the version it was loaded with", before === JSON.parse(readFileSync(join(root, "dist/manifest.json"), "utf8")).version, before);
63+
64+// The cask stages the new release next to the old one and renames it into place.
65+const newer = "2999.1.1.1";
66+const staged = `${ext}.new`;
67+cpSync(ext, staged, { recursive: true });
68+const manifest = JSON.parse(readFileSync(join(staged, "manifest.json"), "utf8"));
69+manifest.version = newer;
70+writeFileSync(join(staged, "manifest.json"), JSON.stringify(manifest, null, 2));
71+renameSync(ext, `${ext}.old`);
72+renameSync(staged, ext);
73+
74+// Opening the popup (or settings) asks the worker for status, which checks disk.
75+await inPage("popup.html", "1", true);
76+let after;
77+for (let i = 0; i < 20 && after !== newer; i++) {
78+ await sleep(500);
79+ after = await version().catch(() => undefined);
80+}
81+check("reloads itself into the newer version on disk", after === newer, `${before} -> ${after}`);
82+
83+// Same version on disk again: nothing to do, and no reload loop.
84+await inPage("popup.html", "1");
85+await sleep(2000);
86+check("stays on it afterwards", (await version()) === newer);
87+
88+chrome.kill();
89+process.exit(failed ? 1 : 0);
test/selfupdate.test.tsnew file+46
@@ -0,0 +1,46 @@
1+import { describe, expect, it } from "vitest";
2+import { compareVersions, readDisk, shouldReload } from "../src/background/selfupdate";
3+
4+describe("compareVersions", () => {
5+ it("compares numerically, ignoring leading zeros", () => {
6+ expect(compareVersions("2026.09.24.0512", "2026.9.24.512")).toBe(0);
7+ expect(compareVersions("2026.09.24.0512", "2026.09.23.2039")).toBeGreaterThan(0);
8+ expect(compareVersions("0.1.0", "2026.09.23.2039")).toBeLessThan(0);
9+ expect(compareVersions("1.2", "1.2.0.1")).toBeLessThan(0);
10+ });
11+});
12+
13+describe("shouldReload", () => {
14+ const base = { running: "2026.09.23.2039", filesReady: true };
15+ it("reloads when a newer version is on disk", () => {
16+ expect(shouldReload({ ...base, onDisk: "2026.09.24.0512" })).toBe(true);
17+ });
18+ it("stays put for the same or an older version", () => {
19+ expect(shouldReload({ ...base, onDisk: "2026.09.23.2039" })).toBe(false);
20+ expect(shouldReload({ ...base, onDisk: "2026.09.22.1000" })).toBe(false);
21+ });
22+ it("waits while the folder is mid-copy or unreadable", () => {
23+ expect(shouldReload({ ...base, onDisk: "2026.09.24.0512", filesReady: false })).toBe(false);
24+ expect(shouldReload({ ...base, onDisk: undefined })).toBe(false);
25+ expect(shouldReload({ ...base, onDisk: "garbage" })).toBe(false);
26+ });
27+ it("tries each on-disk version once, so a failed reload cannot loop", () => {
28+ expect(shouldReload({ ...base, onDisk: "2026.09.24.0512", lastTried: "2026.09.24.0512" })).toBe(false);
29+ expect(shouldReload({ ...base, onDisk: "2026.09.25.0100", lastTried: "2026.09.24.0512" })).toBe(true);
30+ });
31+});
32+
33+describe("readDisk", () => {
34+ const url = (p: string) => `chrome-extension://x/${p}`;
35+ const res = (body: string, ok = true) => ({ ok, json: async () => JSON.parse(body), text: async () => body }) as Response;
36+ it("reads the manifest version when both files are there", async () => {
37+ const f = (async (u: string) => (u.endsWith("manifest.json") ? res('{"version":"2026.09.24.0512"}') : res("code"))) as typeof fetch;
38+ expect(await readDisk(f, url)).toEqual({ onDisk: "2026.09.24.0512", filesReady: true });
39+ });
40+ it("is not ready when a file is missing or the manifest is half-written", async () => {
41+ const missing = (async (u: string) => (u.endsWith("manifest.json") ? res("{}") : res("", false))) as typeof fetch;
42+ expect((await readDisk(missing, url)).filesReady).toBe(false);
43+ const half = (async (u: string) => (u.endsWith("manifest.json") ? res('{"vers') : res("code"))) as typeof fetch;
44+ expect((await readDisk(half, url)).filesReady).toBe(false);
45+ });
46+});