on master help sign in
Register the native host with every Chromium browser

The installer now writes io.diagonal.host.json into the NativeMessagingHosts folder of every Chromium browser under ~/Library/Application Support (Chrome, Brave including Brave Origin, Edge, Vivaldi, Arc, Opera, and any other folder that looks like a Chromium profile), and --unregister removes them all. The extension skips tab-group work in browsers without chrome.tabGroups and says so in the popup. Wording changed from Brave to Chromium browsers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdKDAYoqmGehyHsV42LhJW

commit6ad7ba181cb727c5db5c0a10f74334a239d6b296 authorClaude <noreply@anthropic.com> dateWed 23 Sep 2026, 20:30 (2 weeks ago) parent4a50725
10 files changed+266−120
README.md+14−14 flake.nix+9−8 host/diagonal-host.py+122 host/tests/test_host.py+59−1 scripts/install-manifest.sh+7−53 src/background/host.ts+3−2 src/background/index.ts+47−40 src/options/options.ts+2−1 src/popup/popup.ts+2 src/shared/url.ts+1−1
README.md+14−14
@@ -1,6 +1,6 @@
11# Diagonal
22
3−Dia-style tab groups for Brave on macOS, named by Apple's on-device model through the `fm` command-line tool over Chromium native messaging. No HTTP server, no cloud call, no model download.
3+Dia-style tab groups for Chromium browsers on macOS (Brave, Chrome, Edge, Vivaldi, Arc, Opera, …), named by Apple's on-device model through the `fm` command-line tool over Chromium native messaging. No HTTP server, no cloud call, no model download.
44
55You open and close tabs as usual. Diagonal does the rest on its own; there are no shortcuts and nothing to press.
66
@@ -16,7 +16,7 @@ The model sees a tab's title, address and `<meta name="description">`, and nothi
1616
1717## Requirements
1818
19−- Brave stable 1.95+ (Chromium 121+ APIs are used)
19+- A Chromium browser based on Chromium 121 or newer that lets extensions manage tab groups (`chrome.tabGroups`). Chrome, Brave (including Brave Origin), Edge and Chromium do. Browsers with their own tab grouping, such as Arc, Opera and Vivaldi, may not expose it; if so, Diagonal's popup says so and it stays idle.
2020- macOS 27 on Apple silicon, with Apple Intelligence on and the on-device model downloaded (`/usr/bin/fm` ships with macOS 27)
2121- Python 3 (standard library only) for the host
2222
@@ -28,23 +28,23 @@ The model sees a tab's title, address and `<meta name="description">`, and nothi
2828brew install iko-soy/tap/diagonal
2929```
3030
31−This downloads the latest release, puts the extension at `~/Library/Application Support/Diagonal/extension`, installs the native host and registers it with Brave.
31+This downloads the latest release, puts the extension at `~/Library/Application Support/Diagonal/extension`, installs the native host and registers it with every Chromium browser on the Mac.
3232
3333Apple's `fm` tool refuses to run until its terms are accepted once per Mac. If they aren't yet, the install shows them (through `sudo fm license`, so it asks for your password) and you agree or decline there. If you decline, the install still finishes and tells you to run `sudo fm license` when you're ready; until then Diagonal's popup says the same. The install ends with "Diagonal's host is ready." once everything works.
3434
35−Then load the extension in Brave once:
35+Then load the extension once, in each browser you use:
3636
37−1. Open `brave://extensions` and turn on **Developer mode**.
37+1. Open `chrome://extensions` (it works in Brave, Edge and the rest too) and turn on **Developer mode**.
38382. Click **Load unpacked**, press **Cmd+Shift+G** and paste `~/Library/Application Support/Diagonal/extension`.
3939
40−To update: `brew upgrade --cask --greedy diagonal`, then click reload on Diagonal at `brave://extensions`. `brew uninstall diagonal` removes the extension folder, the host and its Brave manifest; `brew uninstall --zap diagonal` also removes the fm schemas and logs.
40+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.
4141
42−Brave only installs extensions on its own from the Chrome Web Store, so the one Load unpacked step stays until Diagonal is published there.
42+Chromium browsers only install extensions on their own from a web store, so the one Load unpacked step stays until Diagonal is published there.
4343
4444### From a release zip
4545
46461. Download `diagonal-extension-<version>.zip` from the [latest release](https://github.com/iko-soy/diagonal/releases/latest) and unzip it somewhere it can stay, for example `~/Applications/Diagonal`.
47−2. Open `brave://extensions`, turn on **Developer mode**, click **Load unpacked** and pick that folder.
47+2. Open `chrome://extensions`, turn on **Developer mode**, click **Load unpacked** and pick that folder.
48483. Install the native host once, from Terminal:
4949
5050 ```sh
@@ -52,7 +52,7 @@ Brave only installs extensions on its own from the Chrome Web Store, so the one
5252 ```
5353
5454 (Type `bash `, then drag `install-host.command` from the folder into the Terminal window.) If it says python3 is not set up, run `xcode-select --install` and try again. It shows Apple's terms for `fm` if they haven't been accepted on this Mac yet, and ends with "Diagonal's host is ready." when everything works.
55−4. Open Diagonal's settings and click **Run self-test**. Brave does not need a restart.
55+4. Open Diagonal's settings and click **Run self-test**. The browser does not need a restart.
5656
5757The zip carries the host (`host/`), `extension-id` and `install-host.command`, which is the same script as `scripts/install-manifest.sh`. When you update to a newer release, rerun step 3.
5858
@@ -61,7 +61,7 @@ The zip carries the host (`host/`), `extension-id` and `install-host.command`, w
6161#### 1. The extension
6262
63631. `npm ci && npm run build` writes the unpacked extension to `dist/`.
64−2. Open `brave://extensions`, turn on **Developer mode**, click **Load unpacked** and pick `dist/`.
64+2. Open `chrome://extensions`, turn on **Developer mode**, click **Load unpacked** and pick `dist/`.
65653. Check that the ID shown is `mpnodlalikgeehnlnofdkpgapkmbkjdf`. It is pinned by the `key` in `manifest.json`, so it stays the same across reloads and machines.
6666
6767#### 2. The native host
@@ -69,10 +69,10 @@ The zip carries the host (`host/`), `extension-id` and `install-host.command`, w
6969Without Nix:
7070
7171```sh
72−scripts/install-manifest.sh # add --beta / --nightly / --all-channels for other Brave channels
72+scripts/install-manifest.sh
7373```
7474
75−This copies `host/` to `~/.local/share/diagonal-host`, links `~/.local/bin/diagonal-host`, pins its shebang to your `python3`, writes `~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/io.diagonal.host.json`, runs `diagonal-host --install-schemas`, then `diagonal-host --selftest`.
75+This copies `host/` to `~/.local/share/diagonal-host`, links `~/.local/bin/diagonal-host`, pins its shebang to your `python3`, runs `diagonal-host --register`, which writes `io.diagonal.host.json` into the `NativeMessagingHosts` folder of every Chromium browser's profile folder under `~/Library/Application Support` (known ones such as `Google/Chrome`, `BraveSoftware/Brave-Origin`, `Microsoft Edge`, `Arc/User Data`, plus any other folder with a Chromium `Local State` and browsing history) and checks each one the way the browser reads it, then runs `diagonal-host --install-schemas`, then `diagonal-host --selftest`.
7676
7777With Nix (nix-darwin + home-manager):
7878
@@ -83,7 +83,7 @@ diagonal.url = "path:/path/to/diagonal"; # or a git URL once it lives in a rep
8383# home-manager config
8484imports = [ inputs.diagonal.homeManagerModules.default ];
8585programs.diagonal.enable = true;
86−# programs.diagonal.channels = [ "Brave-Browser" "Brave-Browser-Beta" ];
86+# programs.diagonal.browsers = [ "BraveSoftware/Brave-Origin" "Google/Chrome" ];
8787```
8888
8989`nix build .#extension` builds `dist/` into `./result`; `nix build .#diagonal-host` builds the host.
@@ -125,7 +125,7 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
125125- **Grey is reserved for the Parked group.** Site colours hash over the other eight colours.
126126- **Organize only adds tabs to Diagonal's own groups,** not to groups you made by hand, unless "Name my own groups" is on.
127127- **An Organize group whose title fails validation is still created,** with the hostname as a placeholder, and the naming loop names it.
128−- **Restarts.** Tab and group IDs change when Brave restarts, so records are matched to live groups by title and colour, which keeps managed groups managed.
128+- **Restarts.** Tab and group IDs change when the browser restarts, so records are matched to live groups by title and colour, which keeps managed groups managed.
129129
130130## Not verified on a real Mac yet
131131
flake.nix+9−8
@@ -63,26 +63,27 @@
6363 in
6464 {
6565 options.programs.diagonal = {
66− enable = lib.mkEnableOption "the Diagonal native messaging host for Brave";
66+ enable = lib.mkEnableOption "the Diagonal native messaging host for Chromium browsers";
6767 extensionId = lib.mkOption {
6868 type = lib.types.str;
6969 default = extensionId;
7070 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.";
7171 };
72− channels = lib.mkOption {
73− type = lib.types.listOf (lib.types.enum [ "Brave-Browser" "Brave-Browser-Beta" "Brave-Browser-Nightly" ]);
74− default = [ "Brave-Browser" ];
75− description = "Brave channels to install the host manifest for.";
72+ browsers = lib.mkOption {
73+ type = lib.types.listOf lib.types.str;
74+ default = [ "BraveSoftware/Brave-Browser" ];
75+ 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.";
7677 };
7778 };
7879
7980 config = lib.mkIf cfg.enable {
8081 home.packages = [ host ];
8182 home.file = lib.listToAttrs (map
82− (ch: lib.nameValuePair
83− "Library/Application Support/BraveSoftware/${ch}/NativeMessagingHosts/io.diagonal.host.json"
83+ (dir: lib.nameValuePair
84+ "Library/Application Support/${dir}/NativeMessagingHosts/io.diagonal.host.json"
8485 { text = builtins.toJSON manifest; })
85− cfg.channels);
86+ cfg.browsers);
8687 # `|| true`: a Mac without Apple Intelligence still activates.
8788 home.activation.diagonalSchemas = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
8889 ${host}/bin/diagonal-host --install-schemas || true
host/diagonal-host.py+122
@@ -449,6 +449,124 @@ def selftest():
449449 return 0 if failures == 0 else 1
450450
451451
452+# ----- registering with browsers ------------------------------------------------------------------
453+
454+# Chromium reads per-user host manifests from <user data dir>/NativeMessagingHosts. Known user data
455+# dirs under ~/Library/Application Support; anything else that looks like a Chromium browser profile
456+# is found by browser_dirs()' scan.
457+KNOWN_BROWSER_DIRS = [
458+ "Google/Chrome", "Google/Chrome Beta", "Google/Chrome Dev", "Google/Chrome Canary", "Chromium",
459+ "BraveSoftware/Brave-Browser", "BraveSoftware/Brave-Origin", "BraveSoftware/Brave-Browser-Beta",
460+ "BraveSoftware/Brave-Browser-Nightly", "Microsoft Edge", "Microsoft Edge Beta", "Microsoft Edge Dev",
461+ "Microsoft Edge Canary", "Vivaldi", "Vivaldi Snapshot", "Arc/User Data", "Dia/User Data",
462+ "com.operasoftware.Opera", "com.operasoftware.OperaGX", "Thorium", "net.imput.helium", "Yandex/YandexBrowser",
463+]
464+BROWSER_SCAN_DEPTH = 3
465+REGISTERED_LIST = "registered-browsers.txt" # next to the host: where --register wrote manifests
466+
467+
468+def app_support():
469+ return os.environ.get("DIAGONAL_APP_SUPPORT", os.path.expanduser("~/Library/Application Support"))
470+
471+
472+def looks_like_browser(user_data_dir):
473+ """A Chromium browser's user data dir: `Local State` plus a profile with browsing data.
474+ Electron apps also have `Local State`, but no History, Favicons or Top Sites."""
475+ if not os.path.isfile(os.path.join(user_data_dir, "Local State")):
476+ return False
477+ try:
478+ profiles = [e for e in os.listdir(user_data_dir) if e == "Default" or e.startswith("Profile ")]
479+ except OSError:
480+ return False
481+ return any(os.path.exists(os.path.join(user_data_dir, p, f)) for p in profiles for f in ("History", "Favicons", "Top Sites"))
482+
483+
484+def browser_dirs():
485+ base = app_support()
486+ found = [os.path.join(base, rel) for rel in KNOWN_BROWSER_DIRS if os.path.isdir(os.path.join(base, rel))]
487+ for root, dirs, _files in os.walk(base):
488+ depth = os.path.relpath(root, base).count(os.sep) + (root != base)
489+ if root != base and (root in found or looks_like_browser(root)):
490+ if root not in found:
491+ found.append(root)
492+ dirs[:] = [] # profiles inside need no scan
493+ continue
494+ if depth >= BROWSER_SCAN_DEPTH or os.path.basename(root) == "Diagonal":
495+ dirs[:] = []
496+ return sorted(set(found))
497+
498+
499+def manifest_problems(path, host_path):
500+ try:
501+ m = json.load(open(path, encoding="utf-8"))
502+ except (OSError, ValueError) as e:
503+ return [f"unreadable ({e})"]
504+ problems = []
505+ if m.get("name") != HOST_NAME:
506+ problems.append(f"name is {m.get('name')!r}")
507+ if m.get("path") != host_path or not os.path.isabs(host_path):
508+ problems.append(f"path is {m.get('path')!r}")
509+ elif not os.access(host_path, os.X_OK):
510+ problems.append(f"host {host_path} is missing or not executable")
511+ if m.get("allowed_origins") != [ALLOWED_ORIGIN]:
512+ problems.append(f"allowed_origins is {m.get('allowed_origins')}")
513+ return problems
514+
515+
516+def register(host_path):
517+ """Write this host's manifest into every Chromium browser's NativeMessagingHosts folder, check each
518+ one the way the browser will read it, and remember where they went for --unregister."""
519+ host_path = os.path.abspath(host_path)
520+ body = json.dumps(manifest_for(host_path), indent=2) + "\n"
521+ dirs = browser_dirs()
522+ if not dirs:
523+ print("No Chromium browser found. Open your browser once, then run this again.", file=sys.stderr)
524+ return 1
525+ written, failures = [], 0
526+ for d in dirs:
527+ target_dir = os.path.join(d, "NativeMessagingHosts")
528+ target = os.path.join(target_dir, f"{HOST_NAME}.json")
529+ try:
530+ os.makedirs(target_dir, exist_ok=True)
531+ with open(target + ".tmp", "w", encoding="utf-8") as f:
532+ f.write(body)
533+ os.replace(target + ".tmp", target) # a browser never reads a half-written file
534+ except OSError as e:
535+ print(f" {os.path.relpath(d, app_support())}: could not write ({e})", file=sys.stderr)
536+ failures += 1
537+ continue
538+ problems = manifest_problems(target, host_path)
539+ if problems:
540+ print(f" {os.path.relpath(d, app_support())}: {'; '.join(problems)}", file=sys.stderr)
541+ failures += 1
542+ else:
543+ print(f"registered: {os.path.relpath(d, app_support())}")
544+ written.append(target)
545+ try:
546+ with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), REGISTERED_LIST), "w", encoding="utf-8") as f:
547+ f.write("\n".join(written) + "\n")
548+ except OSError:
549+ pass
550+ return 1 if failures else 0
551+
552+
553+def unregister():
554+ """Remove every manifest --register wrote, plus any other copy of ours a browser folder still has."""
555+ listed = []
556+ try:
557+ listed = open(os.path.join(os.path.dirname(os.path.realpath(__file__)), REGISTERED_LIST), encoding="utf-8").read().split("\n")
558+ except OSError:
559+ pass
560+ candidates = set(filter(None, listed)) | {os.path.join(d, "NativeMessagingHosts", f"{HOST_NAME}.json") for d in browser_dirs()}
561+ for path in sorted(candidates):
562+ try:
563+ os.remove(path)
564+ print(f"removed: {path}")
565+ except OSError:
566+ pass
567+ return 0
568+
569+
452570def manifest_for(path):
453571 return {"name": HOST_NAME, "description": "Diagonal: names tab groups with Apple's on-device model",
454572 "path": os.path.abspath(path), "type": "stdio", "allowed_origins": [ALLOWED_ORIGIN]}
@@ -464,6 +582,10 @@ def main(argv=None):
464582 return selftest()
465583 if argv and argv[0] == "--install-schemas":
466584 return install_schemas()
585+ if argv and argv[0] == "--register":
586+ return register(argv[1] if len(argv) > 1 else sys.argv[0])
587+ if argv and argv[0] == "--unregister":
588+ return unregister()
467589 if argv and argv[0] == "--print-manifest":
468590 print(json.dumps(manifest_for(argv[1] if len(argv) > 1 else os.path.realpath(__file__)), indent=2))
469591 return 0
host/tests/test_host.py+59−1
@@ -2,10 +2,13 @@ import io
22import json
33import os
44import re
5+import shutil
56import struct
7+import subprocess
8+import sys
69import unittest
710
8−from support import HOST_DIR, ITEMS3, HostCase, frame, host, unframe
11+from support import HOST_DIR, HOST_SCRIPT, ITEMS3, HostCase, frame, host, unframe
912
1013
1114class Framing(HostCase):
@@ -287,6 +290,61 @@ class InstallSchemas(HostCase):
287290 self.assertEqual(len(self.argv_log()), 1) # stopped after the first refusal
288291
289292
293+class Register(HostCase):
294+ def make_profile(self, rel, browsing=True):
295+ d = os.path.join(self.tmp, "appsupport", rel)
296+ os.makedirs(os.path.join(d, "Default"))
297+ open(os.path.join(d, "Local State"), "w").write("{}")
298+ if browsing:
299+ open(os.path.join(d, "Default", "History"), "w").write("")
300+ return d
301+
302+ def setUp(self):
303+ super().setUp()
304+ self.env["DIAGONAL_APP_SUPPORT"] = os.path.join(self.tmp, "appsupport")
305+ self.host_copy = os.path.join(self.tmp, "bin", "diagonal-host.py")
306+ os.makedirs(os.path.dirname(self.host_copy))
307+ shutil.copy(HOST_SCRIPT, self.host_copy)
308+ for f in ("prompts.py", "validate.py", "emoji.txt"):
309+ shutil.copy(os.path.join(HOST_DIR, f), os.path.dirname(self.host_copy))
310+
311+ def run_copy(self, *args):
312+ return subprocess.run([sys.executable, self.host_copy, *args], capture_output=True, text=True, env=self.env, timeout=30)
313+
314+ def test_registers_with_every_chromium_browser_and_only_those(self):
315+ origin = self.make_profile("BraveSoftware/Brave-Origin")
316+ chrome = self.make_profile("Google/Chrome")
317+ arc = self.make_profile("Arc/User Data")
318+ unknown = self.make_profile("SomeVendor/NewBrowser")
319+ electron = self.make_profile("Slack", browsing=False)
320+ p = self.run_copy("--register", self.host_copy)
321+ self.assertEqual(p.returncode, 0, p.stderr)
322+ for d in (origin, chrome, arc, unknown):
323+ m = json.load(open(os.path.join(d, "NativeMessagingHosts", "io.diagonal.host.json")))
324+ self.assertEqual(m["path"], self.host_copy)
325+ self.assertEqual(m["allowed_origins"], [host.ALLOWED_ORIGIN])
326+ self.assertFalse(os.path.exists(os.path.join(electron, "NativeMessagingHosts")))
327+ self.assertIn("registered: BraveSoftware/Brave-Origin", p.stdout)
328+
329+ p = self.run_copy("--unregister")
330+ self.assertEqual(p.returncode, 0, p.stderr)
331+ for d in (origin, chrome, arc, unknown):
332+ self.assertFalse(os.path.exists(os.path.join(d, "NativeMessagingHosts", "io.diagonal.host.json")))
333+
334+ def test_names_a_host_that_cannot_run(self):
335+ self.make_profile("Google/Chrome")
336+ os.chmod(self.host_copy, 0o644)
337+ p = self.run_copy("--register", self.host_copy)
338+ self.assertEqual(p.returncode, 1)
339+ self.assertIn("missing or not executable", p.stderr)
340+
341+ def test_no_browser_found(self):
342+ os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
343+ p = self.run_copy("--register", self.host_copy)
344+ self.assertEqual(p.returncode, 1)
345+ self.assertIn("No Chromium browser found", p.stderr)
346+
347+
290348class SelfTest(HostCase):
291349 def test_selftest_passes_with_a_working_fm(self):
292350 self.respond(json.dumps({"title": "Rust async runtimes", "emoji": "🦀"}))
scripts/install-manifest.sh+7−53
@@ -3,8 +3,8 @@
33# scripts/install-manifest.sh [--beta] [--nightly] [--all-channels]
44# The release zip ships this same script as install-host.command next to host/ and extension-id,
55# so `bash install-host.command` works from the unzipped extension folder too.
6−# Copies host/ to ~/.local/share/diagonal-host, links ~/.local/bin/diagonal-host to it, writes the Brave
7−# host manifest with that absolute path and the pinned extension ID, then installs the fm schemas
6+# Copies host/ to ~/.local/share/diagonal-host, links ~/.local/bin/diagonal-host to it, registers the host
7+# (absolute path, pinned extension ID) with every Chromium browser on this Mac, then installs the fm schemas
88# and runs the self-test. Safe to re-run.
99set -euo pipefail
1010# Keep a copy of this run's output for diagnosis (brew's own output scrolls away).
@@ -17,22 +17,13 @@ trap 'echo "Diagonal host install failed at line $LINENO: $BASH_COMMAND" >&2' ER
1717HERE="$(cd "$(dirname "$0")" && pwd)"
1818if [[ -d "$HERE/host" ]]; then cd "$HERE"; else cd "$HERE/.."; fi
1919
20−CHANNELS=("Brave-Browser")
20+# (--beta, --nightly and --all-channels are accepted for old scripts; every browser is registered now.)
2121for arg in "$@"; do
2222 case "$arg" in
23− --beta) CHANNELS+=("Brave-Browser-Beta") ;;
24− --nightly) CHANNELS+=("Brave-Browser-Nightly") ;;
25− --all-channels) CHANNELS=("Brave-Browser" "Brave-Browser-Beta" "Brave-Browser-Nightly") ;;
23+ --beta|--nightly|--all-channels) ;;
2624 *) echo "unknown option: $arg" >&2; exit 2 ;;
2725 esac
2826done
29−# Also register with every other Brave channel that has been run on this Mac (Beta, Nightly),
30−# since each one reads host manifests from its own folder.
31−for ch in Brave-Browser-Beta Brave-Browser-Nightly; do
32− if [[ -d "$HOME/Library/Application Support/BraveSoftware/$ch" && " ${CHANNELS[*]} " != *" $ch "* ]]; then
33− CHANNELS+=("$ch")
34− fi
35−done
3627
3728ID=$(tr -d '[:space:]' < extension-id 2>/dev/null || true)
3829if [[ -z "$ID" ]]; then
@@ -59,47 +50,10 @@ xattr -dr com.apple.quarantine "$SHARE" 2>/dev/null || true
5950ln -sf "$SHARE/diagonal-host.py" "$BIN"
6051echo "host: $BIN -> $SHARE/diagonal-host.py"
6152
62−# Written here rather than by the host, so a python3 problem cannot leave an empty manifest behind,
63−# and moved into place so Brave never reads a half-written one.
64−MANIFEST=$(cat <<JSON
65−{
66− "name": "io.diagonal.host",
67− "description": "Diagonal: names tab groups with Apple's on-device model",
68− "path": "$BIN",
69− "type": "stdio",
70− "allowed_origins": ["chrome-extension://$ID/"]
71−}
72−JSON
73−)
7453"$BIN" --version >/dev/null # the host must start with the python3 it was pinned to
75−for ch in "${CHANNELS[@]}"; do
76− DIR="$HOME/Library/Application Support/BraveSoftware/$ch/NativeMessagingHosts"
77− mkdir -p "$DIR"
78− printf '%s\n' "$MANIFEST" > "$DIR/io.diagonal.host.json.tmp"
79− mv -f "$DIR/io.diagonal.host.json.tmp" "$DIR/io.diagonal.host.json"
80− echo "manifest: $DIR/io.diagonal.host.json"
81−done
82−
83−# Check what Brave will check, so a problem is named here instead of as "host not found" later.
84−for ch in "${CHANNELS[@]}"; do
85− "$PYTHON" - "$HOME/Library/Application Support/BraveSoftware/$ch/NativeMessagingHosts/io.diagonal.host.json" "$ID" <<'PY'
86−import json, os, sys
87−path, ext_id = sys.argv[1], sys.argv[2]
88−m = json.load(open(path))
89−problems = []
90−if m.get("name") != "io.diagonal.host":
91− problems.append(f"name is {m.get('name')!r}")
92−host = m.get("path", "")
93−if not os.path.isabs(host):
94− problems.append(f"path {host!r} is not absolute")
95−elif not os.access(host, os.X_OK):
96− problems.append(f"host {host} is missing or not executable")
97−if m.get("allowed_origins") != [f"chrome-extension://{ext_id}/"]:
98− problems.append(f"allowed_origins is {m.get('allowed_origins')}")
99−if problems:
100− sys.exit("manifest check failed for " + path + ": " + "; ".join(problems))
101−PY
102−done
54+# Writes the manifest into every Chromium browser's NativeMessagingHosts folder (Chrome, Brave and
55+# Brave Origin, Edge, Vivaldi, Arc, Opera, …) and checks each one the way the browser reads it.
56+"$BIN" --register "$BIN"
10357
10458FM="${DIAGONAL_FM:-/usr/bin/fm}"
10559
src/background/host.ts+3−2
@@ -2,7 +2,8 @@ import type { HostError, HostErrorCode } from "./state";
22
33/** Section 8's wire protocol, from the extension's side. One `sendNativeMessage` per request. */
44export const HOST_NAME = "io.diagonal.host";
5−export const HOST_MANIFEST_DIR = "~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/";
5+/** Each Chromium browser reads its own folder, e.g. Google/Chrome, BraveSoftware/Brave-Origin, Microsoft Edge. */
6+export const HOST_MANIFEST_DIR = "~/Library/Application Support/<browser>/NativeMessagingHosts/";
67export const HOST_MANIFEST_PATH = `${HOST_MANIFEST_DIR}${HOST_NAME}.json`;
78
89export type Op = "ping" | "name" | "organize";
@@ -111,7 +112,7 @@ export function explain(error: HostError | undefined, extensionId = ""): string
111112 if (!error) return "";
112113 switch (error.code) {
113114 case "HOST_NOT_FOUND":
114− return `Native host not installed (no manifest at ${HOST_MANIFEST_PATH}). Installed with Homebrew? Run: brew reinstall diagonal. Otherwise run: bash <the Diagonal folder>/install-host.command`;
115+ return `Native host not installed for this browser (no manifest at ${HOST_MANIFEST_PATH}). Installed with Homebrew? Run: brew reinstall diagonal. Otherwise run: bash <the Diagonal folder>/install-host.command. Either one registers the host with every Chromium browser on this Mac.`;
115116 case "HOST_FORBIDDEN":
116117 case "FORBIDDEN_ORIGIN":
117118 return `The host does not allow this extension. Its manifest needs "allowed_origins": ["chrome-extension://${extensionId}/"]`;
src/background/index.ts+47−40
@@ -10,6 +10,8 @@ import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, und
1010
1111/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
1212
13+const GROUPS_SUPPORTED = typeof chrome.tabGroups?.query === "function";
14+
1315const ALARM_NAMING = "naming-fallback";
1416const ALARM_TIDY = "tidy-sweep";
1517const ALARM_HOST = "host-retry";
@@ -335,7 +337,7 @@ async function tabById(id: number): Promise<chrome.tabs.Tab | undefined> {
335337/**
336338 * On startup and install: rebuild the state model from the live browser. Tab and group ids change
337339 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
338− * (groups), which keeps managed groups managed after Brave restarts.
340+ * (groups), which keeps managed groups managed after the browser restarts.
339341 */
340342async function reconcile(): Promise<void> {
341343 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
@@ -417,48 +419,52 @@ async function boot(reason: "startup" | "installed"): Promise<void> {
417419
418420// ----- browser events --------------------------------------------------------------------------
419421
420−chrome.runtime.onStartup.addListener(() => void boot("startup"));
421−chrome.runtime.onInstalled.addListener(() => void boot("installed"));
422−
423−chrome.tabs.onCreated.addListener((tab) =>
424− serial("tabs.onCreated", async () => {
425− const opener = tab.openerTabId !== undefined ? await tabById(tab.openerTabId) : undefined;
426− await feed({ type: "tabCreated", tab: snapTab(tab), opener: opener && snapTab(opener) });
427− }),
428−);
429−
430−chrome.tabs.onUpdated.addListener((_id, change, tab) => {
431− if (!("title" in change || "url" in change || "status" in change || "groupId" in change || "pinned" in change)) return;
432− void serial("tabs.onUpdated", () => feed({ type: "tabUpdated", tab: snapTab(tab) }));
433−});
434−
435−const refetch = (where: string) => (tabId: number) =>
436− void serial(where, async () => {
437− const t = await tabById(tabId);
438− if (t) await feed({ type: "tabUpdated", tab: snapTab(t) });
422+// Every Chromium browser has tabs, but not all let extensions manage tab groups (chrome.tabGroups,
423+// Chromium 89+): without it Diagonal stays idle and the popup says why.
424+if (GROUPS_SUPPORTED) {
425+ chrome.runtime.onStartup.addListener(() => void boot("startup"));
426+ chrome.runtime.onInstalled.addListener(() => void boot("installed"));
427+
428+ chrome.tabs.onCreated.addListener((tab) =>
429+ serial("tabs.onCreated", async () => {
430+ const opener = tab.openerTabId !== undefined ? await tabById(tab.openerTabId) : undefined;
431+ await feed({ type: "tabCreated", tab: snapTab(tab), opener: opener && snapTab(opener) });
432+ }),
433+ );
434+
435+ chrome.tabs.onUpdated.addListener((_id, change, tab) => {
436+ if (!("title" in change || "url" in change || "status" in change || "groupId" in change || "pinned" in change)) return;
437+ void serial("tabs.onUpdated", () => feed({ type: "tabUpdated", tab: snapTab(tab) }));
439438 });
440−chrome.tabs.onMoved.addListener(refetch("tabs.onMoved"));
441−chrome.tabs.onAttached.addListener(refetch("tabs.onAttached"));
442−
443−chrome.tabs.onRemoved.addListener((tabId) => void serial("tabs.onRemoved", () => feed({ type: "tabRemoved", tabId })));
444−
445−chrome.tabs.onReplaced.addListener((added, removed) =>
446− void serial("tabs.onReplaced", async () => {
447− const rec = state.tabs[removed];
448− delete state.tabs[removed];
449− const t = await tabById(added);
450− if (t) {
451− await feed({ type: "tabUpdated", tab: snapTab(t) });
452− if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
453− }
454− }),
455−);
456439
457−chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
440+ const refetch = (where: string) => (tabId: number) =>
441+ void serial(where, async () => {
442+ const t = await tabById(tabId);
443+ if (t) await feed({ type: "tabUpdated", tab: snapTab(t) });
444+ });
445+ chrome.tabs.onMoved.addListener(refetch("tabs.onMoved"));
446+ chrome.tabs.onAttached.addListener(refetch("tabs.onAttached"));
447+
448+ chrome.tabs.onRemoved.addListener((tabId) => void serial("tabs.onRemoved", () => feed({ type: "tabRemoved", tabId })));
449+
450+ chrome.tabs.onReplaced.addListener((added, removed) =>
451+ void serial("tabs.onReplaced", async () => {
452+ const rec = state.tabs[removed];
453+ delete state.tabs[removed];
454+ const t = await tabById(added);
455+ if (t) {
456+ await feed({ type: "tabUpdated", tab: snapTab(t) });
457+ if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
458+ }
459+ }),
460+ );
458461
459−chrome.tabGroups.onCreated.addListener((g) => void serial("tabGroups.onCreated", () => feed({ type: "groupCreated", group: snapGroup(g) })));
460−chrome.tabGroups.onUpdated.addListener((g) => void serial("tabGroups.onUpdated", () => feed({ type: "groupUpdated", group: snapGroup(g) })));
461−chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
462+ chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
463+
464+ chrome.tabGroups.onCreated.addListener((g) => void serial("tabGroups.onCreated", () => feed({ type: "groupCreated", group: snapGroup(g) })));
465+ chrome.tabGroups.onUpdated.addListener((g) => void serial("tabGroups.onUpdated", () => feed({ type: "groupUpdated", group: snapGroup(g) })));
466+ chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
467+}
462468
463469chrome.alarms.onAlarm.addListener((alarm) => {
464470 void (async () => {
@@ -637,6 +643,7 @@ async function statusFor(windowId: number | undefined): Promise<unknown> {
637643 size: (await chrome.tabs.query({ groupId: g.id }).catch(() => [])).length,
638644 })),
639645 ),
646+ groupsSupported: GROUPS_SUPPORTED,
640647 extensionId: chrome.runtime.id,
641648 manifestPath: HOST_MANIFEST_PATH,
642649 };
src/options/options.ts+2−1
@@ -125,7 +125,8 @@ async function main(): Promise<void> {
125125 settings = withDefaults((await chrome.storage.local.get("settings")).settings);
126126 renderSettings();
127127 const id = chrome.runtime.id;
128− const path = "~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/io.diagonal.host.json";
128+ // Each Chromium browser has its own folder: Google/Chrome, BraveSoftware/Brave-Browser, BraveSoftware/Brave-Origin, …
129+ const path = "~/Library/Application Support/<browser>/NativeMessagingHosts/io.diagonal.host.json";
129130 const manifest = JSON.stringify({
130131 name: "io.diagonal.host",
131132 description: "Diagonal: names tab groups with Apple's on-device model",
src/popup/popup.ts+2
@@ -5,6 +5,7 @@ let windowId: number | undefined;
55
66interface Status {
77 health: "ok" | "unknown" | "degraded";
8+ groupsSupported: boolean;
89 message: string;
910 lastError?: { code: string; message: string };
1011 lastPing?: { fmAvailable: boolean; schemasOk: boolean };
@@ -45,6 +46,7 @@ function renderStatus(s: Status): void {
4546 const notice = $("notice");
4647 notice.replaceChildren();
4748 const lines: (string | Node)[] = [];
49+ if (!s.groupsSupported) lines.push("This browser doesn't let extensions manage tab groups, so Diagonal can't group tabs here.");
4850 if (s.message) lines.push(s.message);
4951 if (s.timeoutHint) lines.push("Three timeouts in a row: consider raising the timeout in Settings.");
5052 if (s.lastError?.code === "HOST_NOT_FOUND") {
src/shared/url.ts+1−1
@@ -1,6 +1,6 @@
11import { colorForKey, type GroupColor } from "./colors";
22
3−const INTERNAL_PREFIXES = ["brave:", "chrome:", "chrome-extension:", "chrome-search:", "chrome-untrusted:", "devtools:", "edge:", "file:", "about:", "view-source:", "data:", "javascript:", "blob:"];
3+const INTERNAL_PREFIXES = ["brave:", "chrome:", "chrome-extension:", "chrome-search:", "chrome-untrusted:", "devtools:", "edge:", "vivaldi:", "opera:", "file:", "about:", "view-source:", "data:", "javascript:", "blob:"];
44
55/** True for pages that never feed grouping data or prompts (section 11). */
66export function isInternalUrl(url: string | undefined): boolean {