summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--.chezmoiignore2
-rw-r--r--docs/canonical-laptop.md23
-rw-r--r--dot_local/lib/dotfiles/canonical_desktop.py1
-rw-r--r--dot_local/share/gnome-shell/extensions/external-display@dotfiles/extension.js26
-rw-r--r--dot_local/share/gnome-shell/extensions/external-display@dotfiles/metadata.json7
-rw-r--r--scripts/canonical-vm-guest.sh5
-rw-r--r--tests/fixtures/display-lifecycle.js56
-rw-r--r--tests/test_canonical.py2
-rw-r--r--tests/test_canonical_displays.py28
9 files changed, 149 insertions, 1 deletions
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,
+ )