From 9b9b7b6824cb9219f93d1a70414b2412ea7d3585 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Thu, 17 Sep 2026 15:05:37 +0100 Subject: Add GNOME panel status and corporate desktop tools --- .chezmoiignore | 6 + KEYBINDS.md | 1 + dot_local/bin/executable_wqr | 8 +- dot_local/lib/dotfiles/canonical_desktop.py | 40 ++++++ dot_local/lib/dotfiles/canonical_panel.py | 109 ++++++++++++++++ .../corporate-panel@dotfiles/extension.js | 145 +++++++++++++++++++++ .../corporate-panel@dotfiles/metadata.json | 7 + .../corporate-panel@dotfiles/stylesheet.css | 16 +++ justfile | 12 +- meta/canonical/apt.txt | 3 + meta/canonical/extensions.txt | 1 + nix/canonical.nix | 2 + tests/fixtures/panel-lifecycle.js | 112 ++++++++++++++++ tests/test_canonical.py | 2 + tests/test_canonical_desktop.py | 24 ++++ tests/test_canonical_panel.py | 89 +++++++++++++ 16 files changed, 564 insertions(+), 13 deletions(-) create mode 100644 dot_local/lib/dotfiles/canonical_panel.py create mode 100644 dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/extension.js create mode 100644 dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/metadata.json create mode 100644 dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/stylesheet.css create mode 100644 tests/fixtures/panel-lifecycle.js create mode 100644 tests/test_canonical_panel.py diff --git a/.chezmoiignore b/.chezmoiignore index 28568bb..139587f 100644 --- a/.chezmoiignore +++ b/.chezmoiignore @@ -84,6 +84,11 @@ dot_config/nvim/nvim-pack-lock.json !.local/share/applications .local/share/applications/* !.local/share/applications/org.pwmt.zathura.desktop +!.local/share/gnome-shell +.local/share/gnome-shell/* +!.local/share/gnome-shell/extensions +.local/share/gnome-shell/extensions/* +!.local/share/gnome-shell/extensions/corporate-panel@dotfiles/** {{ range list "dictate" "ocr" "record" "rqr" "wqr" "linkhandler" }} !.local/bin/{{ . }} {{ end }} @@ -97,5 +102,6 @@ canonical-nvim-lock.sh .config/systemd/user/podman.socket .config/systemd/user/podman.service .local/lib/dotfiles/ +.local/share/gnome-shell/extensions/corporate-panel@dotfiles/ .local/bin/canonical-desktop {{ end }} diff --git a/KEYBINDS.md b/KEYBINDS.md index 9756d10..a5794f1 100644 --- a/KEYBINDS.md +++ b/KEYBINDS.md @@ -447,4 +447,5 @@ Use GNOME's screenshot UI and Emoji Copy's configured shortcut. PaperWM's other default shortcuts remain active. Sway modes and recovery keys do not apply to this role. Super+L remains the GNOME lock shortcut. Super+D opens the application launcher. +XF86Display remains the display-switching key; Super+P opens clipboard history. Super+1 through Super+9 select workspaces 1 through 9; Super+0 selects workspace 10. Add Shift to move the current window to that workspace. diff --git a/dot_local/bin/executable_wqr b/dot_local/bin/executable_wqr index b4cc123..5f9d36d 100755 --- a/dot_local/bin/executable_wqr +++ b/dot_local/bin/executable_wqr @@ -8,10 +8,4 @@ else text="$1" fi -if [ "$(cat "$HOME/.config/dotfiles/role" 2>/dev/null)" = canonical ]; then - image=$(mktemp "${XDG_RUNTIME_DIR:?}/dotfiles-qr-XXXXXX.png") - printf '%s' "$text" | qrencode -t PNG -o "$image" - /usr/bin/xdg-open "$image" -else - printf '%s' "$text" | qrencode -t PNG -o - | imv - -fi +printf '%s' "$text" | qrencode -t PNG -o - | imv - diff --git a/dot_local/lib/dotfiles/canonical_desktop.py b/dot_local/lib/dotfiles/canonical_desktop.py index fc9e946..cbda77f 100644 --- a/dot_local/lib/dotfiles/canonical_desktop.py +++ b/dot_local/lib/dotfiles/canonical_desktop.py @@ -13,6 +13,9 @@ EXTENSIONS = [ "paperwm@paperwm.github.com", "copyous@boerdereinar.dev", "emoji-copy@felipeftn", + "Vitals@CoreCoding.com", + "corporate-panel@dotfiles", + "ubuntu-appindicators@ubuntu.com", ] @@ -75,6 +78,19 @@ def merge_key(schema: str, key: str, values: list[str], saved: dict) -> None: def shortcuts(saved: dict) -> None: + display_schema = "org.gnome.mutter.keybindings" + display_keys = settings_object(display_schema) + if display_keys is not None: + write_key( + display_schema, + "switch-monitor", + [ + key + for key in display_keys.get_strv("switch-monitor") + if key != "p" + ], + saved, + ) shell = shlex.quote(str(HOME / ".nix-profile/bin/zsh")) actions = { "terminal": ("Return", "/snap/bin/ghostty"), @@ -138,6 +154,30 @@ def apply_settings(saved: dict) -> None: write_key(paper, f"move-{direction}", [f"{letter}"], saved) shortcuts(saved) workspaces(saved) + panel(saved) + + +def panel(saved: dict) -> None: + schema = "org.gnome.shell.extensions.vitals" + monitor = shlex.join( + ["/snap/bin/ghostty", "-e", str(HOME / ".nix-profile/bin/htop")] + ) + for key, value in { + "hot-sensors": [ + "_processor_usage_", + "__temperature_max__", + "_memory_usage_", + "__network-rx_max__", + "__network-tx_max__", + ], + "position-in-panel": 2, + "update-time": 5, + "include-public-ip": False, + "monitor-cmd": monitor, + }.items(): + write_key(schema, key, value, saved) + write_key("org.gnome.desktop.interface", "show-battery-percentage", True, saved) + write_key("org.gnome.desktop.interface", "clock-show-weekday", True, saved) def workspaces(saved: dict) -> None: diff --git a/dot_local/lib/dotfiles/canonical_panel.py b/dot_local/lib/dotfiles/canonical_panel.py new file mode 100644 index 0000000..befc3b6 --- /dev/null +++ b/dot_local/lib/dotfiles/canonical_panel.py @@ -0,0 +1,109 @@ +"""Read corporate panel status or open a requested desktop action.""" + +import json +import os +import shlex +import subprocess +import sys +from pathlib import Path + + +def output(*command: str) -> str: + return subprocess.check_output( + command, + text=True, + timeout=10, + stderr=subprocess.PIPE, + env={**os.environ, "LC_ALL": "C"}, + ) + + +def failed_units() -> str: + count = 0 + for scope in ([], ["--user"]): + units = json.loads( + output( + "/usr/bin/systemctl", *scope, "--failed", "--output=json", "--no-pager" + ) + ) + count += len(units) + return f"FAIL {count}" + + +def apt_updates() -> str: + lines = output("/usr/bin/apt", "list", "--upgradable").splitlines() + count = sum("/" in line.split()[0] for line in lines if line.strip()) + return f"APT {count}" + + +def displays(root: Path = Path("/sys/class/drm")) -> str: + connected = [ + path + for path in root.glob("*/status") + if "-eDP-" not in path.parent.name + and "-LVDS-" not in path.parent.name + and path.read_text().strip() == "connected" + ] + return f"EXT {len(connected)}" + + +def collect() -> dict: + status = { + "errors": "", + "reboot": "REBOOT" if Path("/run/reboot-required").exists() else "", + } + for name, label, read in [ + ("displays", "EXT", displays), + ("updates", "APT", apt_updates), + ("failed", "FAIL", failed_units), + ]: + try: + status[name] = read() + except (OSError, ValueError, subprocess.SubprocessError, RuntimeError) as error: + status[name] = f"{label} ?" + status["errors"] += f"{label}: {error}\n" + return status + + +def terminal(command: str) -> list[str]: + shell = str(Path.home() / ".nix-profile/bin/zsh") + pause = "; printf '\\nPress Enter to close'; read -r reply" + return ["/snap/bin/ghostty", "-e", shell, "-lc", command + pause] + + +def action_command(action: str) -> list[str]: + if action == "update": + source = output( + str(Path.home() / ".nix-profile/bin/chezmoi"), "source-path" + ).strip() + return terminal(f"cd -- {shlex.quote(source)} && just update") + if action == "failed": + return terminal("systemctl --failed; systemctl --user --failed") + if action == "updates": + return terminal("apt list --upgradable; snap refresh --list") + if action == "reboot": + return terminal("cat /run/reboot-required /run/reboot-required.pkgs") + if action == "monitor": + return terminal("htop") + if action == "audio": + return terminal("pulsemixer") + if action == "mail": + return ["/snap/bin/thunderbird"] + if action == "displays": + return ["/usr/bin/gnome-control-center", "display"] + raise ValueError(f"Unknown panel action: {action}") + + +def main() -> None: + if (Path.home() / ".config/dotfiles/role").read_text().strip() != "canonical": + raise SystemExit("The panel requires the canonical role.") + if sys.argv[1:] == ["status"]: + print(json.dumps(collect())) + elif len(sys.argv) == 3 and sys.argv[1] == "action": + subprocess.Popen(action_command(sys.argv[2]), start_new_session=True) + else: + raise SystemExit("Use status or action NAME.") + + +if __name__ == "__main__": + main() diff --git a/dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/extension.js b/dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/extension.js new file mode 100644 index 0000000..d959669 --- /dev/null +++ b/dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/extension.js @@ -0,0 +1,145 @@ +import Clutter from "gi://Clutter"; +import Gio from "gi://Gio"; +import GLib from "gi://GLib"; +import St from "gi://St"; +import { Extension } from "resource:///org/gnome/shell/extensions/extension.js"; +import * as Main from "resource:///org/gnome/shell/ui/main.js"; +import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js"; +import * as PopupMenu from "resource:///org/gnome/shell/ui/popupMenu.js"; + +export default class CorporatePanel extends Extension { + enable() { + this._cancellable = new Gio.Cancellable(); + this._button = new PanelMenu.Button(0.0, "Corporate status"); + const box = new St.BoxLayout({ style_class: "corporate-panel" }); + this._labels = {}; + for (const name of ["displays", "updates", "failed", "reboot"]) { + const label = new St.Label({ + text: "", + y_align: Clutter.ActorAlign.CENTER, + }); + this._labels[name] = label; + box.add_child(label); + } + this._button.add_child(box); + this._addActions(); + Main.panel.addToStatusArea(this.uuid, this._button, 1, "right"); + this._refresh(); + this._timer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 60, () => { + this._refresh(); + return GLib.SOURCE_CONTINUE; + }); + } + + _command(...args) { + return [ + "/usr/bin/python3", + GLib.build_filenamev([ + GLib.get_home_dir(), + ".local/lib/dotfiles/canonical_panel.py", + ]), + ...args, + ]; + } + + _addActions() { + this._status = new PopupMenu.PopupMenuItem("Reading status...", { + reactive: false, + }); + this._button.menu.addMenuItem(this._status); + for (const [name, title] of [ + ["monitor", "System monitor"], + ["audio", "Audio mixer"], + ["displays", "Display settings"], + ["failed", "Failed services"], + ["updates", "Available updates (apt and Snap)"], + ["update", "Run dotfiles update"], + ["reboot", "Reboot requirement details"], + ["mail", "Thunderbird"], + ]) { + this._button.menu.addAction(title, () => { + try { + Gio.Subprocess.new( + this._command("action", name), + Gio.SubprocessFlags.NONE, + ); + } catch (error) { + Main.notifyError("Corporate panel", error.message); + } + }); + } + this._button.menu.addAction("Refresh status", () => this._refresh()); + } + + _refresh() { + if (this._process) return; + const token = this._cancellable; + try { + const process = Gio.Subprocess.new( + this._command("status"), + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, + ); + this._process = process; + process.communicate_utf8_async(null, token, (source, result) => { + if (token.is_cancelled()) return; + this._process = null; + try { + const [, stdout, stderr] = source.communicate_utf8_finish(result); + if (!source.get_successful()) + throw new Error(stderr || "Status command failed"); + this._render(JSON.parse(stdout)); + } catch (error) { + this._showError(error); + } + }); + } catch (error) { + this._showError(error); + } + } + + _render(status) { + for (const [name, label] of Object.entries(this._labels)) { + const value = status[name]; + label.set_text(value); + label.visible = Boolean(value); + const warning = + value.includes("?") || (name === "updates" && value !== "APT 0"); + const critical = + name === "reboot" || (name === "failed" && value !== "FAIL 0"); + label.set_style_class_name( + critical + ? "corporate-critical" + : warning + ? "corporate-warning" + : "corporate-ok", + ); + } + this._status.label.set_text( + status.errors + ? "Some checks failed; see journal" + : "APT uses the local package cache", + ); + if (status.errors) console.warn(`Corporate panel: ${status.errors}`); + } + + _showError(error) { + this._render({ + displays: "EXT ?", + updates: "APT ?", + failed: "FAIL ?", + reboot: "", + errors: error.message, + }); + } + + disable() { + this._cancellable?.cancel(); + if (this._timer) GLib.Source.remove(this._timer); + this._timer = null; + this._process?.force_exit(); + this._process = null; + this._button?.destroy(); + this._button = null; + this._labels = null; + } +} diff --git a/dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/metadata.json b/dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/metadata.json new file mode 100644 index 0000000..a21b618 --- /dev/null +++ b/dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/metadata.json @@ -0,0 +1,7 @@ +{ + "uuid": "corporate-panel@dotfiles", + "name": "Corporate Panel", + "description": "Display status, package updates, and failed services.", + "shell-version": ["50"], + "version": 1 +} diff --git a/dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/stylesheet.css b/dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/stylesheet.css new file mode 100644 index 0000000..a198ccc --- /dev/null +++ b/dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/stylesheet.css @@ -0,0 +1,16 @@ +.corporate-panel { + spacing: 8px; + font-size: 12px; +} + +.corporate-ok { + color: #b8bb26; +} + +.corporate-warning { + color: #fabd2f; +} + +.corporate-critical { + color: #fb4934; +} diff --git a/justfile b/justfile index 8e4c924..9c4edaa 100644 --- a/justfile +++ b/justfile @@ -96,7 +96,7 @@ _require-canonical: source just-lib.sh [ "$(_machine_role)" = canonical ] -# Install the two program-scoped AppArmor profiles and Thunderbird GPG access. +# Install corporate app permissions and system integration. canonical-system: _require-canonical @bash scripts/canonical-system.sh @@ -320,7 +320,7 @@ fmt *target: _fmt_prettier --ignore-unknown --log-level=warn \ '**/*.md' '**/*.json' '**/*.jsonc' \ - '**/*.yaml' '**/*.yml' '**/*.css' + '**/*.yaml' '**/*.yml' '**/*.css' 'dot_local/share/gnome-shell/extensions/**/*.js' exit 0 fi @@ -338,7 +338,7 @@ fmt *target: *.py) _fmt_py "$target" ;; *.toml) _fmt_toml "$target" ;; *.nix) _fmt_nix "$target" ;; - *.md|*.json|*.jsonc|*.yaml|*.yml|*.css) _fmt_prettier "$target" ;; + *.md|*.json|*.jsonc|*.yaml|*.yml|*.css|*.js) _fmt_prettier "$target" ;; *) if _is_shellscript "$target"; then _fmt_sh "$target" @@ -385,7 +385,7 @@ check-fmt *target: _chk_prettier --ignore-unknown --log-level=warn \ '**/*.md' '**/*.json' '**/*.jsonc' \ - '**/*.yaml' '**/*.yml' '**/*.css' || rc=$? + '**/*.yaml' '**/*.yml' '**/*.css' 'dot_local/share/gnome-shell/extensions/**/*.js' || rc=$? exit $rc fi @@ -403,7 +403,7 @@ check-fmt *target: *.py) _chk_py "$target" ;; *.toml) _chk_toml "$target" ;; *.nix) _chk_nix "$target" ;; - *.md|*.json|*.jsonc|*.yaml|*.yml|*.css) _chk_prettier "$target" ;; + *.md|*.json|*.jsonc|*.yaml|*.yml|*.css|*.js) _chk_prettier "$target" ;; *) if _is_shellscript "$target"; then _chk_sh "$target" @@ -463,7 +463,7 @@ lint *target: *.sh) _lint_sh "$target" ;; *.py) _lint_py "$target"; _lint_pytype "$target" ;; *.toml) _lint_toml "$target" ;; - *.md|*.json|*.jsonc|*.yaml|*.yml|*.css) echo "skip: $target (no linter; use check-fmt)" >&2; exit 0 ;; + *.md|*.json|*.jsonc|*.yaml|*.yml|*.css|*.js) echo "skip: $target (no linter; use check-fmt)" >&2; exit 0 ;; *) if _is_shellscript "$target"; then _lint_sh "$target" diff --git a/meta/canonical/apt.txt b/meta/canonical/apt.txt index e8b5885..c2c033a 100644 --- a/meta/canonical/apt.txt +++ b/meta/canonical/apt.txt @@ -3,6 +3,9 @@ flatpak uidmap gir1.2-gda-5.0 gir1.2-gsound-1.0 +gir1.2-gtop-2.0 zbar-tools pinentry-gnome3 python3-gi +imv +gnome-shell-ubuntu-extensions diff --git a/meta/canonical/extensions.txt b/meta/canonical/extensions.txt index e3450d2..7844496 100644 --- a/meta/canonical/extensions.txt +++ b/meta/canonical/extensions.txt @@ -1,3 +1,4 @@ paperwm@paperwm.github.com copyous@boerdereinar.dev emoji-copy@felipeftn +Vitals@CoreCoding.com diff --git a/nix/canonical.nix b/nix/canonical.nix index 0f37427..c0f35da 100644 --- a/nix/canonical.nix +++ b/nix/canonical.nix @@ -8,10 +8,12 @@ external-editor-revived gnome-extensions-cli wl-clipboard + wtype qrencode libnotify playerctl pulseaudio + pulsemixer (tesseract.override { enableLanguages = [ "eng" diff --git a/tests/fixtures/panel-lifecycle.js b/tests/fixtures/panel-lifecycle.js new file mode 100644 index 0000000..d035289 --- /dev/null +++ b/tests/fixtures/panel-lifecycle.js @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; + +const callbacks = []; +let killed = 0; +let removed = 0; +class Actor { + constructor(props = {}) { + Object.assign(this, props); + } + add_child() {} + set_text(text) { + this.text = text; + } + set_style_class_name(name) { + this.style = name; + } + destroy() { + this.destroyed = true; + } +} +class Button extends Actor { + menu = { addMenuItem() {}, addAction() {} }; +} +class Item { + label = new Actor(); +} +class Extension { + uuid = "test"; +} +const St = { BoxLayout: Actor, Label: Actor }; +const Clutter = { ActorAlign: { CENTER: 0 } }; +const PanelMenu = { Button }; +const PopupMenu = { PopupMenuItem: Item }; +const Main = { panel: { addToStatusArea() {} }, notifyError() {} }; +const GLib = { + PRIORITY_DEFAULT: 0, + SOURCE_CONTINUE: true, + timeout_add_seconds: () => 1, + Source: { + remove() { + removed++; + }, + }, + get_home_dir: () => "/home/test", + build_filenamev: (parts) => parts.join("/"), +}; +const Gio = { + Cancellable: class { + cancelled = false; + cancel() { + this.cancelled = true; + } + is_cancelled() { + return this.cancelled; + } + }, + SubprocessFlags: { NONE: 0, STDOUT_PIPE: 1, STDERR_PIPE: 2 }, + Subprocess: { + new() { + return { + communicate_utf8_async(_input, _token, callback) { + callbacks.push(callback); + }, + force_exit() { + killed++; + }, + }; + }, + }, +}; + +// EXTENSION + +const panel = new CorporatePanel(); +panel.enable(); +panel._refresh(); +assert.equal(callbacks.length, 1, "Do not overlap status processes"); +panel._render({ + displays: "EXT 1", + updates: "APT 3", + failed: "FAIL 1", + reboot: "", + errors: "", +}); +assert.equal(panel._labels.failed.style, "corporate-critical"); +assert.equal(panel._labels.updates.style, "corporate-warning"); +assert.equal(panel._labels.reboot.visible, false); +const button = panel._button; +panel.disable(); +assert.equal(killed, 1); +assert.equal(removed, 1); +assert.equal(button.destroyed, true); +panel.enable(); +callbacks[0]({}, {}); +assert.ok(panel._process, "Old callbacks cannot clear the new process"); +const data = { + displays: "EXT 0", + updates: "APT 0", + failed: "FAIL 0", + reboot: "REBOOT", + errors: "", +}; +callbacks[1]( + { + communicate_utf8_finish: () => [true, JSON.stringify(data), ""], + get_successful: () => true, + }, + {}, +); +assert.equal(panel._labels.reboot.visible, true); +assert.equal(panel._process, null); +panel.disable(); diff --git a/tests/test_canonical.py b/tests/test_canonical.py index e1c825c..27fd199 100644 --- a/tests/test_canonical.py +++ b/tests/test_canonical.py @@ -132,6 +132,7 @@ class RoleTests(unittest.TestCase): ".config/git/config", ".config/ghostty/config", ".local/bin/rqr", + ".local/share/gnome-shell/extensions/corporate-panel@dotfiles/extension.js", ]: self.assertIn(required, files) for path in files: @@ -203,6 +204,7 @@ class RoleTests(unittest.TestCase): ) self.assertNotIn(".config/autostart/dotfiles-", files) self.assertNotIn("gpg-agent.service.d/canonical.conf", files) + self.assertNotIn("corporate-panel@dotfiles", files) if __name__ == "__main__": diff --git a/tests/test_canonical_desktop.py b/tests/test_canonical_desktop.py index cdbc812..9bdace1 100644 --- a/tests/test_canonical_desktop.py +++ b/tests/test_canonical_desktop.py @@ -14,6 +14,30 @@ SPEC.loader.exec_module(desktop) class DesktopTests(unittest.TestCase): + def test_clipboard_shortcut_preserves_other_display_bindings(self): + settings = MagicMock() + settings.get_strv.return_value = ["p", "XF86Display", "x"] + with ( + patch.object(desktop, "settings_object", return_value=settings), + patch.object(desktop, "write_key") as write, + ): + desktop.shortcuts({}) + write.assert_any_call( + "org.gnome.mutter.keybindings", + "switch-monitor", + ["XF86Display", "x"], + {}, + ) + + def test_panel_settings_disable_external_ip_lookup(self): + with patch.object(desktop, "write_key") as write: + desktop.panel({}) + write.assert_any_call( + "org.gnome.shell.extensions.vitals", "include-public-ip", False, {} + ) + self.assertIn("corporate-panel@dotfiles", desktop.EXTENSIONS) + self.assertIn("ubuntu-appindicators@ubuntu.com", desktop.EXTENSIONS) + def test_unknown_keys_fail_without_writing(self): settings = MagicMock() settings.props.settings_schema.list_keys.return_value = [] diff --git a/tests/test_canonical_panel.py b/tests/test_canonical_panel.py new file mode 100644 index 0000000..efdf7a5 --- /dev/null +++ b/tests/test_canonical_panel.py @@ -0,0 +1,89 @@ +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "panel", ROOT / "dot_local/lib/dotfiles/canonical_panel.py" +) +assert SPEC and SPEC.loader +panel = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(panel) + + +class PanelTests(unittest.TestCase): + def test_extension_lifecycle(self): + source = ( + ROOT + / "dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/extension.js" + ) + code = "\n".join( + line + for line in source.read_text().splitlines() + if not line.startswith("import ") + ) + code = code.replace("export default class", "class") + harness = (ROOT / "tests/fixtures/panel-lifecycle.js").read_text() + subprocess.run( + ["node", "--input-type=module"], + input=harness.replace("// EXTENSION", code), + text=True, + check=True, + ) + + def test_failed_units_use_both_scopes(self): + with patch.object( + panel, "output", side_effect=['[{"unit":"a.service"}]', "[]"] + ): + self.assertEqual(panel.failed_units(), "FAIL 1") + + def test_failed_query_does_not_report_healthy(self): + with patch.object(panel, "output", side_effect=RuntimeError("offline")): + status = panel.collect() + self.assertEqual(status["failed"], "FAIL ?") + self.assertIn("offline", status["errors"]) + + def test_updates_ignore_header(self): + with patch.object( + panel, + "output", + return_value="Listing...\na/stable 1 amd64 [upgradable from: 0]\n", + ): + self.assertEqual(panel.apt_updates(), "APT 1") + + def test_displays_do_not_depend_on_personal_connector_names(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name, value in [ + ("card1-DP-9", "connected"), + ("card0-eDP-1", "connected"), + ("card1-HDMI-A-1", "disconnected"), + ]: + (root / name).mkdir() + (root / name / "status").write_text(value) + self.assertEqual(panel.displays(root), "EXT 1") + + def test_update_action_quotes_source_directory(self): + with patch.object(panel, "output", return_value="/tmp/work tree'quoted\n"): + command = panel.action_command("update") + self.assertEqual(command[:2], ["/snap/bin/ghostty", "-e"]) + self.assertIn("just update", command[-1]) + self.assertIn("'\"'\"'", command[-1]) + + def test_extension_metadata_targets_corporate_gnome(self): + metadata = json.loads( + ( + ROOT + / "dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/metadata.json" + ).read_text() + ) + self.assertEqual(metadata["uuid"], "corporate-panel@dotfiles") + self.assertIn("50", metadata["shell-version"]) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.3.1