From aff435a0fb3a56b1d7b738c7dede363440884b3a Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Thu, 17 Sep 2026 15:05:36 +0100 Subject: Add the Canonical laptop profile and setup workflows --- dot_local/lib/dotfiles/canonical_desktop.py | 201 ++++++++++++++++++++++++++++ dot_local/lib/dotfiles/record.py | 97 ++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 dot_local/lib/dotfiles/canonical_desktop.py create mode 100644 dot_local/lib/dotfiles/record.py (limited to 'dot_local/lib') diff --git a/dot_local/lib/dotfiles/canonical_desktop.py b/dot_local/lib/dotfiles/canonical_desktop.py new file mode 100644 index 0000000..fc9e946 --- /dev/null +++ b/dot_local/lib/dotfiles/canonical_desktop.py @@ -0,0 +1,201 @@ +"""Apply owned GNOME keys and restore their previous values.""" + +import importlib +import json +import os +import shlex +import sys +from pathlib import Path + +HOME = Path.home() +STATE = HOME / ".local/state/dotfiles/gnome-settings.json" +EXTENSIONS = [ + "paperwm@paperwm.github.com", + "copyous@boerdereinar.dev", + "emoji-copy@felipeftn", +] + + +def save_state(saved: dict) -> None: + STATE.parent.mkdir(parents=True, exist_ok=True) + temporary = STATE.with_suffix(".tmp") + temporary.write_text(json.dumps(saved, indent=2) + "\n") + temporary.chmod(0o600) + temporary.replace(STATE) + + +def settings_object(schema: str, path: str | None = None): + gio = importlib.import_module("gi.repository.Gio") + source = gio.SettingsSchemaSource.get_default() + for directory in (HOME / ".local/share/gnome-shell/extensions").glob("*/schemas"): + if (directory / "gschemas.compiled").exists(): + source = gio.SettingsSchemaSource.new_from_directory( + str(directory), source, False + ) + definition = source.lookup(schema, True) + if definition is None: + print( + f"Missing schema: {schema}. Install extensions, log in again, then retry." + ) + return None + return gio.Settings.new_full(definition, None, path) + + +def write_key( + schema: str, key: str, value, saved: dict, path: str | None = None +) -> None: + settings = settings_object(schema, path) + if settings is None: + return + if key not in settings.props.settings_schema.list_keys(): + raise RuntimeError(f"Unknown setting: {schema} {key}") + if not settings.is_writable(key): + print(f"Locked by policy: {schema} {key}") + return + glib = importlib.import_module("gi.repository.GLib") + variant = glib.Variant(settings.get_value(key).get_type_string(), value) + name = json.dumps([schema, key, path]) + if name not in saved: + previous = settings.get_user_value(key) + saved[name] = { + "before": previous.print_(True) if previous is not None else None + } + saved[name]["applied"] = variant.print_(True) + save_state(saved) + if not settings.set_value(key, variant): + raise RuntimeError(f"Cannot set {schema} {key}") + + +def merge_key(schema: str, key: str, values: list[str], saved: dict) -> None: + settings = settings_object(schema) + if settings is not None: + write_key( + schema, key, list(dict.fromkeys([*settings.get_strv(key), *values])), saved + ) + + +def shortcuts(saved: dict) -> None: + shell = shlex.quote(str(HOME / ".nix-profile/bin/zsh")) + actions = { + "terminal": ("Return", "/snap/bin/ghostty"), + "files": ("Return", f"/snap/bin/ghostty -e {shell} -lc yazi"), + "browser": ("b", "/snap/bin/firefox"), + "mail": ("t", "/snap/bin/thunderbird"), + "dictate": ("i", f"{shell} -lc dictate"), + "ocr": ("o", f"{shell} -lc ocr"), + "record": ("r", f"{shell} -lc 'record toggle'"), + "clipboard": ( + "p", + "gdbus call --session --dest org.gnome.Shell.Extensions.Copyous --object-path /org/gnome/Shell/Extensions/Copyous --method org.gnome.Shell.Extensions.Copyous.Show", + ), + } + schema = "org.gnome.settings-daemon.plugins.media-keys" + paths = [] + for name, (binding, command) in actions.items(): + path = f"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/dotfiles-{name}/" + paths.append(path) + for key, value in { + "name": name, + "binding": binding, + "command": command, + }.items(): + write_key(schema + ".custom-keybinding", key, value, saved, path) + merge_key(schema, "custom-keybindings", paths, saved) + + +def apply_settings(saved: dict) -> None: + merge_key("org.gnome.shell", "enabled-extensions", EXTENSIONS, saved) + merge_key( + "org.gnome.desktop.input-sources", + "xkb-options", + ["caps:escape", "compose:rctrl"], + saved, + ) + write_key( + "org.gnome.desktop.input-sources", "sources", [("xkb", "us+altgr-intl")], saved + ) + write_key("org.gnome.desktop.wm.keybindings", "close", ["q"], saved) + write_key("org.gnome.desktop.wm.keybindings", "minimize", ["F9"], saved) + write_key( + "org.gnome.shell.keybindings", "toggle-application-view", ["d"], saved + ) + write_key( + "org.gnome.desktop.wm.keybindings", "toggle-fullscreen", ["f"], saved + ) + paper = "org.gnome.shell.extensions.paperwm.keybindings" + for key in [ + "new-window", + "take-window", + "toggle-maximize-width", + "slurp-in", + "barf-out-active", + "cycle-height", + ]: + write_key(paper, key, [], saved) + for direction, letter in zip(["left", "down", "up", "right"], "hjkl"): + binding = "Right" if direction == "right" else f"{letter}" + write_key(paper, f"switch-{direction}", [binding], saved) + write_key(paper, f"move-{direction}", [f"{letter}"], saved) + shortcuts(saved) + workspaces(saved) + + +def workspaces(saved: dict) -> None: + write_key("org.gnome.mutter", "dynamic-workspaces", False, saved) + write_key("org.gnome.desktop.wm.preferences", "num-workspaces", 10, saved) + write_key("org.gnome.shell.extensions.dash-to-dock", "hot-keys", False, saved) + for number in range(1, 11): + key = str(number % 10) + for action, modifier in [("switch", ""), ("move", "")]: + write_key( + "org.gnome.desktop.wm.keybindings", + f"{action}-to-workspace-{number}", + [f"{modifier}{key}"], + saved, + ) + if number < 10: + write_key( + "org.gnome.shell.keybindings", + f"switch-to-application-{number}", + [], + saved, + ) + + +def restore(saved: dict) -> None: + glib = importlib.import_module("gi.repository.GLib") + for name, entry in list(saved.items()): + schema, key, path = json.loads(name) + settings = settings_object(schema, path) + if settings is None or not settings.is_writable(key): + continue + if settings.get_value(key).print_(True) != entry["applied"]: + print(f"Changed since deployment; retained: {schema} {key}") + continue + if entry["before"] is None: + settings.reset(key) + else: + settings.set_value( + key, glib.Variant.parse(None, entry["before"], None, None) + ) + del saved[name] + save_state(saved) + + +def main() -> None: + if (HOME / ".config/dotfiles/role").read_text().strip() != "canonical": + raise SystemExit("The desktop settings require the canonical role.") + if "GNOME" not in os.environ.get("XDG_CURRENT_DESKTOP", "").upper(): + raise SystemExit("Run this command from the GNOME desktop session.") + saved = json.loads(STATE.read_text()) if STATE.exists() else {} + if sys.argv[1:] == ["settings"]: + apply_settings(saved) + elif sys.argv[1:] == ["restore"]: + restore(saved) + else: + raise SystemExit("Use settings or restore.") + importlib.import_module("gi.repository.Gio").Settings.sync() + + +if __name__ == "__main__": + main() diff --git a/dot_local/lib/dotfiles/record.py b/dot_local/lib/dotfiles/record.py new file mode 100644 index 0000000..d6b3cf9 --- /dev/null +++ b/dot_local/lib/dotfiles/record.py @@ -0,0 +1,97 @@ +"""Control one GNOME portal recording in a transient user service.""" + +import fcntl +import os +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path + +APP = "com.dec05eba.gpu_screen_recorder" +UNIT = "dotfiles-record.service" + + +def active() -> bool: + return ( + subprocess.run( + ["systemctl", "--user", "is-active", "--quiet", UNIT], + check=False, + ).returncode + == 0 + ) + + +def start(runtime: Path) -> None: + if active(): + return + (runtime / "control.sock").unlink(missing_ok=True) + videos = Path.home() / "vids" + videos.mkdir(exist_ok=True) + output = videos / (datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S-%fZ") + ".mkv") + subprocess.run( + [ + "systemd-run", + "--user", + "--collect", + "--unit=" + UNIT, + "--property=KillSignal=SIGINT", + "--property=TimeoutStopSec=30s", + "/usr/bin/flatpak", + "run", + "--filesystem=" + str(videos), + "--filesystem=" + str(runtime), + "--command=gpu-screen-recorder", + APP, + "-w", + "portal", + "-f", + "60", + "-o", + str(output), + "-ipc", + str(runtime / "control.sock"), + ], + check=True, + ) + + +def stop(runtime: Path) -> None: + if not active(): + return + if (runtime / "control.sock").exists(): + subprocess.run( + [ + "/usr/bin/flatpak", + "run", + "--filesystem=" + str(runtime), + "--command=gsr-cli", + APP, + "-ipc", + str(runtime / "control.sock"), + "stop", + ], + check=True, + ) + else: + subprocess.run(["systemctl", "--user", "stop", UNIT], check=True) + + +def main() -> None: + action = sys.argv[1] if len(sys.argv) == 2 else "toggle" + if action not in {"start", "stop", "toggle", "status"}: + raise SystemExit("Use record start|stop|toggle|status") + if action == "status": + print("recording" if active() else "stopped") + return + runtime = Path(os.environ["XDG_RUNTIME_DIR"]) / "dotfiles-record" + runtime.mkdir(mode=0o700, exist_ok=True) + with (runtime / "lock").open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + if action == "stop" or (action == "toggle" and active()): + stop(runtime) + else: + start(runtime) + + +if __name__ == "__main__": + main() -- cgit v1.3.1