diff --git a/docs/skills/bazaar.md b/docs/skills/bazaar.md index a3311bddc..8f8b26941 100644 --- a/docs/skills/bazaar.md +++ b/docs/skills/bazaar.md @@ -27,7 +27,7 @@ metadata: - Editing Bazaar config in `system_files/bluefin/etc/bazaar/` - Porting curated-page structure across Bazaar schema versions -- Changing Bazaar hook behavior for app install interception +- Changing Bazaar hook behavior for app install interception (JetBrains, VS Code/Codium, Zed) - Adding or changing banner images (JXL→PNG conversion pipeline) - Validating Bazaar behavior locally before opening a PR @@ -50,6 +50,8 @@ metadata: | `tests/test_bazaar_hook.py` | `bazaar-hook` state machine tests | | `tests/test_curated_config.py` | Curated/Bazaar config shape regression checks | +Both hook scripts must remain synchronized: `hooks.py` (host `/run/host/etc/bazaar/hooks.py`) and `bazaar-hook` (`/usr/libexec/bazaar-hook`) must implement identical hook IDs, stages, and package redirect actions. + ## Curated schema and compatibility notes Bazaar supports two distinct configuration schemas depending on the installed Flatpak version. Because stable releases may lag behind upstream GitHub commits, agents must verify the local version's expected format before editing. @@ -168,6 +170,7 @@ just test - Editing curated content without local preview causes UI regressions to slip through. - Copying Aurora/Bazaar examples directly can leave non-Bluefin branding or links. - Changing hook dialog/response IDs must be mirrored in tests to avoid silent behavior drift. +- Editing `system_files/bluefin/etc/bazaar/hooks.py` without applying the same hook handler to `system_files/bluefin/usr/libexec/bazaar-hook` leaves the in-image entry point out of sync. - Dropping `set -e` from the JXL conversion RUN step lets silent build failures through. - Using `--color_space=sRGB` instead of `-C sRGB` breaks the conversion with "Unknown argument" error. diff --git a/system_files/bluefin/etc/bazaar/bazaar.yaml b/system_files/bluefin/etc/bazaar/bazaar.yaml index dd6d84251..0a4b778f3 100644 --- a/system_files/bluefin/etc/bazaar/bazaar.yaml +++ b/system_files/bluefin/etc/bazaar/bazaar.yaml @@ -57,3 +57,21 @@ hooks: # styling style: suggested shell: exec python3 /run/host/etc/bazaar/hooks.py + + - id: zed + when: before-transaction + dialogs: + - id: zed-warning + title: >- + Zed is managed through Homebrew on Bluefin + body: >- + Install Zed from the Universal Blue Homebrew tap for the supported + Bluefin integration. + default-response-id: cancel + options: + - id: cancel + string: "Cancel" + - id: download + string: "Download from Homebrew" + style: suggested + shell: exec python3 /run/host/etc/bazaar/hooks.py diff --git a/system_files/bluefin/etc/bazaar/hooks.py b/system_files/bluefin/etc/bazaar/hooks.py index d7b8e1264..af81eeca1 100644 --- a/system_files/bluefin/etc/bazaar/hooks.py +++ b/system_files/bluefin/etc/bazaar/hooks.py @@ -30,7 +30,8 @@ def spawn_brew(app): brew = '/home/linuxbrew/.linuxbrew/bin/brew' spawn_and_detach([ 'flatpak-spawn', '--host', 'xdg-terminal-exec', '-x', - 'bash', '-c', f'{brew} install --cask {app}' + 'bash', '-c', + f'{brew} tap ublue-os/tap; {brew} trust ublue-os/tap; {brew} install --cask {app}' ]) def handle_jetbrains(): @@ -95,9 +96,9 @@ def appid_is_code(appid): case 'action': try: if transaction_appid == ('com.vscodium.codium'): - spawn_brew('ublue/tap/vscodium-linux') + spawn_brew('ublue-os/tap/vscodium-linux') else: - spawn_brew('ublue/tap/visual-studio-code-linux') + spawn_brew('ublue-os/tap/visual-studio-code-linux') except: pass return '' @@ -105,6 +106,40 @@ def appid_is_code(appid): case 'teardown': return 'deny' +def handle_zed(): + + def appid_is_zed(appid): + return appid == 'dev.zed.Zed' + + match stage: + case 'setup': + if transaction_type == 'install' and appid_is_zed(transaction_appid): + return 'ok' + else: + return 'pass' + + case 'setup-dialog': + return 'ok' + + case 'teardown-dialog': + if dialog_response_id == 'download': + return 'ok' + else: + return 'abort' + + case 'catch': + return 'abort' + + case 'action': + try: + spawn_brew('ublue-os/tap/zed-linux') + except Exception: + pass + return '' + + case 'teardown': + return 'deny' + # --- response = 'pass' @@ -113,6 +148,8 @@ def appid_is_code(appid): response = handle_jetbrains() case 'code': response = handle_code() + case 'zed': + response = handle_zed() print(response) sys.exit(0) diff --git a/system_files/bluefin/usr/libexec/bazaar-hook b/system_files/bluefin/usr/libexec/bazaar-hook index b4fae5477..ee0c7fa19 100755 --- a/system_files/bluefin/usr/libexec/bazaar-hook +++ b/system_files/bluefin/usr/libexec/bazaar-hook @@ -107,6 +107,41 @@ def handle_code(): case 'teardown': return 'deny' +def handle_zed(): + + def appid_is_zed(appid): + return appid == 'dev.zed.Zed' + + match stage: + case 'setup': + if transaction_type == 'install' and appid_is_zed(transaction_appid): + return 'ok' + else: + return 'pass' + + case 'setup-dialog': + return 'ok' + + case 'teardown-dialog': + if dialog_response_id == 'download': + return 'ok' + else: + return 'abort' + + case 'catch': + return 'abort' + + case 'action': + try: + spawn_brew('ublue-os/tap/zed-linux') + except Exception: + pass + return '' + + case 'teardown': + return 'deny' + + # --- response = 'pass' @@ -115,6 +150,8 @@ match hook_id: response = handle_jetbrains() case 'code': response = handle_code() + case 'zed': + response = handle_zed() print(response) sys.exit(0) diff --git a/tests/test_bazaar_hook.py b/tests/test_bazaar_hook.py index 09e59a892..0024ea24d 100644 --- a/tests/test_bazaar_hook.py +++ b/tests/test_bazaar_hook.py @@ -279,6 +279,80 @@ def test_teardown_returns_deny(self): assert resp == "deny" +# --------------------------------------------------------------------------- +# Zed hook +# --------------------------------------------------------------------------- + +class TestZedHook: + def test_setup_install_zed_returns_ok(self): + resp = _run_hook({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "setup", + "BAZAAR_TS_TYPE": "install", + "BAZAAR_TS_APPID": "dev.zed.Zed", + }) + assert resp == "ok" + + def test_setup_non_zed_returns_pass(self): + resp = _run_hook({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "setup", + "BAZAAR_TS_TYPE": "install", + "BAZAAR_TS_APPID": "org.mozilla.firefox", + }) + assert resp == "pass" + + def test_setup_dialog_returns_ok(self): + resp = _run_hook({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "setup-dialog", + }) + assert resp == "ok" + + def test_teardown_dialog_download_returns_ok(self): + resp = _run_hook({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "teardown-dialog", + "BAZAAR_HOOK_DIALOG_RESPONSE_ID": "download", + }) + assert resp == "ok" + + def test_teardown_dialog_cancel_returns_abort(self): + resp = _run_hook({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "teardown-dialog", + "BAZAAR_HOOK_DIALOG_RESPONSE_ID": "cancel", + }) + assert resp == "abort" + + def test_catch_returns_abort(self): + resp = _run_hook({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "catch", + }) + assert resp == "abort" + + def test_action_spawns_brew_and_returns_empty(self): + resp, popen_calls = _run_hook_with_mock({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "action", + "BAZAAR_TS_APPID": "dev.zed.Zed", + }) + assert resp == "" + assert len(popen_calls) == 1 + cmd = " ".join(popen_calls[0]) + assert "brew tap ublue-os/tap" in cmd + assert "brew trust ublue-os/tap" in cmd + assert "zed-linux" in cmd + + def test_teardown_returns_deny(self): + resp = _run_hook({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "teardown", + }) + assert resp == "deny" + + # --------------------------------------------------------------------------- # Unknown hook ID # --------------------------------------------------------------------------- diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 8e863e990..b8888d30b 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -23,6 +23,12 @@ def _load_hooks(env: dict) -> str: """Execute hooks.py under a given environment, return stdout.""" + resp, _ = _load_hooks_with_mock(env) + return resp + + +def _load_hooks_with_mock(env: dict) -> tuple[str, list]: + """Execute hooks.py under a given environment, return stdout and Popen calls.""" env_defaults = { "BAZAAR_HOOK_INITIATED_UNIX_STAMP": "0", "BAZAAR_HOOK_INITIATED_UNIX_STAMP_USEC": "0", @@ -40,14 +46,14 @@ def _load_hooks(env: dict) -> str: env_defaults.update(env) buf = io.StringIO() + popen_calls = [] with patch.dict(os.environ, env_defaults, clear=True): - # Mock subprocess.Popen so spawn helpers don't actually launch processes with patch("subprocess.Popen") as mock_popen: mock_popen.return_value = MagicMock() + mock_popen.side_effect = lambda args, **kwargs: (popen_calls.append(args), MagicMock())[1] spec = importlib.util.spec_from_file_location("hooks", HOOKS_PATH) mod = importlib.util.module_from_spec(spec) - # Redirect sys.stdout during exec so print() is captured old_stdout = sys.stdout sys.stdout = buf try: @@ -57,7 +63,7 @@ def _load_hooks(env: dict) -> str: finally: sys.stdout = old_stdout - return buf.getvalue().strip() + return buf.getvalue().strip(), popen_calls # --------------------------------------------------------------------------- @@ -225,6 +231,79 @@ def test_action_codium_returns_empty(self): assert resp == "" +# --------------------------------------------------------------------------- +# Zed hook +# --------------------------------------------------------------------------- + +class TestZedHook: + def test_setup_install_zed_returns_ok(self): + resp = _load_hooks({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "setup", + "BAZAAR_TS_TYPE": "install", + "BAZAAR_TS_APPID": "dev.zed.Zed", + }) + assert resp == "ok" + + def test_setup_non_zed_returns_pass(self): + resp = _load_hooks({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "setup", + "BAZAAR_TS_TYPE": "install", + "BAZAAR_TS_APPID": "org.gnome.Calculator", + }) + assert resp == "pass" + + def test_setup_dialog_returns_ok(self): + resp = _load_hooks({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "setup-dialog", + }) + assert resp == "ok" + + def test_teardown_dialog_download_returns_ok(self): + resp = _load_hooks({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "teardown-dialog", + "BAZAAR_HOOK_DIALOG_RESPONSE_ID": "download", + }) + assert resp == "ok" + + def test_teardown_dialog_cancel_returns_abort(self): + resp = _load_hooks({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "teardown-dialog", + "BAZAAR_HOOK_DIALOG_RESPONSE_ID": "cancel", + }) + assert resp == "abort" + + def test_catch_returns_abort(self): + resp = _load_hooks({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "catch", + }) + assert resp == "abort" + + def test_teardown_returns_deny(self): + resp = _load_hooks({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "teardown", + }) + assert resp == "deny" + + def test_action_spawns_brew_with_tap_and_trust(self): + resp, popen_calls = _load_hooks_with_mock({ + "BAZAAR_HOOK_ID": "zed", + "BAZAAR_HOOK_STAGE": "action", + }) + assert resp == "" + assert len(popen_calls) == 1 + cmd = " ".join(popen_calls[0]) + assert "brew tap ublue-os/tap" in cmd + assert "brew trust ublue-os/tap" in cmd + assert "--trust" not in cmd + assert "zed-linux" in cmd + # --------------------------------------------------------------------------- # Unknown hook ID # ---------------------------------------------------------------------------