on master help sign in
Rename Grove to Diagonal
0a83dd8Claude
.gitignore+1−1
@@ -2,7 +2,7 @@ node_modules/
2dist/
2dist/
3result
3result
4key.pem
4key.pem
5−grove-extension.zip
5+diagonal-extension.zip
6host/tests/__pycache__/
6host/tests/__pycache__/
7host/__pycache__/
7host/__pycache__/
8__pycache__/
8__pycache__/
README.md+15−15
@@ -1,11 +1,11 @@
1−# Grove
1+# Diagonal
2
2
3Dia-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.
3Dia-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.
4
4
5What it does (v1):
5What it does (v1):
6
6
71. **Opener grouping.** Cmd-click a link from an ungrouped, unpinned tab and both tabs become a group, titled with the site's hostname until the model names it. An opener group that shrinks to one tab dissolves.
71. **Opener grouping.** Cmd-click a link from an ungrouped, unpinned tab and both tabs become a group, titled with the site's hostname until the model names it. An opener group that shrinks to one tab dissolves.
8−2. **Naming loop.** Groups Grove made get an `emoji + 2–4 word` title from the model. The title is recomputed after 4 s of quiet when membership changes, stays put when the model only paraphrases, and is never touched again once you type a title yourself.
8+2. **Naming loop.** Groups Diagonal made get an `emoji + 2–4 word` title from the model. The title is recomputed after 4 s of quiet when membership changes, stays put when the model only paraphrases, and is never touched again once you type a title yourself.
93. **Organize this window** (toolbar or Alt+Shift+G). Clusters ungrouped tabs into topic groups in batches of 18. Undo is available for an hour.
93. **Organize this window** (toolbar or Alt+Shift+G). Clusters ungrouped tabs into topic groups in batches of 18. Undo is available for an hour.
104. **Tidy sweep.** Tabs idle for 24 h move to a collapsed grey "Parked" group (Ask mode by default). After another 48 h they are archived and closed, with restore from the popup.
104. **Tidy sweep.** Tabs idle for 24 h move to a collapsed grey "Parked" group (Ask mode by default). After another 48 h they are archived and closed, with restore from the popup.
11
11
@@ -21,7 +21,7 @@ The model sees a tab's title, address and `<meta name="description">`, and nothi
21
21
22### 1. The extension
22### 1. The extension
23
23
24−The built extension is in `dist/` (or `grove-extension/` next to this folder in the delivery).
24+The built extension is in `dist/` (or `diagonal-extension/` next to this folder in the delivery).
25
25
261. Open `brave://extensions` and turn on **Developer mode**.
261. Open `brave://extensions` and turn on **Developer mode**.
272. Click **Load unpacked** and pick the `dist/` folder.
272. Click **Load unpacked** and pick the `dist/` folder.
@@ -37,25 +37,25 @@ Without Nix:
37scripts/install-manifest.sh # add --beta / --nightly / --all-channels for other Brave channels
37scripts/install-manifest.sh # add --beta / --nightly / --all-channels for other Brave channels
38```
38```
39
39
40−This copies `host/` to `~/.local/share/grove-host`, links `~/.local/bin/grove-host`, pins its shebang to your `python3`, writes `~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/io.grove.host.json`, runs `grove-host --install-schemas`, then `grove-host --selftest`.
40+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`.
41
41
42With Nix (nix-darwin + home-manager):
42With Nix (nix-darwin + home-manager):
43
43
44```nix
44```nix
45# flake inputs
45# flake inputs
46−grove.url = "path:/path/to/grove"; # or a git URL once it lives in a repo
46+diagonal.url = "path:/path/to/diagonal"; # or a git URL once it lives in a repo
47
47
48# home-manager config
48# home-manager config
49−imports = [ inputs.grove.homeManagerModules.default ];
49+imports = [ inputs.diagonal.homeManagerModules.default ];
50−programs.grove.enable = true;
50+programs.diagonal.enable = true;
51−# programs.grove.channels = [ "Brave-Browser" "Brave-Browser-Beta" ];
51+# programs.diagonal.channels = [ "Brave-Browser" "Brave-Browser-Beta" ];
52```
52```
53
53
54−`nix build .#extension` builds `dist/` into `./result`; `nix build .#grove-host` builds the host.
54+`nix build .#extension` builds `dist/` into `./result`; `nix build .#diagonal-host` builds the host.
55
55
56### 3. Check it
56### 3. Check it
57
57
58−Open Grove's settings page and click **Run self-test**. Expect `fmAvailable: true`, `schemasOk: true` and a title for the three fixture tabs. From a terminal, `grove-host --selftest` runs the same checks.
58+Open Diagonal's settings page and click **Run self-test**. Expect `fmAvailable: true`, `schemasOk: true` and a title for the three fixture tabs. From a terminal, `diagonal-host --selftest` runs the same checks.
59
59
60## Keyboard shortcuts
60## Keyboard shortcuts
61
61
@@ -81,20 +81,20 @@ npm run smoke # Linux: loads dist/ in Chromium with the real host and a
81npm run check # typecheck + both unit suites
81npm run check # typecheck + both unit suites
82```
82```
83
83
84−Layout follows the spec: `src/background/` (engine, naming, organize, tidy, host client, state, settings), `src/content/meta.ts`, `src/popup/`, `src/options/`, `src/shared/`, and `host/` (`grove-host.py`, `prompts.py`, `validate.py`, `emoji.txt`, `tests/`).
84+Layout follows the spec: `src/background/` (engine, naming, organize, tidy, host client, state, settings), `src/content/meta.ts`, `src/popup/`, `src/options/`, `src/shared/`, and `host/` (`diagonal-host.py`, `prompts.py`, `validate.py`, `emoji.txt`, `tests/`).
85
85
86`host/emoji.txt` is the single emoji list. `npm run gen-emoji` regenerates `src/shared/emoji.gen.ts` from it.
86`host/emoji.txt` is the single emoji list. `npm run gen-emoji` regenerates `src/shared/emoji.gen.ts` from it.
87
87
88### Pinning a different extension ID
88### Pinning a different extension ID
89
89
90−`scripts/gen-key.sh` creates `key.pem` if it is missing, writes the public key into `manifest.json`, the ID into `extension-id` and into `ALLOWED_ORIGIN` in `host/grove-host.py`, and prints the ID. The shipped ID comes from a key that is not included; you only need the private key to pack a `.crx`. Running the script without a `key.pem` makes a new key and a new ID, so after that rebuild and rerun the host installer.
90+`scripts/gen-key.sh` creates `key.pem` if it is missing, writes the public key into `manifest.json`, the ID into `extension-id` and into `ALLOWED_ORIGIN` in `host/diagonal-host.py`, and prints the ID. The shipped ID comes from a key that is not included; you only need the private key to pack a `.crx`. Running the script without a `key.pem` makes a new key and a new ID, so after that rebuild and rerun the host installer.
91
91
92## Where this differs from the spec, and why
92## Where this differs from the spec, and why
93
93
94- **New tab in group is Alt+Shift+T, not Alt+Cmd+T.** Chromium refuses to load an extension whose manifest suggests Alt+Command+T (it is treated as Ctrl+Alt, which is reserved for AltGr). You can still bind Alt+Cmd+T by hand at `brave://extensions/shortcuts` if Brave accepts it there.
94- **New tab in group is Alt+Shift+T, not Alt+Cmd+T.** Chromium refuses to load an extension whose manifest suggests Alt+Command+T (it is treated as Ctrl+Alt, which is reserved for AltGr). You can still bind Alt+Cmd+T by hand at `brave://extensions/shortcuts` if Brave accepts it there.
95−- **Query strings.** The spec keeps the query only when the path is empty, but also wants `youtube.com/watch?v=…` kept. Grove keeps the whole query when the path is empty and otherwise keeps only content keys (`v`, `q`, `query`, `search_query`, `search`, `s`, `k`, `id`, `p`, `list`, `page`), dropping `utm_*` and similar.
95+- **Query strings.** The spec keeps the query only when the path is empty, but also wants `youtube.com/watch?v=…` kept. Diagonal keeps the whole query when the path is empty and otherwise keeps only content keys (`v`, `q`, `query`, `search_query`, `search`, `s`, `k`, `id`, `p`, `list`, `page`), dropping `utm_*` and similar.
96- **Grey is reserved for the Parked group.** Site colours hash over the other eight colours.
96- **Grey is reserved for the Parked group.** Site colours hash over the other eight colours.
97−- **Organize only adds tabs to Grove's own groups,** not to groups you made by hand, unless "Name my own groups" is on.
97+- **Organize only adds tabs to Diagonal's own groups,** not to groups you made by hand, unless "Name my own groups" is on.
98- **An Organize group whose title fails validation is still created,** with the hostname as a placeholder, and the naming loop names it.
98- **An Organize group whose title fails validation is still created,** with the hostname as a placeholder, and the naming loop names it.
99- **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.
99- **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.
100
100
@@ -102,7 +102,7 @@ Layout follows the spec: `src/background/` (engine, naming, organize, tidy, host
102
102
103Everything was tested against a fake `fm`. These are open until someone runs it on macOS 27 (section 16 of the spec):
103Everything was tested against a fake `fm`. These are open until someone runs it on macOS 27 (section 16 of the spec):
104
104
105−- The `fm schema` flags. `grove-host --install-schemas` tries the nested organize schema and falls back to the two flat schemas plus two calls if that fails. The flags are in `SCHEMA_COMMANDS` at the top of `host/grove-host.py`.
105+- The `fm schema` flags. `diagonal-host --install-schemas` tries the nested organize schema and falls back to the two flat schemas plus two calls if that fails. The flags are in `SCHEMA_COMMANDS` at the top of `host/diagonal-host.py`.
106- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
106- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
107- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
107- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
108- 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.
108- 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.
flake.nix+22−22
@@ -1,5 +1,5 @@
1{
1{
2− description = "Grove: Dia-style tab groups for Brave, named by Apple's on-device model";
2+ description = "Diagonal: Dia-style tab groups for Brave, 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
@@ -13,7 +13,7 @@
13 packages = forAll (pkgs: rec {
13 packages = forAll (pkgs: rec {
14 # The unpacked extension (dist/). Load it from brave://extensions → Load unpacked.
14 # The unpacked extension (dist/). Load it from brave://extensions → Load unpacked.
15 extension = pkgs.buildNpmPackage {
15 extension = pkgs.buildNpmPackage {
16− pname = "grove-extension";
16+ pname = "diagonal-extension";
17 version = "0.1.0";
17 version = "0.1.0";
18 src = ./.;
18 src = ./.;
19 npmDeps = pkgs.importNpmLock { npmRoot = ./.; };
19 npmDeps = pkgs.importNpmLock { npmRoot = ./.; };
@@ -23,24 +23,24 @@
23 };
23 };
24
24
25 # The native host: host/ copied whole, the shebang pinned to Nix's python3.
25 # The native host: host/ copied whole, the shebang pinned to Nix's python3.
26− grove-host = pkgs.stdenvNoCC.mkDerivation {
26+ diagonal-host = pkgs.stdenvNoCC.mkDerivation {
27− pname = "grove-host";
27+ pname = "diagonal-host";
28 version = "0.1.0";
28 version = "0.1.0";
29 src = ./host;
29 src = ./host;
30 dontBuild = true;
30 dontBuild = true;
31 installPhase = ''
31 installPhase = ''
32− mkdir -p $out/libexec/grove-host $out/bin
32+ mkdir -p $out/libexec/diagonal-host $out/bin
33− cp grove-host.py prompts.py validate.py emoji.txt $out/libexec/grove-host/
33+ cp diagonal-host.py prompts.py validate.py emoji.txt $out/libexec/diagonal-host/
34− substituteInPlace $out/libexec/grove-host/grove-host.py \
34+ substituteInPlace $out/libexec/diagonal-host/diagonal-host.py \
35 --replace-fail "#!/usr/bin/env python3" "#!${pkgs.python3}/bin/python3"
35 --replace-fail "#!/usr/bin/env python3" "#!${pkgs.python3}/bin/python3"
36− chmod +x $out/libexec/grove-host/grove-host.py
36+ chmod +x $out/libexec/diagonal-host/diagonal-host.py
37− ln -s $out/libexec/grove-host/grove-host.py $out/bin/grove-host
37+ ln -s $out/libexec/diagonal-host/diagonal-host.py $out/bin/diagonal-host
38 '';
38 '';
39 doInstallCheck = true;
39 doInstallCheck = true;
40− installCheckPhase = "$out/bin/grove-host --version";
40+ installCheckPhase = "$out/bin/diagonal-host --version";
41 };
41 };
42
42
43− default = grove-host;
43+ default = diagonal-host;
44 });
44 });
45
45
46 devShells = forAll (pkgs: {
46 devShells = forAll (pkgs: {
@@ -51,23 +51,23 @@
51
51
52 homeManagerModules.default = { config, pkgs, lib, ... }:
52 homeManagerModules.default = { config, pkgs, lib, ... }:
53 let
53 let
54− cfg = config.programs.grove;
54+ cfg = config.programs.diagonal;
55− host = self.packages.${pkgs.stdenv.hostPlatform.system}.grove-host;
55+ host = self.packages.${pkgs.stdenv.hostPlatform.system}.diagonal-host;
56 manifest = {
56 manifest = {
57− name = "io.grove.host";
57+ name = "io.diagonal.host";
58− description = "Grove: 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/grove-host";
59+ path = "${host}/bin/diagonal-host";
60 type = "stdio";
60 type = "stdio";
61 allowed_origins = [ "chrome-extension://${cfg.extensionId}/" ];
61 allowed_origins = [ "chrome-extension://${cfg.extensionId}/" ];
62 };
62 };
63 in
63 in
64 {
64 {
65− options.programs.grove = {
65+ options.programs.diagonal = {
66− enable = lib.mkEnableOption "the Grove native messaging host for Brave";
66+ enable = lib.mkEnableOption "the Diagonal native messaging host for Brave";
67 extensionId = lib.mkOption {
67 extensionId = lib.mkOption {
68 type = lib.types.str;
68 type = lib.types.str;
69 default = extensionId;
69 default = extensionId;
70− description = "Pinned extension ID (from scripts/gen-key.sh). The host itself checks the ID baked into host/grove-host.py, so change both together by re-running gen-key.sh.";
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 };
71 };
72 channels = lib.mkOption {
72 channels = lib.mkOption {
73 type = lib.types.listOf (lib.types.enum [ "Brave-Browser" "Brave-Browser-Beta" "Brave-Browser-Nightly" ]);
73 type = lib.types.listOf (lib.types.enum [ "Brave-Browser" "Brave-Browser-Beta" "Brave-Browser-Nightly" ]);
@@ -80,12 +80,12 @@
80 home.packages = [ host ];
80 home.packages = [ host ];
81 home.file = lib.listToAttrs (map
81 home.file = lib.listToAttrs (map
82 (ch: lib.nameValuePair
82 (ch: lib.nameValuePair
83− "Library/Application Support/BraveSoftware/${ch}/NativeMessagingHosts/io.grove.host.json"
83+ "Library/Application Support/BraveSoftware/${ch}/NativeMessagingHosts/io.diagonal.host.json"
84 { text = builtins.toJSON manifest; })
84 { text = builtins.toJSON manifest; })
85 cfg.channels);
85 cfg.channels);
86 # `|| true`: a Mac without Apple Intelligence still activates.
86 # `|| true`: a Mac without Apple Intelligence still activates.
87− home.activation.groveSchemas = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
87+ home.activation.diagonalSchemas = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
88− ${host}/bin/grove-host --install-schemas || true
88+ ${host}/bin/diagonal-host --install-schemas || true
89 '';
89 '';
90 };
90 };
91 };
91 };
host/grove-host.py → host/diagonal-host.pyrenamed+11−11
@@ -1,15 +1,15 @@
1#!/usr/bin/env python3
1#!/usr/bin/env python3
2−"""grove-host: Brave native-messaging host that calls /usr/bin/fm once and exits.
2+"""diagonal-host: Brave native-messaging host that calls /usr/bin/fm once and exits.
3
3
4Protocol (section 8): Brave starts this process with the caller's origin as argv[1], writes one
4Protocol (section 8): Brave starts this process with the caller's origin as argv[1], writes one
5length-prefixed JSON request to stdin, and reads one length-prefixed JSON reply from stdout.
5length-prefixed JSON request to stdin, and reads one length-prefixed JSON reply from stdout.
6Standard library only; no network imports.
6Standard library only; no network imports.
7
7
8Other entry points:
8Other entry points:
9− grove-host --selftest check fm, the model, the schemas, and one fixture naming call
9+ diagonal-host --selftest check fm, the model, the schemas, and one fixture naming call
10− grove-host --install-schemas write the fm schema files to ~/Library/Application Support/Grove/schemas
10+ diagonal-host --install-schemas write the fm schema files to ~/Library/Application Support/Diagonal/schemas
11− grove-host --print-manifest P print the host manifest JSON for executable path P
11+ diagonal-host --print-manifest P print the host manifest JSON for executable path P
12− grove-host --version
12+ diagonal-host --version
13"""
13"""
14import json
14import json
15import os
15import os
@@ -24,12 +24,12 @@ import prompts # noqa: E402
24import validate # noqa: E402
24import validate # noqa: E402
25
25
26HOST_VERSION = "0.1.0"
26HOST_VERSION = "0.1.0"
27−HOST_NAME = "io.grove.host"
27+HOST_NAME = "io.diagonal.host"
28ALLOWED_ORIGIN = "chrome-extension://mpnodlalikgeehnlnofdkpgapkmbkjdf/"
28ALLOWED_ORIGIN = "chrome-extension://mpnodlalikgeehnlnofdkpgapkmbkjdf/"
29−FM = os.environ.get("GROVE_FM", "/usr/bin/fm") # tests point this at host/tests/fake_fm.py
29+FM = os.environ.get("DIAGONAL_FM", "/usr/bin/fm") # tests point this at host/tests/fake_fm.py
30−SUPPORT = os.environ.get("GROVE_SUPPORT_DIR", os.path.expanduser("~/Library/Application Support/Grove"))
30+SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/Application Support/Diagonal"))
31SCHEMAS = os.path.join(SUPPORT, "schemas")
31SCHEMAS = os.path.join(SUPPORT, "schemas")
32−LOG_DIR = os.environ.get("GROVE_LOG_DIR", os.path.expanduser("~/Library/Logs/Grove"))
32+LOG_DIR = os.environ.get("DIAGONAL_LOG_DIR", os.path.expanduser("~/Library/Logs/Diagonal"))
33LOG_MAX_BYTES = 5 * 1024 * 1024
33LOG_MAX_BYTES = 5 * 1024 * 1024
34CHAR_BUDGET = 10_000 # ≈ 2,850 tokens of prompt at 3.5 chars/token (section 9); double once 8,192 is confirmed
34CHAR_BUDGET = 10_000 # ≈ 2,850 tokens of prompt at 3.5 chars/token (section 9); double once 8,192 is confirmed
35MAX_REQUEST = 64 * 1024 * 1024
35MAX_REQUEST = 64 * 1024 * 1024
@@ -378,7 +378,7 @@ def selftest():
378 for name in ["name.json", *(["organize.json"] if organize_mode() == "nested" else list(FALLBACK_SCHEMA_COMMANDS))]:
378 for name in ["name.json", *(["organize.json"] if organize_mode() == "nested" else list(FALLBACK_SCHEMA_COMMANDS))]:
379 path = os.path.join(SCHEMAS, name)
379 path = os.path.join(SCHEMAS, name)
380 readable = os.path.isfile(path) and os.path.getsize(path) > 0
380 readable = os.path.isfile(path) and os.path.getsize(path) > 0
381− check(f"schema {name}", readable, path if readable else "missing: run grove-host --install-schemas")
381+ check(f"schema {name}", readable, path if readable else "missing: run diagonal-host --install-schemas")
382 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
382 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
383 if ping["fmAvailable"] and schema_path("name.json"):
383 if ping["fmAvailable"] and schema_path("name.json"):
384 reply = handle({"v": 1, "id": "selftest", "op": "name", "payload": json.loads(json.dumps(FIXTURE)), "opts": {"timeoutMs": 45000}})
384 reply = handle({"v": 1, "id": "selftest", "op": "name", "payload": json.loads(json.dumps(FIXTURE)), "opts": {"timeoutMs": 45000}})
@@ -390,7 +390,7 @@ def selftest():
390
390
391
391
392def manifest_for(path):
392def manifest_for(path):
393− return {"name": HOST_NAME, "description": "Grove: names tab groups with Apple's on-device model",
393+ return {"name": HOST_NAME, "description": "Diagonal: names tab groups with Apple's on-device model",
394 "path": os.path.abspath(path), "type": "stdio", "allowed_origins": [ALLOWED_ORIGIN]}
394 "path": os.path.abspath(path), "type": "stdio", "allowed_origins": [ALLOWED_ORIGIN]}
395
395
396
396
host/emoji.txt+1−1
@@ -1,4 +1,4 @@
1−# Grove curated emoji list. One category per line: "<category>: <emoji> <emoji> ...".
1+# Diagonal curated emoji list. One category per line: "<category>: <emoji> <emoji> ...".
2# The first emoji of each line is that category's default. This file is the single
2# The first emoji of each line is that category's default. This file is the single
3# source of truth: host/validate.py reads it at runtime and scripts/gen-emoji.mjs
3# source of truth: host/validate.py reads it at runtime and scripts/gen-emoji.mjs
4# generates src/shared/emoji.ts from it (npm run gen-emoji).
4# generates src/shared/emoji.ts from it (npm run gen-emoji).
host/tests/support.py+5−5
@@ -10,12 +10,12 @@ import tempfile
10import unittest
10import unittest
11
11
12HOST_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
12HOST_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
13−HOST_SCRIPT = os.path.join(HOST_DIR, "grove-host.py")
13+HOST_SCRIPT = os.path.join(HOST_DIR, "diagonal-host.py")
14sys.path.insert(0, HOST_DIR)
14sys.path.insert(0, HOST_DIR)
15
15
16
16
17def load_host():
17def load_host():
18− spec = importlib.util.spec_from_file_location("grove_host", HOST_SCRIPT)
18+ spec = importlib.util.spec_from_file_location("diagonal_host", HOST_SCRIPT)
19 mod = importlib.util.module_from_spec(spec)
19 mod = importlib.util.module_from_spec(spec)
20 spec.loader.exec_module(mod)
20 spec.loader.exec_module(mod)
21 return mod
21 return mod
@@ -51,7 +51,7 @@ class HostCase(unittest.TestCase):
51 """Each test gets a fresh temp dir with a fake fm and installed schemas."""
51 """Each test gets a fresh temp dir with a fake fm and installed schemas."""
52
52
53 def setUp(self):
53 def setUp(self):
54− self.tmp = tempfile.mkdtemp(prefix="grove-host-test-")
54+ self.tmp = tempfile.mkdtemp(prefix="diagonal-host-test-")
55 self.fm = os.path.join(self.tmp, "fm")
55 self.fm = os.path.join(self.tmp, "fm")
56 with open(os.path.join(HOST_DIR, "tests", "fake_fm.py"), encoding="utf-8") as f:
56 with open(os.path.join(HOST_DIR, "tests", "fake_fm.py"), encoding="utf-8") as f:
57 body = f.read()
57 body = f.read()
@@ -64,8 +64,8 @@ class HostCase(unittest.TestCase):
64 with open(os.path.join(self.support, "schemas", name), "w") as f:
64 with open(os.path.join(self.support, "schemas", name), "w") as f:
65 f.write("{}")
65 f.write("{}")
66 self.control({})
66 self.control({})
67− self.env = {**os.environ, "GROVE_FM": self.fm, "GROVE_SUPPORT_DIR": self.support,
67+ self.env = {**os.environ, "DIAGONAL_FM": self.fm, "DIAGONAL_SUPPORT_DIR": self.support,
68− "GROVE_LOG_DIR": os.path.join(self.tmp, "logs"), "HOME": self.tmp}
68+ "DIAGONAL_LOG_DIR": os.path.join(self.tmp, "logs"), "HOME": self.tmp}
69
69
70 def tearDown(self):
70 def tearDown(self):
71 shutil.rmtree(self.tmp, ignore_errors=True)
71 shutil.rmtree(self.tmp, ignore_errors=True)
host/tests/test_host.py+6−6
@@ -72,7 +72,7 @@ class Ping(HostCase):
72 self.assertIn("Apple Intelligence", r["result"]["fmMessage"])
72 self.assertIn("Apple Intelligence", r["result"]["fmMessage"])
73
73
74 def test_ping_without_fm(self):
74 def test_ping_without_fm(self):
75− self.env["GROVE_FM"] = os.path.join(self.tmp, "missing-fm")
75+ self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
76 r = self.call("ping")
76 r = self.call("ping")
77 self.assertFalse(r["result"]["fmAvailable"])
77 self.assertFalse(r["result"]["fmAvailable"])
78 self.assertIn("requires macOS 27", r["result"]["fmMessage"])
78 self.assertIn("requires macOS 27", r["result"]["fmMessage"])
@@ -127,7 +127,7 @@ class ErrorCodes(HostCase):
127 self.expect("MODEL_UNAVAILABLE", stderr="Error: The model is not available. Enable Apple Intelligence.", exit=1)
127 self.expect("MODEL_UNAVAILABLE", stderr="Error: The model is not available. Enable Apple Intelligence.", exit=1)
128
128
129 def test_fm_missing(self):
129 def test_fm_missing(self):
130− self.env["GROVE_FM"] = os.path.join(self.tmp, "nope")
130+ self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "nope")
131 r = self.call("name", {"items": ITEMS3})
131 r = self.call("name", {"items": ITEMS3})
132 self.assertEqual(r["error"]["code"], "MODEL_UNAVAILABLE")
132 self.assertEqual(r["error"]["code"], "MODEL_UNAVAILABLE")
133 self.assertIn("requires macOS 27", r["error"]["message"])
133 self.assertIn("requires macOS 27", r["error"]["message"])
@@ -251,10 +251,10 @@ class SelfTest(HostCase):
251 self.assertIn("FAIL model available", p.stdout.decode())
251 self.assertIn("FAIL model available", p.stdout.decode())
252
252
253 def test_print_manifest(self):
253 def test_print_manifest(self):
254− code, _, p = self.run_host(args=["--print-manifest", "/Users/me/.local/bin/grove-host"])
254+ code, _, p = self.run_host(args=["--print-manifest", "/Users/me/.local/bin/diagonal-host"])
255 m = json.loads(p.stdout)
255 m = json.loads(p.stdout)
256− self.assertEqual(m["name"], "io.grove.host")
256+ self.assertEqual(m["name"], "io.diagonal.host")
257− self.assertEqual(m["path"], "/Users/me/.local/bin/grove-host")
257+ self.assertEqual(m["path"], "/Users/me/.local/bin/diagonal-host")
258 self.assertEqual(m["allowed_origins"], [host.ALLOWED_ORIGIN])
258 self.assertEqual(m["allowed_origins"], [host.ALLOWED_ORIGIN])
259
259
260
260
@@ -273,7 +273,7 @@ class Logging(HostCase):
273class Privacy(unittest.TestCase):
273class Privacy(unittest.TestCase):
274 def test_no_network_imports(self):
274 def test_no_network_imports(self):
275 banned = re.compile(r"^\s*(import|from)\s+(socket|ssl|http|urllib\.request|urllib3|requests|ftplib|smtplib|asyncio|xmlrpc)\b", re.M)
275 banned = re.compile(r"^\s*(import|from)\s+(socket|ssl|http|urllib\.request|urllib3|requests|ftplib|smtplib|asyncio|xmlrpc)\b", re.M)
276− for name in ("grove-host.py", "prompts.py", "validate.py"):
276+ for name in ("diagonal-host.py", "prompts.py", "validate.py"):
277 src = open(os.path.join(HOST_DIR, name), encoding="utf-8").read()
277 src = open(os.path.join(HOST_DIR, name), encoding="utf-8").read()
278 self.assertIsNone(banned.search(src), name)
278 self.assertIsNone(banned.search(src), name)
279
279
manifest.json+2−2
@@ -1,6 +1,6 @@
1{
1{
2 "manifest_version": 3,
2 "manifest_version": 3,
3− "name": "Grove",
3+ "name": "Diagonal",
4 "version": "0.1.0",
4 "version": "0.1.0",
5 "minimum_chrome_version": "121",
5 "minimum_chrome_version": "121",
6 "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjQfra9g/ZwPFNa1EGvqo6GORMuLrj9Gk6/yonBCrF78qMk6Kb8S7inM5I0O+V+aaZ1DwI3IPAJTyTbvzcPR5xuaGzaDCBuwqb2uo3zyY8dKqK//s6SXbDVue/czTRXvEBrKx0G4Mo19VFmft2XcHqDdqRZH3eSagHLw2r+CNLAeyNJOQNN8/Dfdx56axtB++HwK+/D9fafFQUMsTOGIQ3atjkM9VCK7050CFTeoL2IpJPJuo5Mh4Rd+zOzMI6Q4mfb2mFgraRwMNevbYBRgWWn19UMkKIc1IkP17f6BDo/0NP7ehGxL/rUmB+pqNCAmWDxJyYLyER35y5Ap+5BLpcQIDAQAB",
6 "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjQfra9g/ZwPFNa1EGvqo6GORMuLrj9Gk6/yonBCrF78qMk6Kb8S7inM5I0O+V+aaZ1DwI3IPAJTyTbvzcPR5xuaGzaDCBuwqb2uo3zyY8dKqK//s6SXbDVue/czTRXvEBrKx0G4Mo19VFmft2XcHqDdqRZH3eSagHLw2r+CNLAeyNJOQNN8/Dfdx56axtB++HwK+/D9fafFQUMsTOGIQ3atjkM9VCK7050CFTeoL2IpJPJuo5Mh4Rd+zOzMI6Q4mfb2mFgraRwMNevbYBRgWWn19UMkKIc1IkP17f6BDo/0NP7ehGxL/rUmB+pqNCAmWDxJyYLyER35y5Ap+5BLpcQIDAQAB",
@@ -35,7 +35,7 @@
35 ],
35 ],
36 "action": {
36 "action": {
37 "default_popup": "popup.html",
37 "default_popup": "popup.html",
38− "default_title": "Grove",
38+ "default_title": "Diagonal",
39 "default_icon": {
39 "default_icon": {
40 "16": "icons/16.png",
40 "16": "icons/16.png",
41 "32": "icons/32.png",
41 "32": "icons/32.png",
package-lock.json+2−2
@@ -1,11 +1,11 @@
1{
1{
2− "name": "grove-extension",
2+ "name": "diagonal-extension",
3 "version": "0.1.0",
3 "version": "0.1.0",
4 "lockfileVersion": 3,
4 "lockfileVersion": 3,
5 "requires": true,
5 "requires": true,
6 "packages": {
6 "packages": {
7 "": {
7 "": {
8− "name": "grove-extension",
8+ "name": "diagonal-extension",
9 "version": "0.1.0",
9 "version": "0.1.0",
10 "devDependencies": {
10 "devDependencies": {
11 "@types/chrome": "^0.3.0",
11 "@types/chrome": "^0.3.0",
package.json+2−2
@@ -1,5 +1,5 @@
1{
1{
2− "name": "grove-extension",
2+ "name": "diagonal-extension",
3 "version": "0.1.0",
3 "version": "0.1.0",
4 "private": true,
4 "private": true,
5 "type": "module",
5 "type": "module",
@@ -11,7 +11,7 @@
11 "test": "vitest run",
11 "test": "vitest run",
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 ../grove-extension.zip .",
14+ "zip": "node scripts/build.mjs && cd dist && zip -qr ../diagonal-extension.zip .",
15 "smoke": "node scripts/build.mjs && node test/e2e/smoke.mjs",
15 "smoke": "node scripts/build.mjs && node test/e2e/smoke.mjs",
16 "icons": "node scripts/render-icons.mjs",
16 "icons": "node scripts/render-icons.mjs",
17 "check": "tsc --noEmit && vitest run && cd host && python3 -m unittest discover -s tests"
17 "check": "tsc --noEmit && vitest run && cd host && python3 -m unittest discover -s tests"
scripts/gen-key.sh+2−2
@@ -1,6 +1,6 @@
1#!/usr/bin/env bash
1#!/usr/bin/env bash
2# Pins the extension ID (section 14). Creates key.pem once (keep it out of the repo), writes the
2# Pins the extension ID (section 14). Creates key.pem once (keep it out of the repo), writes the
3−# public key into manifest.json, the ID into ./extension-id and into host/grove-host.py's
3+# public key into manifest.json, the ID into ./extension-id and into host/diagonal-host.py's
4# ALLOWED_ORIGIN, and prints the ID. Re-running with an existing key.pem changes nothing.
4# ALLOWED_ORIGIN, and prints the ID. Re-running with an existing key.pem changes nothing.
5set -euo pipefail
5set -euo pipefail
6cd "$(dirname "$0")/.."
6cd "$(dirname "$0")/.."
@@ -23,7 +23,7 @@ m["key"] = key
23with open("manifest.json", "w", encoding="utf-8") as f:
23with open("manifest.json", "w", encoding="utf-8") as f:
24 json.dump(m, f, indent=2, ensure_ascii=False)
24 json.dump(m, f, indent=2, ensure_ascii=False)
25 f.write("\n")
25 f.write("\n")
26−p = "host/grove-host.py"
26+p = "host/diagonal-host.py"
27s = open(p, encoding="utf-8").read()
27s = open(p, encoding="utf-8").read()
28s = re.sub(r'^ALLOWED_ORIGIN = ".*"$', f'ALLOWED_ORIGIN = "chrome-extension://{ext_id}/"', s, count=1, flags=re.M)
28s = re.sub(r'^ALLOWED_ORIGIN = ".*"$', f'ALLOWED_ORIGIN = "chrome-extension://{ext_id}/"', s, count=1, flags=re.M)
29open(p, "w", encoding="utf-8").write(s)
29open(p, "w", encoding="utf-8").write(s)
scripts/install-manifest.sh+9−9
@@ -1,7 +1,7 @@
1#!/usr/bin/env bash
1#!/usr/bin/env bash
2# Non-Nix installer for the native host (section 14).
2# Non-Nix installer for the native host (section 14).
3# scripts/install-manifest.sh [--beta] [--nightly] [--all-channels]
3# scripts/install-manifest.sh [--beta] [--nightly] [--all-channels]
4−# Copies host/ to ~/.local/share/grove-host, links ~/.local/bin/grove-host to it, writes the Brave
4+# Copies host/ to ~/.local/share/diagonal-host, links ~/.local/bin/diagonal-host to it, writes the Brave
5# host manifest with that absolute path and the pinned extension ID, then installs the fm schemas
5# host manifest with that absolute path and the pinned extension ID, then installs the fm schemas
6# and runs the self-test. Safe to re-run.
6# and runs the self-test. Safe to re-run.
7set -euo pipefail
7set -euo pipefail
@@ -31,20 +31,20 @@ fi
31# Brave starts the host with a minimal PATH, so the shebang names this python3 explicitly.
31# Brave starts the host with a minimal PATH, so the shebang names this python3 explicitly.
32PYTHON=$("$PYTHON" -c 'import sys; print(sys.executable)')
32PYTHON=$("$PYTHON" -c 'import sys; print(sys.executable)')
33
33
34−SHARE="$HOME/.local/share/grove-host"
34+SHARE="$HOME/.local/share/diagonal-host"
35−BIN="$HOME/.local/bin/grove-host"
35+BIN="$HOME/.local/bin/diagonal-host"
36mkdir -p "$SHARE" "$(dirname "$BIN")"
36mkdir -p "$SHARE" "$(dirname "$BIN")"
37cp host/prompts.py host/validate.py host/emoji.txt "$SHARE/"
37cp host/prompts.py host/validate.py host/emoji.txt "$SHARE/"
38−sed -e "1s|.*|#!$PYTHON|" -e "s|^ALLOWED_ORIGIN = \".*\"|ALLOWED_ORIGIN = \"chrome-extension://$ID/\"|" host/grove-host.py > "$SHARE/grove-host.py"
38+sed -e "1s|.*|#!$PYTHON|" -e "s|^ALLOWED_ORIGIN = \".*\"|ALLOWED_ORIGIN = \"chrome-extension://$ID/\"|" host/diagonal-host.py > "$SHARE/diagonal-host.py"
39−chmod 755 "$SHARE/grove-host.py"
39+chmod 755 "$SHARE/diagonal-host.py"
40−ln -sf "$SHARE/grove-host.py" "$BIN"
40+ln -sf "$SHARE/diagonal-host.py" "$BIN"
41−echo "host: $BIN -> $SHARE/grove-host.py"
41+echo "host: $BIN -> $SHARE/diagonal-host.py"
42
42
43for ch in "${CHANNELS[@]}"; do
43for ch in "${CHANNELS[@]}"; do
44 DIR="$HOME/Library/Application Support/BraveSoftware/$ch/NativeMessagingHosts"
44 DIR="$HOME/Library/Application Support/BraveSoftware/$ch/NativeMessagingHosts"
45 mkdir -p "$DIR"
45 mkdir -p "$DIR"
46− "$BIN" --print-manifest "$BIN" > "$DIR/io.grove.host.json"
46+ "$BIN" --print-manifest "$BIN" > "$DIR/io.diagonal.host.json"
47− echo "manifest: $DIR/io.grove.host.json"
47+ echo "manifest: $DIR/io.diagonal.host.json"
48done
48done
49
49
50echo "schemas:"
50echo "schemas:"
src/background/host.ts+4−4
@@ -1,7 +1,7 @@
1import type { HostError, HostErrorCode } from "./state";
1import type { HostError, HostErrorCode } from "./state";
2
2
3/** Section 8's wire protocol, from the extension's side. One `sendNativeMessage` per request. */
3/** Section 8's wire protocol, from the extension's side. One `sendNativeMessage` per request. */
4−export const HOST_NAME = "io.grove.host";
4+export const HOST_NAME = "io.diagonal.host";
5export const HOST_MANIFEST_DIR = "~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/";
5export const HOST_MANIFEST_DIR = "~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/";
6export const HOST_MANIFEST_PATH = `${HOST_MANIFEST_DIR}${HOST_NAME}.json`;
6export const HOST_MANIFEST_PATH = `${HOST_MANIFEST_DIR}${HOST_NAME}.json`;
7
7
@@ -65,13 +65,13 @@ export async function callHost<T>(
65 const reply = await Promise.race([
65 const reply = await Promise.race([
66 send(HOST_NAME, request),
66 send(HOST_NAME, request),
67 new Promise((_, reject) => {
67 new Promise((_, reject) => {
68− timer = setTimeout(() => reject(new Error("__grove_timeout__")), guardMs);
68+ timer = setTimeout(() => reject(new Error("__diagonal_timeout__")), guardMs);
69 }),
69 }),
70 ]);
70 ]);
71 return normalizeReply<T>(reply, now());
71 return normalizeReply<T>(reply, now());
72 } catch (e) {
72 } catch (e) {
73 const message = e instanceof Error ? e.message : String(e);
73 const message = e instanceof Error ? e.message : String(e);
74− if (message === "__grove_timeout__") {
74+ if (message === "__diagonal_timeout__") {
75 return { ok: false, error: { code: "TIMEOUT", message: `no reply from host in ${guardMs} ms`, retryable: true, at: now() } };
75 return { ok: false, error: { code: "TIMEOUT", message: `no reply from host in ${guardMs} ms`, retryable: true, at: now() } };
76 }
76 }
77 return { ok: false, error: { ...mapLastError(message), at: now() } };
77 return { ok: false, error: { ...mapLastError(message), at: now() } };
@@ -116,7 +116,7 @@ export function explain(error: HostError | undefined, extensionId = ""): string
116 case "FORBIDDEN_ORIGIN":
116 case "FORBIDDEN_ORIGIN":
117 return `The host does not allow this extension. Its manifest needs "allowed_origins": ["chrome-extension://${extensionId}/"]`;
117 return `The host does not allow this extension. Its manifest needs "allowed_origins": ["chrome-extension://${extensionId}/"]`;
118 case "SCHEMA_MISSING":
118 case "SCHEMA_MISSING":
119− return "Schemas missing. Run: grove-host --install-schemas";
119+ return "Schemas missing. Run: diagonal-host --install-schemas";
120 case "MODEL_UNAVAILABLE":
120 case "MODEL_UNAVAILABLE":
121 return `On-device model unavailable: ${error.message}. Check System Settings → Apple Intelligence & Siri.`;
121 return `On-device model unavailable: ${error.message}. Check System Settings → Apple Intelligence & Siri.`;
122 case "RATE_LIMITED":
122 case "RATE_LIMITED":
src/background/index.ts+3−3
@@ -35,7 +35,7 @@ function commit(): void {
35 if (saveTimer) return;
35 if (saveTimer) return;
36 saveTimer = setTimeout(() => {
36 saveTimer = setTimeout(() => {
37 saveTimer = undefined;
37 saveTimer = undefined;
38− chrome.storage.local.set({ state }).catch((e) => console.error("[grove] save failed", e));
38+ chrome.storage.local.set({ state }).catch((e) => console.error("[diagonal] save failed", e));
39 }, 0);
39 }, 0);
40}
40}
41
41
@@ -54,12 +54,12 @@ async function pushRing(key: string, entry: unknown, cap: number): Promise<void>
54
54
55function log(...args: unknown[]): void {
55function log(...args: unknown[]): void {
56 if (!settings?.debugLog) return;
56 if (!settings?.debugLog) return;
57− console.log("[grove]", ...args);
57+ console.log("[diagonal]", ...args);
58 void pushRing("debugLog", { at: Date.now(), msg: args.map((a) => (a instanceof Error ? a.message : typeof a === "string" ? a : JSON.stringify(a))).join(" ") }, 200);
58 void pushRing("debugLog", { at: Date.now(), msg: args.map((a) => (a instanceof Error ? a.message : typeof a === "string" ? a : JSON.stringify(a))).join(" ") }, 200);
59}
59}
60
60
61function logError(where: string, e: unknown): void {
61function logError(where: string, e: unknown): void {
62− console.error(`[grove] ${where}`, e);
62+ console.error(`[diagonal] ${where}`, e);
63 if (settings?.debugLog) void pushRing("debugLog", { at: Date.now(), msg: `${where}: ${e instanceof Error ? e.message : String(e)}` }, 200);
63 if (settings?.debugLog) void pushRing("debugLog", { at: Date.now(), msg: `${where}: ${e instanceof Error ? e.message : String(e)}` }, 200);
64}
64}
65
65
src/options/options.html+2−2
@@ -2,7 +2,7 @@
2<html lang="en">
2<html lang="en">
3<head>
3<head>
4 <meta charset="utf-8">
4 <meta charset="utf-8">
5− <title>Grove settings</title>
5+ <title>Diagonal settings</title>
6 <link rel="stylesheet" href="ui.css">
6 <link rel="stylesheet" href="ui.css">
7 <style>
7 <style>
8 body { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }
8 body { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }
@@ -19,7 +19,7 @@
19 </style>
19 </style>
20</head>
20</head>
21<body>
21<body>
22− <div class="row"><h1 class="grow">Grove settings</h1><span id="saved" class="muted" style="opacity:0">Saved</span></div>
22+ <div class="row"><h1 class="grow">Diagonal settings</h1><span id="saved" class="muted" style="opacity:0">Saved</span></div>
23 <p class="muted">Tab groups named by Apple's on-device model through the <code>fm</code> command-line tool. Nothing is sent over the network.</p>
23 <p class="muted">Tab groups named by Apple's on-device model through the <code>fm</code> command-line tool. Nothing is sent over the network.</p>
24
24
25 <section>
25 <section>
src/options/options.ts+6−6
@@ -36,7 +36,7 @@ const SECTIONS: [string, Field[]][] = [
36 { key: "archiveCap", label: "Keep at most", kind: "number", min: 50, max: 5000, unit: "archived tabs" },
36 { key: "archiveCap", label: "Keep at most", kind: "number", min: 50, max: 5000, unit: "archived tabs" },
37 ]],
37 ]],
38 ["Diagnostics", [
38 ["Diagnostics", [
39− { key: "debugLog", label: "Debug logging", help: "Worker logs to the console; the host logs to ~/Library/Logs/Grove/host.log when ~/Library/Application Support/Grove/debug exists.", kind: "bool" },
39+ { key: "debugLog", label: "Debug logging", help: "Worker logs to the console; the host logs to ~/Library/Logs/Diagonal/host.log when ~/Library/Application Support/Diagonal/debug exists.", kind: "bool" },
40 ]],
40 ]],
41];
41];
42
42
@@ -123,11 +123,11 @@ async function main(): Promise<void> {
123 settings = withDefaults((await chrome.storage.local.get("settings")).settings);
123 settings = withDefaults((await chrome.storage.local.get("settings")).settings);
124 renderSettings();
124 renderSettings();
125 const id = chrome.runtime.id;
125 const id = chrome.runtime.id;
126− const path = "~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/io.grove.host.json";
126+ const path = "~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/io.diagonal.host.json";
127 const manifest = JSON.stringify({
127 const manifest = JSON.stringify({
128− name: "io.grove.host",
128+ name: "io.diagonal.host",
129− description: "Grove: names tab groups with Apple's on-device model",
129+ description: "Diagonal: names tab groups with Apple's on-device model",
130− path: "/Users/<you>/.local/bin/grove-host",
130+ path: "/Users/<you>/.local/bin/diagonal-host",
131 type: "stdio",
131 type: "stdio",
132 allowed_origins: [`chrome-extension://${id}/`],
132 allowed_origins: [`chrome-extension://${id}/`],
133 }, null, 2);
133 }, null, 2);
@@ -151,7 +151,7 @@ async function main(): Promise<void> {
151 $("export").onclick = async () => {
151 $("export").onclick = async () => {
152 const archive = await send("exportArchive");
152 const archive = await send("exportArchive");
153 const blob = new Blob([JSON.stringify(archive, null, 2)], { type: "application/json" });
153 const blob = new Blob([JSON.stringify(archive, null, 2)], { type: "application/json" });
154− const a = el("a", { href: URL.createObjectURL(blob), download: `grove-archive-${new Date().toISOString().slice(0, 10)}.json` });
154+ const a = el("a", { href: URL.createObjectURL(blob), download: `diagonal-archive-${new Date().toISOString().slice(0, 10)}.json` });
155 a.click();
155 a.click();
156 setTimeout(() => URL.revokeObjectURL(a.href), 1000);
156 setTimeout(() => URL.revokeObjectURL(a.href), 1000);
157 };
157 };
src/popup/popup.html+2−2
@@ -2,7 +2,7 @@
2<html lang="en">
2<html lang="en">
3<head>
3<head>
4 <meta charset="utf-8">
4 <meta charset="utf-8">
5− <title>Grove</title>
5+ <title>Diagonal</title>
6 <link rel="stylesheet" href="ui.css">
6 <link rel="stylesheet" href="ui.css">
7 <style>
7 <style>
8 body { width: 360px; padding: 12px 14px 14px; }
8 body { width: 360px; padding: 12px 14px 14px; }
@@ -15,7 +15,7 @@
15</head>
15</head>
16<body>
16<body>
17 <div class="row">
17 <div class="row">
18− <h1 class="grow">Grove</h1>
18+ <h1 class="grow">Diagonal</h1>
19 <span class="pill" id="status"><span class="dot" id="status-dot"></span><span id="status-text">Checking…</span></span>
19 <span class="pill" id="status"><span class="dot" id="status-dot"></span><span id="status-text">Checking…</span></span>
20 </div>
20 </div>
21 <div id="notice" class="notice" hidden></div>
21 <div id="notice" class="notice" hidden></div>
test/e2e/smoke.mjs+10−10
@@ -1,4 +1,4 @@
1−// End-to-end smoke test: loads dist/ into Chromium with the real native host (host/grove-host.py)
1+// End-to-end smoke test: loads dist/ into Chromium with the real native host (host/diagonal-host.py)
2// wired to a keyword-driven fake fm, then drives opener grouping, naming, a user rename, dissolve,
2// wired to a keyword-driven fake fm, then drives opener grouping, naming, a user rename, dissolve,
3// Organize and the popup. Linux only (native host manifests live under the profile directory).
3// Organize and the popup. Linux only (native host manifests live under the profile directory).
4// node test/e2e/smoke.mjs (after npm run build)
4// node test/e2e/smoke.mjs (after npm run build)
@@ -13,7 +13,7 @@ import { execFileSync } from "node:child_process";
13const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
13const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
14const dist = join(root, "dist");
14const dist = join(root, "dist");
15const extId = readFileSync(join(root, "extension-id"), "utf8").trim();
15const extId = readFileSync(join(root, "extension-id"), "utf8").trim();
16−const tmp = mkdtempSync(join(tmpdir(), "grove-smoke-"));
16+const tmp = mkdtempSync(join(tmpdir(), "diagonal-smoke-"));
17const python = execFileSync("python3", ["-c", "import sys; print(sys.executable)"]).toString().trim();
17const python = execFileSync("python3", ["-c", "import sys; print(sys.executable)"]).toString().trim();
18const shotDir = process.env.SMOKE_SHOTS || join(tmp, "shots");
18const shotDir = process.env.SMOKE_SHOTS || join(tmp, "shots");
19mkdirSync(shotDir, { recursive: true });
19mkdirSync(shotDir, { recursive: true });
@@ -22,20 +22,20 @@ mkdirSync(shotDir, { recursive: true });
22const hostDir = join(tmp, "host");
22const hostDir = join(tmp, "host");
23mkdirSync(hostDir);
23mkdirSync(hostDir);
24for (const f of ["prompts.py", "validate.py", "emoji.txt"]) cpSync(join(root, "host", f), join(hostDir, f));
24for (const f of ["prompts.py", "validate.py", "emoji.txt"]) cpSync(join(root, "host", f), join(hostDir, f));
25−writeFileSync(join(hostDir, "grove-host.py"), readFileSync(join(root, "host/grove-host.py"), "utf8").replace(/^#!.*$/m, `#!${python}`));
25+writeFileSync(join(hostDir, "diagonal-host.py"), readFileSync(join(root, "host/diagonal-host.py"), "utf8").replace(/^#!.*$/m, `#!${python}`));
26−chmodSync(join(hostDir, "grove-host.py"), 0o755);
26+chmodSync(join(hostDir, "diagonal-host.py"), 0o755);
27const fm = join(tmp, "fm");
27const fm = join(tmp, "fm");
28writeFileSync(fm, `#!${python}\n` + readFileSync(join(root, "test/e2e/smoke-fm.py"), "utf8"));
28writeFileSync(fm, `#!${python}\n` + readFileSync(join(root, "test/e2e/smoke-fm.py"), "utf8"));
29chmodSync(fm, 0o755);
29chmodSync(fm, 0o755);
30const support = join(tmp, "support");
30const support = join(tmp, "support");
31const profile = join(tmp, "profile");
31const profile = join(tmp, "profile");
32−const manifest = JSON.stringify({ name: "io.grove.host", description: "Grove smoke", path: join(hostDir, "grove-host.py"), type: "stdio", allowed_origins: [`chrome-extension://${extId}/`] });
32+const manifest = JSON.stringify({ name: "io.diagonal.host", description: "Diagonal smoke", path: join(hostDir, "diagonal-host.py"), type: "stdio", allowed_origins: [`chrome-extension://${extId}/`] });
33for (const dir of [join(profile, "NativeMessagingHosts"), join(tmp, "home/.config/chromium/NativeMessagingHosts")]) {
33for (const dir of [join(profile, "NativeMessagingHosts"), join(tmp, "home/.config/chromium/NativeMessagingHosts")]) {
34 mkdirSync(dir, { recursive: true });
34 mkdirSync(dir, { recursive: true });
35− writeFileSync(join(dir, "io.grove.host.json"), manifest);
35+ writeFileSync(join(dir, "io.diagonal.host.json"), manifest);
36}
36}
37−const env = { ...process.env, GROVE_FM: fm, GROVE_SUPPORT_DIR: support, HOME: join(tmp, "home") };
37+const env = { ...process.env, DIAGONAL_FM: fm, DIAGONAL_SUPPORT_DIR: support, HOME: join(tmp, "home") };
38−execFileSync(join(hostDir, "grove-host.py"), ["--install-schemas"], { env, stdio: "inherit" });
38+execFileSync(join(hostDir, "diagonal-host.py"), ["--install-schemas"], { env, stdio: "inherit" });
39
39
40// A tiny site with meta descriptions.
40// A tiny site with meta descriptions.
41const pages = {
41const pages = {
@@ -175,8 +175,8 @@ try {
175 await options.screenshot({ path: join(shotDir, "options.png"), fullPage: true });
175 await options.screenshot({ path: join(shotDir, "options.png"), fullPage: true });
176
176
177 // 7. Host missing → HOST_NOT_FOUND surfaced.
177 // 7. Host missing → HOST_NOT_FOUND surfaced.
178− writeFileSync(join(profile, "NativeMessagingHosts/io.grove.host.json"), "{}");
178+ writeFileSync(join(profile, "NativeMessagingHosts/io.diagonal.host.json"), "{}");
179− writeFileSync(join(tmp, "home/.config/chromium/NativeMessagingHosts/io.grove.host.json"), "{}");
179+ writeFileSync(join(tmp, "home/.config/chromium/NativeMessagingHosts/io.diagonal.host.json"), "{}");
180 const bad = await popup.evaluate(async () => chrome.runtime.sendMessage({ cmd: "ping" }));
180 const bad = await popup.evaluate(async () => chrome.runtime.sendMessage({ cmd: "ping" }));
181 check("a broken host manifest is reported, not swallowed", bad?.result?.ok === false && /HOST_/.test(bad.result.error.code), JSON.stringify(bad?.result?.error));
181 check("a broken host manifest is reported, not swallowed", bad?.result?.ok === false && /HOST_/.test(bad.result.error.code), JSON.stringify(bad?.result?.error));
182} finally {
182} finally {