on master help sign in
Update without clicking, Chrome Web Store package, clearer setup errors

- The worker reloads itself when a newer version lands on disk (brew upgrade), checked every 2 minutes and whenever the popup or settings open. Only for unpacked installs, once per on-disk version. - npm run zip:store builds a Chrome Web Store package; releases attach it and upload it once the CWS_* secrets exist. Listing text, images and PRIVACY.md are ready in docs/store/. - The host accepts a store install's origin once STORE_ORIGIN is set. - Release builds stamp the host's version; schema failures say what fm reported. - Nix: builds on Linux (checked) and in CI on Linux and Apple silicon; module covers Brave Origin. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YTy2MovVuA1R9w6f9gRDU2

commitd0ca35064a8e43b93d2fccc67540cfbb788dd813 authorClaude <noreply@anthropic.com> dateThu 24 Sep 2026, 06:35 (2 weeks ago) parent6177e0a
22 files changed+458−26
.github/workflows/nix.yml+22 .github/workflows/release.yml+26−2 .gitignore+2 PRIVACY.md+26 README.md+5−4 docs/store/listing.md+51 docs/store/promo-small.png docs/store/screenshot-popup.png docs/store/screenshot-settings.png flake.nix+9−3 host/diagonal-host.py+29−10 host/tests/test_host.py+20 package.json+2−1 scripts/build.mjs+16−2 scripts/store-assets.mjs+29 src/background/host.ts+1−1 src/background/index.ts+24−1 src/background/selfupdate.ts+53 src/background/state.ts+1 src/shared/health.ts+7−2 test/e2e/selfupdate.mjs+89 test/selfupdate.test.ts+46
.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:
5050 # Homebrew cask (iko-soy/homebrew-tap, Casks/diagonal.rb) downloads.
5151 cp "diagonal-extension-${DIAGONAL_VERSION}.zip" diagonal-extension.zip
5252 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
5355
5456 - name: Publish release
5557 env:
@@ -60,7 +62,29 @@ jobs:
6062 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"
6163 if gh release view "$VERSION" >/dev/null 2>&1; then
6264 # 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
6466 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
6668 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
66host/tests/__pycache__/
77host/__pycache__/
88__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:
39391. Open `chrome://extensions` (it works in Brave, Edge and the rest too) and turn on **Developer mode**.
40402. Click **Load unpacked**, press **Cmd+Shift+G** and paste `~/Library/Application Support/Diagonal/extension`.
4141
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.
4343
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.
4545
4646### From a release zip
4747
@@ -103,7 +103,8 @@ npm run watch # rebuild on change
103103npm run typecheck
104104npm test # vitest: engine, naming, organize, tidy, url, property tests
105105npm 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)
107108npm run check # typecheck + both unit suites
108109```
109110
@@ -138,4 +139,4 @@ Everything was tested against a fake `fm`. These are open until someone runs it
138139- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
139140- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
140141- 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 @@
11{
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";
33
44 inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
55
@@ -58,7 +58,8 @@
5858 description = "Diagonal: names tab groups with Apple's on-device model";
5959 path = "${host}/bin/diagonal-host";
6060 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);
6263 };
6364 in
6465 {
@@ -69,9 +70,14 @@
6970 default = extensionId;
7071 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.";
7172 };
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+ };
7278 browsers = lib.mkOption {
7379 type = lib.types.listOf lib.types.str;
74− default = [ "BraveSoftware/Brave-Browser" ];
80+ default = [ "BraveSoftware/Brave-Browser" "BraveSoftware/Brave-Origin" "Google/Chrome" ];
7581 example = [ "BraveSoftware/Brave-Origin" "Google/Chrome" "Microsoft Edge" "Arc/User Data" ];
7682 description = "Browser profile folders under ~/Library/Application Support to install the host manifest into.";
7783 };
host/diagonal-host.py+29−10
@@ -25,9 +25,12 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
2525import prompts # noqa: E402
2626import validate # noqa: E402
2727
28−HOST_VERSION = "0.1.0"
28+HOST_VERSION = "0.1.0" # release builds stamp the extension version here (scripts/build.mjs)
2929HOST_NAME = "io.diagonal.host"
3030ALLOWED_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 = ""
3134FM = os.environ.get("DIAGONAL_FM", "/usr/bin/fm") # tests point this at host/tests/fake_fm.py
3235SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/Application Support/Diagonal"))
3336SCHEMAS = os.path.join(SUPPORT, "schemas")
@@ -247,11 +250,18 @@ def op_ping(_payload, _opts):
247250 license_required, msg = True, LICENSE_MESSAGE
248251 except subprocess.TimeoutExpired:
249252 ok, msg = False, "fm available timed out"
253+ schema_message = None
250254 if ok:
251− ensure_schemas()
255+ try:
256+ ensure_schemas()
257+ except Fail as f:
258+ schema_message = f.message
252259 mode = organize_mode()
253− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
254− "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
260+ reply = {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
261+ "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
262+ if schema_message:
263+ reply["schemaMessage"] = schema_message
264+ return reply
255265
256266
257267def schemas_ok():
@@ -263,17 +273,22 @@ def ensure_schemas():
263273 accepted after the installer ran). stdout is the native-messaging channel: progress lines are swallowed."""
264274 if schemas_ok():
265275 return
266− with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
276+ out = io.StringIO()
277+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
267278 rc = install_schemas()
268279 if rc == INSTALL_LICENSE_EXIT:
269280 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:])
270285
271286
272287def op_name(payload, opts):
273288 ensure_schemas()
274289 schema = schema_path("name.json")
275290 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')}")
277292 prompt = prompts.build_name_prompt(payload, strict=opts.get("strict", False))
278293 check_budget(prompt, len(payload["items"]))
279294 out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
@@ -304,7 +319,7 @@ def op_organize(payload, opts):
304319 return validate.organize_from_two_calls(labels, assign, payload), len(p1) + len(p2), "two-call"
305320 except validate.ValidationError as e:
306321 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')}")
308323
309324
310325def handle(req):
@@ -508,7 +523,7 @@ def manifest_problems(path, host_path):
508523 problems.append(f"path is {m.get('path')!r}")
509524 elif not os.access(host_path, os.X_OK):
510525 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():
512527 problems.append(f"allowed_origins is {m.get('allowed_origins')}")
513528 return problems
514529
@@ -569,11 +584,15 @@ def unregister():
569584
570585def manifest_for(path):
571586 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]
573592
574593
575594def origin_ok(arg):
576− return isinstance(arg, str) and arg.rstrip("/") + "/" == ALLOWED_ORIGIN
595+ return isinstance(arg, str) and arg.rstrip("/") + "/" in allowed_origins()
577596
578597
579598def main(argv=None):
host/tests/test_host.py+20
@@ -55,6 +55,17 @@ class Origin(HostCase):
5555 code, reply, _ = self.run_host({"v": 1, "id": "p", "op": "ping", "payload": {}}, origin=host.ALLOWED_ORIGIN.rstrip("/"))
5656 self.assertTrue(reply["ok"])
5757
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+
5869 def test_origin_is_pinned(self):
5970 self.assertRegex(host.ALLOWED_ORIGIN, r"^chrome-extension://[a-p]{32}/$")
6071
@@ -81,6 +92,15 @@ class Ping(HostCase):
8192 self.assertTrue(r["result"]["licenseRequired"])
8293 self.assertIn("sudo fm license", r["result"]["fmMessage"])
8394
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+
84104 def test_ping_writes_missing_schemas_once_fm_works(self):
85105 for f in os.listdir(os.path.join(self.support, "schemas")):
86106 os.remove(os.path.join(self.support, "schemas", f))
package.json+2−1
@@ -12,7 +12,8 @@
1212 "test:host": "cd host && python3 -m unittest discover -s tests -v",
1313 "gen-emoji": "node scripts/gen-emoji.mjs",
1414 "zip": "node scripts/build.mjs && cd dist && zip -qr ../diagonal-extension.zip .",
15− "smoke": "node scripts/build.mjs && node test/e2e/smoke.mjs",
15+ "zip:store": "node scripts/build.mjs --store && cd dist-store && zip -qr ../diagonal-store.zip .",
16+ "smoke": "node scripts/build.mjs && node test/e2e/smoke.mjs && node test/e2e/selfupdate.mjs",
1617 "icons": "python3 scripts/render-icons.py",
1718 "check": "tsc --noEmit && vitest run && cd host && python3 -m unittest discover -s tests"
1819 },
scripts/build.mjs+16−2
@@ -1,11 +1,14 @@
11// 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.
24import * as esbuild from "esbuild";
35import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
46import { dirname, join } from "node:path";
57import { fileURLToPath } from "node:url";
68
79const root = join(dirname(fileURLToPath(import.meta.url)), "..");
8−const dist = join(root, "dist");
10+const store = process.argv.includes("--store");
11+const dist = join(root, store ? "dist-store" : "dist");
912const watch = process.argv.includes("--watch");
1013
1114rmSync(dist, { recursive: true, force: true });
@@ -30,14 +33,25 @@ function copyStatic() {
3033 if (!/^\d{4}\.\d{2}\.\d{2}\.\d{4}$/.test(stamp)) throw new Error(`DIAGONAL_VERSION must be YYYY.MM.DD.HHMM, got ${stamp}`);
3134 manifest.version = stamp;
3235 }
36+ if (store) {
37+ delete manifest.key;
38+ manifest.version = manifest.version.split(".").map((n) => String(Number(n))).join(".");
39+ }
3340 writeFileSync(join(dist, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
3441 cpSync(join(root, "src/popup/popup.html"), join(dist, "popup.html"));
3542 cpSync(join(root, "src/options/options.html"), join(dist, "options.html"));
3643 cpSync(join(root, "src/shared/ui.css"), join(dist, "ui.css"));
3744 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
3846 // The native host and its installer ride along, so the release zip is all a user needs.
3947 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);
4155 cpSync(join(root, "extension-id"), join(dist, "extension-id"));
4256 cpSync(join(root, "scripts/install-manifest.sh"), join(dist, "install-host.command"));
4357 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
119119 case "LICENSE_REQUIRED":
120120 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.";
121121 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`;
123123 case "MODEL_UNAVAILABLE":
124124 return `On-device model unavailable: ${error.message}. Check System Settings → Apple Intelligence & Siri.`;
125125 case "RATE_LIMITED":
src/background/index.ts+24−1
@@ -6,6 +6,7 @@ import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE
66import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
77import { withDefaults, type Settings } from "./settings";
88import { 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";
910import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
1011
1112/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
@@ -16,6 +17,7 @@ const ALARM_NAMING = "naming-fallback";
1617const ALARM_TIDY = "tidy-sweep";
1718const ALARM_HOST = "host-retry";
1819const ALARM_AUTO = "auto-organize";
20+const ALARM_UPDATE = "self-update";
1921const HOST_RETRY_MS = 600_000;
2022
2123// ----- state cache ------------------------------------------------------------------------------
@@ -131,7 +133,7 @@ async function ping(): Promise<HostReply<PingResult>> {
131133 } else if (!reply.result.fmAvailable) {
132134 recordHostError({ code: "MODEL_UNAVAILABLE", message: reply.result.fmMessage || "fm reports the model unavailable" });
133135 } 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" });
135137 } else {
136138 h.pausedUntil = undefined;
137139 h.lastError = undefined;
@@ -466,8 +468,28 @@ if (GROUPS_SUPPORTED) {
466468 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
467469}
468470
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+
469490chrome.alarms.onAlarm.addListener((alarm) => {
470491 void (async () => {
492+ if (alarm.name === ALARM_UPDATE) return void checkForUpdate().catch((e) => logError("self-update", e));
471493 await ready;
472494 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
473495 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
@@ -537,6 +559,7 @@ chrome.runtime.onMessage.addListener((msg: Msg, sender, sendResponse) => {
537559async function handleCommand(msg: Msg): Promise<unknown> {
538560 switch (msg.cmd) {
539561 case "status":
562+ void checkForUpdate().catch((e) => logError("self-update", e)); // opening the popup checks too
540563 return statusFor(msg.windowId);
541564 case "ping":
542565 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 {
8686 fmMessage: string;
8787 licenseRequired?: boolean;
8888 schemasOk: boolean;
89+ schemaMessage?: string;
8990 organizeMode?: string;
9091}
9192
src/shared/health.ts+7−2
@@ -47,7 +47,8 @@ export function notices(s: Health): Notice[] {
4747 out.push({
4848 tone: "bad", title: "Finish setting up Diagonal",
4949 body: "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal.",
50− command: "brew reinstall diagonal",
50+ // A Web Store install has an update_url and may never have had the cask; an unpacked one came from it.
51+ command: chrome.runtime.getManifest().update_url ? "brew install iko-soy/tap/diagonal" : "brew reinstall diagonal",
5152 });
5253 break;
5354 case "LICENSE_REQUIRED":
@@ -69,7 +70,11 @@ export function notices(s: Health): Notice[] {
6970 out.push({ tone: "bad", title: "The helper doesn't recognize this extension", body: "Reinstalling registers it again.", command: "brew reinstall diagonal" });
7071 break;
7172 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+ });
7378 break;
7479 default:
7580 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+});