From 7773d867f6266a5cd72971bfb1256c8976b280f4 Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Tue, 22 Sep 2026 13:57:08 +0100 Subject: Prefer external displays on the corporate laptop --- .chezmoiignore | 2 + docs/canonical-laptop.md | 23 +++++++++ dot_local/lib/dotfiles/canonical_desktop.py | 1 + .../external-display@dotfiles/extension.js | 26 ++++++++++ .../external-display@dotfiles/metadata.json | 7 +++ scripts/canonical-vm-guest.sh | 5 +- tests/fixtures/display-lifecycle.js | 56 ++++++++++++++++++++++ tests/test_canonical.py | 2 + tests/test_canonical_displays.py | 28 +++++++++++ 9 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 dot_local/share/gnome-shell/extensions/external-display@dotfiles/extension.js create mode 100644 dot_local/share/gnome-shell/extensions/external-display@dotfiles/metadata.json create mode 100644 tests/fixtures/display-lifecycle.js create mode 100644 tests/test_canonical_displays.py diff --git a/.chezmoiignore b/.chezmoiignore index 8a29045..cc00228 100644 --- a/.chezmoiignore +++ b/.chezmoiignore @@ -89,6 +89,7 @@ dot_config/nvim/nvim-pack-lock.json !.local/share/gnome-shell/extensions .local/share/gnome-shell/extensions/* !.local/share/gnome-shell/extensions/corporate-panel@dotfiles/** +!.local/share/gnome-shell/extensions/external-display@dotfiles/** {{ range list "dictate" "ocr" "record" "rqr" "wqr" "linkhandler" }} !.local/bin/{{ . }} {{ end }} @@ -101,5 +102,6 @@ canonical-nvim-lock.sh .config/systemd/user/gpg-agent.service.d/canonical.conf .local/lib/dotfiles/ .local/share/gnome-shell/extensions/corporate-panel@dotfiles/ +.local/share/gnome-shell/extensions/external-display@dotfiles/ .local/bin/canonical-desktop {{ end }} diff --git a/docs/canonical-laptop.md b/docs/canonical-laptop.md index 9048a3a..79a782d 100644 --- a/docs/canonical-laptop.md +++ b/docs/canonical-laptop.md @@ -355,6 +355,29 @@ Inspect failures with `journalctl --user -b -g 'Corporate panel'` and enablement, unless they were changed afterwards. The extension files remain installed. The personal and VM roles do not receive them. +## External Displays + +The corporate-only `external-display@dotfiles` extension selects Mutter's +External Only layout when an external display is connected and the laptop +panel is active. Mutter selects display modes and restores the laptop panel +when the external displays are disconnected. Lid and power policies are +unchanged. While enabled, the extension overrides a manual layout that uses +both the laptop panel and an external display. + +To deploy it, run `just apply` outside the sandbox, log out and log in, then +run `just canonical-desktop`. Enabling it can immediately change the active +displays. Check connection, disconnection, and reconnection with the lid open. +Also check resolution and scaling on the external display. + +To stop automatic selection, run: + +```sh +gnome-extensions disable external-display@dotfiles +``` + +Then choose a layout in GNOME Settings > Displays. Running +`just canonical-desktop` enables the extension again. + ## Updates and Rollback `just update` upgrades apt packages, refreshes Snaps without overriding holds, diff --git a/dot_local/lib/dotfiles/canonical_desktop.py b/dot_local/lib/dotfiles/canonical_desktop.py index 85d265d..d530058 100644 --- a/dot_local/lib/dotfiles/canonical_desktop.py +++ b/dot_local/lib/dotfiles/canonical_desktop.py @@ -10,6 +10,7 @@ from pathlib import Path HOME = Path.home() STATE = HOME / ".local/state/dotfiles/gnome-settings.json" EXTENSIONS = [ + "external-display@dotfiles", "o-tiling@oliwebd.github.com", "copyous@boerdereinar.dev", "emoji-copy@felipeftn", diff --git a/dot_local/share/gnome-shell/extensions/external-display@dotfiles/extension.js b/dot_local/share/gnome-shell/extensions/external-display@dotfiles/extension.js new file mode 100644 index 0000000..ce32fd9 --- /dev/null +++ b/dot_local/share/gnome-shell/extensions/external-display@dotfiles/extension.js @@ -0,0 +1,26 @@ +import Meta from "gi://Meta"; +import { Extension } from "resource:///org/gnome/shell/extensions/extension.js"; + +export default class ExternalDisplay extends Extension { + enable() { + this._manager = global.backend.get_monitor_manager(); + this._signal = this._manager.connect("monitors-changed", () => + this._apply(), + ); + this._apply(); + } + + _apply() { + if ( + this._manager.has_builtin_panel && + this._manager.can_switch_config() && + this._manager.get_is_builtin_display_on() + ) + this._manager.switch_config(Meta.MonitorSwitchConfigType.EXTERNAL); + } + + disable() { + this._manager.disconnect(this._signal); + this._manager = null; + } +} diff --git a/dot_local/share/gnome-shell/extensions/external-display@dotfiles/metadata.json b/dot_local/share/gnome-shell/extensions/external-display@dotfiles/metadata.json new file mode 100644 index 0000000..ab3eba8 --- /dev/null +++ b/dot_local/share/gnome-shell/extensions/external-display@dotfiles/metadata.json @@ -0,0 +1,7 @@ +{ + "uuid": "external-display@dotfiles", + "name": "External Display", + "description": "Use external displays when connected to a laptop.", + "shell-version": ["50"], + "version": 1 +} diff --git a/scripts/canonical-vm-guest.sh b/scripts/canonical-vm-guest.sh index 647fd0a..03a89ff 100644 --- a/scripts/canonical-vm-guest.sh +++ b/scripts/canonical-vm-guest.sh @@ -16,7 +16,10 @@ check_extensions() { gnome-extensions info "$extension" gnome-extensions list --enabled | grep -Fx "$extension" gnome-extensions info "$extension" | grep -Eq 'State: (ACTIVE|ENABLED)$' - done < <(sed '/^#/d; /^$/d' meta/canonical/extensions.txt) + done < <( + sed '/^#/d; /^$/d' meta/canonical/extensions.txt + printf '%s\n' external-display@dotfiles + ) } check_mattermost_keyring() { diff --git a/tests/fixtures/display-lifecycle.js b/tests/fixtures/display-lifecycle.js new file mode 100644 index 0000000..2173c47 --- /dev/null +++ b/tests/fixtures/display-lifecycle.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; + +class Extension {} +const Meta = {MonitorSwitchConfigType: {EXTERNAL: 2}}; +let callback; +let requests = 0; +let switchable = true; +let builtinOn = true; +const manager = { + has_builtin_panel: true, + connect(signal, handler) { + assert.equal(signal, 'monitors-changed'); + callback = handler; + return 1; + }, + disconnect(id) { + assert.equal(id, 1); + callback = null; + }, + can_switch_config: () => switchable, + get_is_builtin_display_on: () => builtinOn, + switch_config(type) { + assert.equal(type, Meta.MonitorSwitchConfigType.EXTERNAL); + requests++; + builtinOn = false; + }, +}; +globalThis.global = {backend: {get_monitor_manager: () => manager}}; + +// EXTENSION + +const extension = new ExternalDisplay(); +extension.enable(); +assert.equal(requests, 1); +callback(); +assert.equal(requests, 1); + +// One connected display, or a closed lid, prevents switching. +switchable = false; +builtinOn = true; +callback(); +assert.equal(requests, 1); +switchable = true; +callback(); +assert.equal(requests, 2); + +manager.has_builtin_panel = false; +builtinOn = true; +callback(); +assert.equal(requests, 2); +extension.disable(); +assert.equal(callback, null); +manager.has_builtin_panel = true; +extension.enable(); +assert.equal(requests, 3); +extension.disable(); diff --git a/tests/test_canonical.py b/tests/test_canonical.py index 848bf6c..fb0bff9 100644 --- a/tests/test_canonical.py +++ b/tests/test_canonical.py @@ -183,6 +183,7 @@ class RoleTests(unittest.TestCase): ".config/ghostty/config", ".local/bin/rqr", ".local/share/gnome-shell/extensions/corporate-panel@dotfiles/extension.js", + ".local/share/gnome-shell/extensions/external-display@dotfiles/extension.js", ]: self.assertIn(required, files) for path in files: @@ -281,6 +282,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) + self.assertNotIn("external-display@dotfiles", files) if __name__ == "__main__": diff --git a/tests/test_canonical_displays.py b/tests/test_canonical_displays.py new file mode 100644 index 0000000..627e3a0 --- /dev/null +++ b/tests/test_canonical_displays.py @@ -0,0 +1,28 @@ +import json +import subprocess +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EXTENSION = ROOT / "dot_local/share/gnome-shell/extensions/external-display@dotfiles" + + +class DisplayTests(unittest.TestCase): + def test_metadata(self): + metadata = json.loads((EXTENSION / "metadata.json").read_text()) + self.assertEqual(metadata["uuid"], "external-display@dotfiles") + self.assertIn("50", metadata["shell-version"]) + + def test_connection_changes_and_lifecycle(self): + code = "\n".join( + line + for line in (EXTENSION / "extension.js").read_text().splitlines() + if not line.startswith("import ") + ).replace("export default class", "class") + harness = (ROOT / "tests/fixtures/display-lifecycle.js").read_text() + subprocess.run( + ["node", "--input-type=module"], + input=harness.replace("// EXTENSION", code), + text=True, + check=True, + ) -- cgit v1.3.1