summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorsommerfeld <sommerfeld@sommerfeld.dev>2026-09-17 15:05:37 +0100
committersommerfeld <sommerfeld@sommerfeld.dev>2026-09-17 15:05:37 +0100
commit9b9b7b6824cb9219f93d1a70414b2412ea7d3585 (patch)
tree96aa6c1f0ede43d6d1e63df186017847345f69fe
parentf25d094d0652e8dedff206ca56671b3548f755ff (diff)
downloaddotfiles-9b9b7b6824cb9219f93d1a70414b2412ea7d3585.tar.gz
dotfiles-9b9b7b6824cb9219f93d1a70414b2412ea7d3585.tar.bz2
dotfiles-9b9b7b6824cb9219f93d1a70414b2412ea7d3585.zip
Add GNOME panel status and corporate desktop tools
-rw-r--r--.chezmoiignore6
-rw-r--r--KEYBINDS.md1
-rwxr-xr-xdot_local/bin/executable_wqr8
-rw-r--r--dot_local/lib/dotfiles/canonical_desktop.py40
-rw-r--r--dot_local/lib/dotfiles/canonical_panel.py109
-rw-r--r--dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/extension.js145
-rw-r--r--dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/metadata.json7
-rw-r--r--dot_local/share/gnome-shell/extensions/corporate-panel@dotfiles/stylesheet.css16
-rw-r--r--justfile12
-rw-r--r--meta/canonical/apt.txt3
-rw-r--r--meta/canonical/extensions.txt1
-rw-r--r--nix/canonical.nix2
-rw-r--r--tests/fixtures/panel-lifecycle.js112
-rw-r--r--tests/test_canonical.py2
-rw-r--r--tests/test_canonical_desktop.py24
-rw-r--r--tests/test_canonical_panel.py89
16 files changed, 564 insertions, 13 deletions
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 != "<Super>p"
+ ],
+ saved,
+ )
shell = shlex.quote(str(HOME / ".nix-profile/bin/zsh"))
actions = {
"terminal": ("<Super>Return", "/snap/bin/ghostty"),
@@ -138,6 +154,30 @@ def apply_settings(saved: dict) -> None:
write_key(paper, f"move-{direction}", [f"<Super><Shift>{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 = ["<Super>p", "XF86Display", "<Super>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", "<Super>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()