diff options
| author | sommerfeld <sommerfeld@sommerfeld.dev> | 2026-09-17 15:05:36 +0100 |
|---|---|---|
| committer | sommerfeld <sommerfeld@sommerfeld.dev> | 2026-09-17 15:05:36 +0100 |
| commit | aff435a0fb3a56b1d7b738c7dede363440884b3a (patch) | |
| tree | 07acb41d37760aa169ee42dbeb69da3621cc7489 | |
| parent | a7f74c1fa4b89c5829f5896842eb7248c820704c (diff) | |
| download | dotfiles-aff435a0fb3a56b1d7b738c7dede363440884b3a.tar.gz dotfiles-aff435a0fb3a56b1d7b738c7dede363440884b3a.tar.bz2 dotfiles-aff435a0fb3a56b1d7b738c7dede363440884b3a.zip | |
Add the Canonical laptop profile and setup workflows
60 files changed, 1583 insertions, 147 deletions
diff --git a/.chezmoi.toml.tmpl b/.chezmoi.toml.tmpl index 3582b7e..781cfbf 100644 --- a/.chezmoi.toml.tmpl +++ b/.chezmoi.toml.tmpl @@ -1,5 +1,8 @@ {{- $defaultMachineRole := default "host" (env "CHEZMOI_MACHINE_ROLE") -}} -{{- $machineRole := promptStringOnce . "machineRole" "Machine role (host or vm)" $defaultMachineRole -}} +{{- $machineRole := promptStringOnce . "machineRole" "Machine role (host, vm, canonical)" $defaultMachineRole -}} +{{- if not (has $machineRole (list "host" "vm" "canonical")) -}} +{{- fail "machineRole must be host, vm, or canonical" -}} +{{- end -}} sourceDir = {{ .chezmoi.sourceDir | quote }} [status] @@ -9,8 +12,12 @@ sourceDir = {{ .chezmoi.sourceDir | quote }} exclude = ["scripts"] [data] - # Machine role used by templates and run hooks. Valid values: "host", "vm". machineRole = {{ $machineRole | quote }} +{{- if eq $machineRole "canonical" }} + workName = {{ promptStringOnce . "workName" "Work Git name" | quote }} + workEmail = {{ promptStringOnce . "workEmail" "Canonical email address" | quote }} + workSigningKey = {{ promptStringOnce . "workSigningKey" "Work GPG signing key fingerprint" | quote }} +{{- end }} {{- if eq $machineRole "host" }} # Block device holding the LUKS-encrypted root, without the /dev/ prefix # (e.g. "nvme0n1p2", "sda2"). Resolved to a UUID at apply time via lsblk, diff --git a/.chezmoiignore b/.chezmoiignore index 01c4d9c..4c4ad4f 100644 --- a/.chezmoiignore +++ b/.chezmoiignore @@ -17,7 +17,84 @@ justfile just-lib.sh selene.toml selene-globals.yml +scripts/ +tests/ +docs/ +canonical/ +**/__pycache__/ +**/*.pyc dot_config/nvim/nvim-pack-lock.json -{{ if ne (default "host" (index . "machineRole")) "host" }} +{{ if eq (default "host" (index . "machineRole")) "vm" }} .local/share/applications/org.pwmt.zathura.desktop {{ end }} +{{ if eq (default "host" (index . "machineRole")) "canonical" }} +* +!.hushlogin +!.zshenv +!canonical-desktop.sh +!canonical-nvim-lock.sh +!.config +.config/* +{{ range list "bat" "cargo" "ccache" "clangd" "containers" "direnv" "dotfiles" "fd" "gdb" "ghostty" "ipython" "lsd" "nix" "npm" "opencode" "ripgrep" "sh" "tuicr" "wget" "yazi" "zellij" "zsh" "zathura" "autostart" }} +!.config/{{ . }}/** +{{ end }} +!.config/nvim +.config/nvim/* +!.config/nvim/*.lua +!.config/nvim/lua/** +!.config/nvim/after/** +!.config/git/config +!.config/git +.config/git/* +!.config/git/attributes +!.config/git/ignore +!.config/mimeapps.list +!.config/systemd/user/gpg-agent.service.d/canonical.conf +!.config/systemd +!.config/systemd/user +.config/systemd/user/* +!.config/systemd/user/gpg-agent.service.d/** +!.config/systemd/user/podman.socket +!.config/systemd/user/podman.service +!.gnupg +.gnupg/* +!.gnupg/gpg.conf +!.gnupg/gpg-agent.conf +!.ssh/config +!.ssh +.ssh/* +!.ssh/config +!.codex/** +!.claude/** +!.copilot/** +!.hermes/** +!.omp/** +!.local +.local/* +!.local/lib +.local/lib/* +!.local/lib/dotfiles +.local/lib/dotfiles/* +!.local/lib/dotfiles/*.py +!.local/bin +.local/bin/* +!.local/share +.local/share/* +!.local/share/applications +.local/share/applications/* +!.local/share/applications/org.pwmt.zathura.desktop +{{ range list "dictate" "ocr" "record" "rqr" "wqr" "linkhandler" }} +!.local/bin/{{ . }} +{{ end }} +.config/nvim/nvim-pack-lock.json +{{ else }} +canonical-desktop.sh +canonical-nvim-lock.sh +.config/nix/nix.conf +.config/autostart/dotfiles-*.desktop +.config/systemd/user/gpg-agent.service.d/canonical.conf +.config/systemd/user/podman.socket +.config/systemd/user/podman.service +.local/lib/dotfiles/ +.local/bin/canonical-desktop +{{ end }} diff --git a/.githooks/post-commit b/.githooks/post-commit index d2d7eba..03ad8d4 100755 --- a/.githooks/post-commit +++ b/.githooks/post-commit @@ -1,2 +1,2 @@ #!/bin/sh -chezmoi apply -S "$(git rev-parse --show-toplevel)" +printf '%s\n' 'Dotfiles committed. Run just apply on the target machine to deploy.' diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0a15f83..4d38a8c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,3 +21,9 @@ jobs: - name: just check run: nix develop ./nix --command just check + + - name: Role and package tests + run: nix develop ./nix --command just test + + - name: Evaluate the corporate profile + run: nix eval --impure ./nix#homeConfigurations.canonical.activationPackage.drvPath @@ -4,3 +4,5 @@ .ruff_cache/ node_modules/ *.swp +__pycache__/ +*.pyc diff --git a/KEYBINDS.md b/KEYBINDS.md index ced3cd1..9756d10 100644 --- a/KEYBINDS.md +++ b/KEYBINDS.md @@ -420,3 +420,31 @@ in `~/.XCompose`. Press and release Compose, then the sequence. | `Compose - - -` | `—` (em dash) | | `Compose - - .` | `–` (en dash) | | `Compose . . .` | `…` | + +# Canonical GNOME Role + +These keys apply only to the `canonical` role with PaperWM. Company-locked +settings remain unchanged. + +| Keys | Action | +| ------------------- | ---------------------------------------- | +| Super+Return | Ghostty | +| Super+Shift+Return | Yazi in Ghostty | +| Super+Shift+B | Firefox | +| Super+T | Thunderbird, normal window | +| Super+Shift+Q | Close window | +| Super+F | Fullscreen | +| Super+H/J/K/Right | Focus left/down/up/right | +| Super+Shift+H/J/K/L | Move window left/down/up/right | +| Super+I | Toggle dictation; copy text to clipboard | +| Super+Shift+O | NormCap OCR | +| Super+Shift+R | Toggle portal recording | +| Super+P | Copyous history | +| Caps Lock | Escape | +| Right Ctrl | Compose | + +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. +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/canonical/apparmor/dotfiles-nix b/canonical/apparmor/dotfiles-nix new file mode 100644 index 0000000..b894210 --- /dev/null +++ b/canonical/apparmor/dotfiles-nix @@ -0,0 +1,10 @@ +abi <abi/4.0>, +include <tunables/global> + +profile dotfiles-nix-bwrap /nix/store/*-bubblewrap-*/bin/bwrap flags=(unconfined) { + userns, +} + +profile dotfiles-nix-podman /nix/store/*-podman-*/bin/{podman,.podman-wrapped} flags=(unconfined) { + userns, +} diff --git a/canonical/firefox.js b/canonical/firefox.js new file mode 100644 index 0000000..aab1534 --- /dev/null +++ b/canonical/firefox.js @@ -0,0 +1,2 @@ +user_pref("browser.startup.page", 3); +user_pref("extensions.formautofill.creditCards.enabled", false); diff --git a/canonical/thunderbird.js b/canonical/thunderbird.js new file mode 100644 index 0000000..c5310fc --- /dev/null +++ b/canonical/thunderbird.js @@ -0,0 +1,4 @@ +user_pref("mail.openpgp.allow_external_gnupg", true); +user_pref("mail.biff.show_alert", true); +user_pref("mail.biff.use_system_alert", true); +user_pref("mailnews.default_view_flags", 0); diff --git a/dot_config/autostart/dotfiles-keybase.desktop b/dot_config/autostart/dotfiles-keybase.desktop new file mode 100644 index 0000000..fbc6b9d --- /dev/null +++ b/dot_config/autostart/dotfiles-keybase.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Type=Application +Name=Keybase +Exec=env KEYBASE_AUTOSTART=1 /snap/bin/keybase +OnlyShowIn=GNOME; diff --git a/dot_config/autostart/dotfiles-mattermost.desktop b/dot_config/autostart/dotfiles-mattermost.desktop new file mode 100644 index 0000000..aa309dd --- /dev/null +++ b/dot_config/autostart/dotfiles-mattermost.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Type=Application +Name=Mattermost +Exec=/snap/bin/mattermost-desktop --hidden +OnlyShowIn=GNOME; diff --git a/dot_config/autostart/dotfiles-nheko.desktop b/dot_config/autostart/dotfiles-nheko.desktop new file mode 100644 index 0000000..d71f16e --- /dev/null +++ b/dot_config/autostart/dotfiles-nheko.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Type=Application +Name=Nheko Work +Exec=/usr/bin/flatpak run im.nheko.Nheko --profile work +OnlyShowIn=GNOME; diff --git a/dot_config/autostart/dotfiles-thunderbird.desktop b/dot_config/autostart/dotfiles-thunderbird.desktop new file mode 100644 index 0000000..ced3696 --- /dev/null +++ b/dot_config/autostart/dotfiles-thunderbird.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Type=Application +Name=Thunderbird +Exec=/snap/bin/thunderbird +OnlyShowIn=GNOME; diff --git a/dot_config/containers/storage.conf.tmpl b/dot_config/containers/storage.conf.tmpl index c304b02..a148c5e 100644 --- a/dot_config/containers/storage.conf.tmpl +++ b/dot_config/containers/storage.conf.tmpl @@ -1,5 +1,5 @@ {{- $machineRole := default "host" (index . "machineRole") -}} -{{- if eq $machineRole "vm" -}} +{{- if ne $machineRole "host" -}} # Rootless podman storage configuration: VM ext4. [storage] diff --git a/dot_config/dotfiles/role.tmpl b/dot_config/dotfiles/role.tmpl new file mode 100644 index 0000000..f889c28 --- /dev/null +++ b/dot_config/dotfiles/role.tmpl @@ -0,0 +1 @@ +{{ default "host" (index . "machineRole") }} diff --git a/dot_config/ghostty/config b/dot_config/ghostty/config.tmpl index 8a1faf6..605c6d1 100644 --- a/dot_config/ghostty/config +++ b/dot_config/ghostty/config.tmpl @@ -13,7 +13,12 @@ clipboard-read = allow bell-features = attention,title # Window — no decorations (sway manages windows) +{{ if eq (default "host" (index . "machineRole")) "canonical" }} +window-decoration = auto +command = {{ .chezmoi.homeDir }}/.nix-profile/bin/zsh -l +{{ else }} window-decoration = none +{{ end }} confirm-close-surface = false resize-overlay = never diff --git a/dot_config/git/config b/dot_config/git/config.tmpl index 4237892..9987261 100644 --- a/dot_config/git/config +++ b/dot_config/git/config.tmpl @@ -1,7 +1,13 @@ [user] +{{ if eq (default "host" (index . "machineRole")) "canonical" }} + email = {{ .workEmail | quote }} + name = {{ .workName | quote }} + signingkey = {{ .workSigningKey | quote }} +{{ else }} email = sommerfeld@sommerfeld.dev name = sommerfeld signingkey = 3298945F717C85F8 +{{ end }} [push] autoSetupRemote = true followTags = true @@ -9,8 +15,10 @@ [core] whitespace = trailing-space,cr-at-eol pager = delta +{{ if ne (default "host" (index . "machineRole")) "canonical" }} hooksPath = ~/.config/git/hooks sshCommand = "ssh -i ~/.ssh/nym.pub" +{{ end }} [branch] sort=-committerdate [diff] @@ -129,6 +137,7 @@ swd = switch -d unstage = reset HEAD -- update=!git fetch && git merge --ff-only +{{ if ne (default "host" (index . "machineRole")) "canonical" }} [includeIf "gitdir:~/doxfiles/.git"] path = config-personal [includeIf "gitdir:~/dev/personal/"] @@ -149,6 +158,7 @@ assume8bitEncoding = UTF-8 [credential "smtp://127.0.0.1:1016"] helper = "!f() { test \"$1\" = get && printf 'password=%s\\n' \"$(pass show proton/bridge-smtp)\"; }; f" +{{ end }} [include] ; Machine-local overrides. Git silently skips this if the file is absent. path = ~/.config/git/config.local diff --git a/dot_config/mimeapps.list b/dot_config/mimeapps.list.tmpl index ab2f7e2..f5b6d20 100644 --- a/dot_config/mimeapps.list +++ b/dot_config/mimeapps.list.tmpl @@ -1,3 +1,16 @@ +{{ if eq (default "host" (index . "machineRole")) "canonical" }} +[Default Applications] +application/pdf=org.pwmt.zathura.desktop +x-scheme-handler/http=firefox_firefox.desktop +x-scheme-handler/https=firefox_firefox.desktop +text/html=firefox_firefox.desktop +x-scheme-handler/mailto=thunderbird_thunderbird.desktop +x-scheme-handler/webcal=thunderbird_thunderbird.desktop +message/rfc822=thunderbird_thunderbird.desktop + +[Removed Associations] +application/pdf=org.pwmt.zathura-pdf-poppler.desktop; +{{ else }} [Default Applications] audio/x-vorbis+ogg=io.mpv.Mpv.desktop audio/aac=io.mpv.Mpv.desktop @@ -115,3 +128,4 @@ x-scheme-handler/webcals=org.mozilla.thunderbird.desktop; [Removed Associations] application/pdf=org.pwmt.zathura-pdf-poppler.desktop; application/postscript=org.pwmt.zathura-ps.desktop; +{{ end }} diff --git a/dot_config/nix/nix.conf b/dot_config/nix/nix.conf new file mode 100644 index 0000000..16e17dd --- /dev/null +++ b/dot_config/nix/nix.conf @@ -0,0 +1,2 @@ +experimental-features = nix-command flakes +!include nix.local.conf diff --git a/dot_config/systemd/user/gpg-agent.service.d/canonical.conf.tmpl b/dot_config/systemd/user/gpg-agent.service.d/canonical.conf.tmpl new file mode 100644 index 0000000..34557b4 --- /dev/null +++ b/dot_config/systemd/user/gpg-agent.service.d/canonical.conf.tmpl @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart="{{ .chezmoi.homeDir }}/.nix-profile/bin/gpg-agent" --supervised diff --git a/dot_config/systemd/user/podman.service.tmpl b/dot_config/systemd/user/podman.service.tmpl new file mode 100644 index 0000000..f104bd9 --- /dev/null +++ b/dot_config/systemd/user/podman.service.tmpl @@ -0,0 +1,9 @@ +[Unit] +Description=Rootless Podman API +Requires=podman.socket +After=podman.socket + +[Service] +Type=exec +ExecStart="{{ .chezmoi.homeDir }}/.nix-profile/bin/podman" system service +KillMode=process diff --git a/dot_config/systemd/user/podman.socket b/dot_config/systemd/user/podman.socket new file mode 100644 index 0000000..9d17f3b --- /dev/null +++ b/dot_config/systemd/user/podman.socket @@ -0,0 +1,9 @@ +[Unit] +Description=Rootless Podman API socket + +[Socket] +ListenStream=%t/podman/podman.sock +SocketMode=0600 + +[Install] +WantedBy=sockets.target diff --git a/dot_config/zsh/dot_zprofile b/dot_config/zsh/dot_zprofile index a2f5c8b..ab6badb 100644 --- a/dot_config/zsh/dot_zprofile +++ b/dot_config/zsh/dot_zprofile @@ -4,6 +4,8 @@ # Guard against double-sourcing (e.g. nested login shells) [[ -n $__ZPROFILE_SOURCED ]] && return __ZPROFILE_SOURCED=1 +_dotfiles_role=host +[[ -r "$HOME/.config/dotfiles/role" ]] && _dotfiles_role=$(<"$HOME/.config/dotfiles/role") # ── PATH ────────────────────────────────────────────────────────────────────── typeset -U path # deduplicate PATH entries @@ -104,13 +106,15 @@ export _JAVA_OPTIONS="-Dawt.useSystemAAFontSettings=on -Dswing.aatext=true -Dswi export _JAVA_AWT_WM_NONREPARENTING=1 # ── Miscellaneous ───────────────────────────────────────────────────────────── -export QT_QPA_PLATFORMTHEME=qt6ct -export NO_AT_BRIDGE=1 # suppress GTK accessibility bus warnings +if [[ $_dotfiles_role == host ]]; then + export QT_QPA_PLATFORMTHEME=qt6ct + export NO_AT_BRIDGE=1 +fi export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock" export INPUTRC="$XDG_CONFIG_HOME/sh/inputrc" # ── Wayland ─────────────────────────────────────────────────────────────────── -export XDG_CURRENT_DESKTOP=sway +[[ $_dotfiles_role == host ]] && export XDG_CURRENT_DESKTOP=sway # Prefer Wayland for Qt; fall back to xcb for apps without qt{5,6}-wayland. export QT_QPA_PLATFORM="wayland;xcb" # SDL2 defaults to X11; force Wayland with X11 fallback. SDL3 ignores this. @@ -142,7 +146,7 @@ case $(uname -n) in esac # ── Auto-start sway on VT1 ──────────────────────────────────────────────────── -if [[ -z $WAYLAND_DISPLAY && $XDG_VTNR == 1 ]]; then +if [[ $_dotfiles_role == host && -z $WAYLAND_DISPLAY && $XDG_VTNR == 1 ]]; then export XDG_SESSION_TYPE=wayland exec sway fi diff --git a/dot_config/zsh/dot_zshrc b/dot_config/zsh/dot_zshrc index 8371c15..dc92c3c 100644 --- a/dot_config/zsh/dot_zshrc +++ b/dot_config/zsh/dot_zshrc @@ -385,8 +385,10 @@ za() { # (e.g. attached over SSH, then reattached locally, or vice versa). reload-env() { local pid - pid=$(pgrep -u "$UID" -x sway | head -1) || { - echo "reload-env: no sway process found for $USER" >&2 + local compositor=sway + [[ $(<"$HOME/.config/dotfiles/role") == canonical ]] && compositor=gnome-shell + pid=$(pgrep -u "$UID" -x "$compositor" | head -1) || { + echo "reload-env: no $compositor process found for $USER" >&2 return 1 } local kv @@ -409,11 +411,9 @@ alias rj='just --justfile ~/.local/share/dotfiles/nix/justfile --working-directo # Home-Manager (flake-based; standalone HM defaults to legacy ~/.config/home-manager) hm() { - local profile=host - [ -r /etc/os-release ] && . /etc/os-release - case "${ID:-}" in - ubuntu|debian) profile=vm ;; - esac + local profile + profile=$(chezmoi data | jq -er '.machineRole') || return + case $profile in host|vm|canonical) ;; *) return 1 ;; esac local flake="$HOME/dotfiles/nix#${profile}" [ -d "$HOME/.local/share/dotfiles/nix" ] && flake="$HOME/.local/share/dotfiles/nix#${profile}" nix run home-manager/master -- "$@" --flake "$flake" --impure diff --git a/dot_local/bin/executable_dictate b/dot_local/bin/executable_dictate index 7ad80bb..3cd763e 100644 --- a/dot_local/bin/executable_dictate +++ b/dot_local/bin/executable_dictate @@ -77,7 +77,9 @@ stop_and_transcribe() { fi printf '%s' "$text" | wl-copy - wtype -- "$text" + if [ "$(cat "$HOME/.config/dotfiles/role" 2>/dev/null)" != canonical ]; then + wtype -- "$text" + fi notify-send -t 2500 "🎙️ Dictated" "$text" } diff --git a/dot_local/bin/executable_linkhandler b/dot_local/bin/executable_linkhandler index e44dcc1..1ca006f 100755 --- a/dot_local/bin/executable_linkhandler +++ b/dot_local/bin/executable_linkhandler @@ -1,4 +1,7 @@ #!/usr/bin/env dash +if [ "$(cat "$HOME/.config/dotfiles/role" 2>/dev/null)" = canonical ]; then + exec /usr/bin/xdg-open "$@" +fi resolve_url() { if [ -f "$1" ]; then diff --git a/dot_local/bin/executable_ocr b/dot_local/bin/executable_ocr index aeadb51..d770911 100644 --- a/dot_local/bin/executable_ocr +++ b/dot_local/bin/executable_ocr @@ -21,6 +21,9 @@ if [ "${1:-}" ]; then } text="$(tesseract "$1" - -l "$lang" 2>/dev/null || true)" else + if [ "$(cat "$HOME/.config/dotfiles/role" 2>/dev/null)" = canonical ]; then + exec flatpak run com.github.dynobo.normcap + fi region="$(slurp 2>/dev/null)" || exit 0 text="$(grim -g "$region" - | tesseract - - -l "$lang" 2>/dev/null || true)" fi diff --git a/dot_local/bin/executable_record b/dot_local/bin/executable_record index ac88771..2601f9d 100755 --- a/dot_local/bin/executable_record +++ b/dot_local/bin/executable_record @@ -1,4 +1,7 @@ #!/usr/bin/env dash +if [ "$(cat "$HOME/.config/dotfiles/role" 2>/dev/null)" = canonical ]; then + exec /usr/bin/python3 "$HOME/.local/lib/dotfiles/record.py" "$@" +fi pid_file="/tmp/recordpid" log_file="/tmp/record.log" diff --git a/dot_local/bin/executable_wqr b/dot_local/bin/executable_wqr index 5f9d36d..b4cc123 100755 --- a/dot_local/bin/executable_wqr +++ b/dot_local/bin/executable_wqr @@ -8,4 +8,10 @@ else text="$1" fi -printf '%s' "$text" | qrencode -t PNG -o - | imv - +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 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": ("<Super>Return", "/snap/bin/ghostty"), + "files": ("<Super><Shift>Return", f"/snap/bin/ghostty -e {shell} -lc yazi"), + "browser": ("<Super><Shift>b", "/snap/bin/firefox"), + "mail": ("<Super>t", "/snap/bin/thunderbird"), + "dictate": ("<Super>i", f"{shell} -lc dictate"), + "ocr": ("<Super><Shift>o", f"{shell} -lc ocr"), + "record": ("<Super><Shift>r", f"{shell} -lc 'record toggle'"), + "clipboard": ( + "<Super>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", ["<Super><Shift>q"], saved) + write_key("org.gnome.desktop.wm.keybindings", "minimize", ["<Alt>F9"], saved) + write_key( + "org.gnome.shell.keybindings", "toggle-application-view", ["<Super>d"], saved + ) + write_key( + "org.gnome.desktop.wm.keybindings", "toggle-fullscreen", ["<Super>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 = "<Super>Right" if direction == "right" else f"<Super>{letter}" + write_key(paper, f"switch-{direction}", [binding], saved) + write_key(paper, f"move-{direction}", [f"<Super><Shift>{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", "<Shift>")]: + write_key( + "org.gnome.desktop.wm.keybindings", + f"{action}-to-workspace-{number}", + [f"<Super>{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() diff --git a/just-lib.sh b/just-lib.sh index 1846c26..5caa79f 100644 --- a/just-lib.sh +++ b/just-lib.sh @@ -91,3 +91,20 @@ _undeclared_packages() { done fi } +_machine_role() { + role=$(chezmoi data -S "${DOTFILES_SOURCE:-.}" | jq -er '.machineRole') || return 1 + case "$role" in + host | vm | canonical) printf '%s\n' "$role" ;; + *) + echo 'error: initialize chezmoi with host, vm, or canonical role first' >&2 + return 1 + ;; + esac +} + +_require_host() { + [ "$(_machine_role)" = host ] || { + echo 'error: this recipe is for the Arch host only' >&2 + return 1 + } +} @@ -7,7 +7,7 @@ default: # ═══════════════════════════════════════════════════════════════════ # First-time machine setup: regenerate chezmoi config, install git hooks, deploy dotfiles, install base packages, switch Home-Manager, enable curated units -init: _chezmoi-init _install-hooks apply (pkg-apply "base") nix-switch unit-apply +init: _chezmoi-init _install-hooks (pkg-apply "base") nix-switch apply unit-apply _canonical-finish # ═══════════════════════════════════════════════════════════════════ # Day-to-day @@ -16,61 +16,81 @@ init: _chezmoi-init _install-hooks apply (pkg-apply "base") nix-switch unit-appl # Reconcile everything: deploy dotfiles + /etc, top up packages, sync Home-Manager, enable curated units sync: apply pkg-fix nix-switch unit-apply -# Deploy dotfiles AND /etc atomically (chezmoi apply; /etc handled by onchange template) +# Deploy home files and the role-specific hooks. apply: chezmoi apply -S . -v -# Apply Home-Manager profile (host on Arch, vm on Ubuntu remote-dev). Falls - -# back to a no-op when nix isn't installed (pre-bootstrap state). +# Build and activate the saved chezmoi role using the locked Home-Manager input. nix-switch: - #!/bin/sh + @bash "{{ justfile_directory() }}/nix/switch.sh" + +# Read-only corporate setup checks. +canonical-check: + @python3 scripts/canonical.py check + +# Install corporate packages, profile, dotfiles, and desktop settings. +canonical-setup: _require-canonical _install-hooks (pkg-apply "base") nix-switch apply canonical-system canonical-extensions canonical-desktop + +_require-canonical: + #!/usr/bin/env bash set -eu - if ! command -v nix >/dev/null 2>&1; then - echo "nix not installed; skipping home-manager switch" >&2 - exit 0 - fi - # home-manager's activation script references $USER unconditionally; - # just runs recipes with a sanitized env that may drop it. - export USER="${USER:-$(id -un)}" - export HOME="${HOME:-$(getent passwd "$USER" | cut -d: -f6)}" - profile=host - [ -f /etc/os-release ] && . /etc/os-release || true - case "${ID:-}" in - ubuntu|debian) profile=vm ;; - esac - sh "{{ justfile_directory() }}/nix/with-github-auth.sh" \ - nix --extra-experimental-features 'nix-command flakes' \ - run home-manager/master -- \ - switch --impure --flake "{{ justfile_directory() }}/nix#${profile}" -b backup - # Keep the login shell pointed at the Home-Manager-managed zsh. - NIX_ZSH="$HOME/.nix-profile/bin/zsh" - if [ -x "$NIX_ZSH" ]; then - if ! grep -qxF "$NIX_ZSH" /etc/shells 2>/dev/null; then - echo "$NIX_ZSH" | sudo tee -a /etc/shells >/dev/null - fi - current_shell="$(getent passwd "$USER" | cut -d: -f7)" - if [ "$current_shell" != "$NIX_ZSH" ]; then - sudo chsh -s "$NIX_ZSH" "$USER" - fi - fi + source just-lib.sh + [ "$(_machine_role)" = canonical ] + +# Install the two program-scoped AppArmor profiles and Thunderbird GPG access. +canonical-system: _require-canonical + @bash scripts/canonical-system.sh + +canonical-extensions: _require-canonical + @python3 scripts/canonical.py extensions + +canonical-desktop: _require-canonical + @/usr/bin/python3 dot_local/lib/dotfiles/canonical_desktop.py settings + +# Set preferences after the first Firefox and Thunderbird launch. +canonical-profiles: _require-canonical + @python3 -m scripts.canonical_profiles + +# Restore only desktop keys previously changed by this repo. +canonical-desktop-restore: _require-canonical + @/usr/bin/python3 dot_local/lib/dotfiles/canonical_desktop.py restore + +# Upgrade an upstream multi-user Nix installation separately from package inputs. +nix-daemon-update: _require-canonical + @bash scripts/nix-daemon-update.sh + +# Check package commands and chezmoi role boundaries without deployment. +test: + @python3 -m unittest discover -s tests -v # ═══════════════════════════════════════════════════════════════════ # Updates # ═══════════════════════════════════════════════════════════════════ # Update everything: system packages, flatpaks, nix flake inputs -update: pkg-update flatpak-update nix-update nvim-update +update: pkg-update flatpak-update nix-update nvim-update _desktop-update # Upgrade official Arch packages, after showing newly published Arch news. pkg-update: arch-news-check _pacman-upgrade _pacman-upgrade: - @sudo pacman -Syu + #!/usr/bin/env bash + set -eu + source just-lib.sh + role=$(_machine_role) || exit 1 + case "$role" in + host) sudo pacman -Syu ;; + canonical) python3 scripts/canonical.py update ;; + vm) echo "System packages are not managed for the VM." ;; + esac # Show new Arch Linux news and ask whether to proceed, like paru's NewsOnUpgrade. arch-news-check: - @sh "{{ justfile_directory() }}/dot_local/bin/executable_arch-news-check" + #!/usr/bin/env bash + set -eu + source just-lib.sh + [ "$(_machine_role)" = host ] || exit 0 + sh "{{ justfile_directory() }}/dot_local/bin/executable_arch-news-check" # Mark the current Arch Linux news feed as seen without running an upgrade. arch-news-read: @@ -96,6 +116,12 @@ _nix-flake-update: # Update all user-scope flatpaks (Flathub apps + URL bundles when their version changes) flatpak-update: #!/usr/bin/env dash + . "{{ justfile_directory() }}/just-lib.sh" + role=$(_machine_role) || exit 1 + case "$role" in + canonical) exec python3 scripts/canonical.py flatpak-update ;; + vm) exit 0 ;; + esac set -eu flatpak update --user -y --noninteractive [ -f meta/flatpak.txt ] || exit 0 @@ -192,25 +218,7 @@ _lockfiles-commit: git commit -m "$msg" -- "${lockfiles[@]}" # Re-add changes from live files back into the repo; pass a path to target one, or omit for all -re-add *paths: - #!/usr/bin/env bash - set -eo pipefail - args=({{ paths }}) - if [ ${#args[@]} -eq 0 ]; then - just dotfiles-re-add - just etc-re-add - exit 0 - fi - for raw in "${args[@]}"; do - case "$raw" in - /etc/*|etc/*) just etc-re-add "$raw" ;; - */*) just dotfiles-re-add "$raw" ;; - *) - echo "error: re-add needs a path (got bare word: $raw)" >&2 - exit 1 - ;; - esac - done +re-add *paths: (_maintenance-home "re-add" paths) (_etc-re-add "auto" paths) (_maintenance-apply-etc "auto" paths) # Format code; pass a path to format a single file, or omit to format everything fmt *target: @@ -485,46 +493,21 @@ forget +args: just pkg-forget "${args[@]}" # Show dotfile + /etc diffs; pass a path to limit to a single file -diff *paths: - #!/usr/bin/env bash - set -eo pipefail - args=({{ paths }}) - if [ ${#args[@]} -eq 0 ]; then - just dotfiles-diff - just etc-diff - exit 0 - fi - for raw in "${args[@]}"; do - case "$raw" in - /etc/*|etc/*) just etc-diff "$raw" ;; - */*) just dotfiles-diff "$raw" ;; - *) - echo "error: diff needs a path (got bare word: $raw)" >&2 - exit 1 - ;; - esac - done +diff *paths: (_maintenance-home "diff" paths) (_etc-diff "auto" paths) # 3-way merge dotfile or /etc conflicts; pass a path for one file, or omit to merge all -merge *paths: +merge *paths: (_maintenance-home "merge" paths) (_etc-merge "auto" paths) + +_maintenance-home action *paths: #!/usr/bin/env bash set -eo pipefail - args=({{ paths }}) - if [ ${#args[@]} -eq 0 ]; then - just dotfiles-merge - just etc-merge - exit 0 - fi - for raw in "${args[@]}"; do - case "$raw" in - /etc/*|etc/*) just etc-merge "$raw" ;; - */*) just dotfiles-merge "$raw" ;; - *) - echo "error: merge needs a path (got bare word: $raw)" >&2 - exit 1 - ;; - esac - done + source just-lib.sh + source scripts/maintenance-lib.sh + _maintenance_select auto home {{ paths }} + "$maintenance_run" || exit 0 + action={{ action }} + if [ "$action" = merge ] && [ ${#args[@]} -eq 0 ]; then action=merge-all; fi + chezmoi "$action" -S . "${args[@]}" # ═══════════════════════════════════════════════════════════════════ # Dotfiles domain (chezmoi-backed) @@ -575,6 +558,8 @@ dotfiles-status: # List curated systemd units with their enabled/active state unit-list: #!/usr/bin/env dash + . "{{ justfile_directory() }}/just-lib.sh" + [ "$(_machine_role)" = host ] || { echo "Use canonical-check for corporate status."; exit 0; } _render() { scope=$1 file=$2 sctl="systemctl"; [ "$scope" = user ] && sctl="systemctl --user" @@ -604,6 +589,8 @@ unit-list: # Enable all curated systemd units (idempotent, soft-fail per unit); walks system + user lists unit-apply: #!/usr/bin/env dash + . "{{ justfile_directory() }}/just-lib.sh" + [ "$(_machine_role)" = host ] || exit 0 if [ -f systemd-units/system.txt ]; then sed -E 's/[[:space:]]*#.*$//; /^[[:space:]]*$/d' systemd-units/system.txt | while read -r u; do sudo systemctl enable --now "$u" \ @@ -620,6 +607,8 @@ unit-apply: # Show drift between curated units and actually-enabled systemd units (system + user) unit-status: #!/usr/bin/env dash + . "{{ justfile_directory() }}/just-lib.sh" + [ "$(_machine_role)" = host ] || { echo "Use canonical-check for corporate status."; exit 0; } tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT _drift() { scope=$1 label=$2 @@ -658,6 +647,8 @@ unit-status: # inferred by probing `systemctl [--user] cat <unit>` (system wins on tie). unit-add +units: #!/usr/bin/env dash + . "{{ justfile_directory() }}/just-lib.sh" + _require_host || exit 1 set -eu _scope() { u=$1 @@ -696,6 +687,8 @@ unit-add +units: # inferred from which list currently contains the unit. unit-forget +units: #!/usr/bin/env dash + . "{{ justfile_directory() }}/just-lib.sh" + _require_host || exit 1 set -eu for u in {{ units }}; do scope= @@ -728,6 +721,8 @@ unit-forget +units: # Show /etc drift: repo-tracked files that differ from or are missing on the host etc-status: #!/usr/bin/env bash + . "{{ justfile_directory() }}/just-lib.sh" + [ "$(_machine_role)" = host ] || { echo "Use canonical-check for corporate status."; exit 0; } set -eo pipefail tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT echo "=== /etc drift ===" @@ -749,9 +744,15 @@ etc-status: done < <(find etc -type f ! -name .ignore | sort) # Diff repo-managed etc/<path> against live /etc/<path> (all managed files if no args) -etc-diff *paths: +etc-diff *paths: (_etc-diff "host" paths) + +_etc-diff scope *paths: #!/usr/bin/env bash + . "{{ justfile_directory() }}/just-lib.sh" set -eo pipefail + source scripts/maintenance-lib.sh + _maintenance_select '{{ scope }}' etc {{ paths }} + "$maintenance_run" || exit 0 diff_labels=0 if diff -u --label old --label new /dev/null /dev/null >/dev/null 2>&1; then diff_labels=1 @@ -764,7 +765,6 @@ etc-diff *paths: diff -u "$left" "$right" fi } - args=({{ paths }}) if [ ${#args[@]} -eq 0 ]; then mapfile -t args < <(find etc -type f ! -name .ignore | sort) fi @@ -808,6 +808,8 @@ etc-diff *paths: # Diff live /etc/<path> against pristine pacman version (defaults to all repo-managed files) etc-upstream-diff *paths: #!/usr/bin/env bash + . "{{ justfile_directory() }}/just-lib.sh" + _require_host || exit 1 set -eo pipefail tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT diff_labels=0 @@ -880,10 +882,15 @@ etc-upstream-diff *paths: done # 3-way merge tracked /etc files against their live /etc counterparts (edit repo side) -etc-merge *paths: +etc-merge *paths: (_etc-merge "host" paths) + +_etc-merge scope *paths: #!/usr/bin/env bash + . "{{ justfile_directory() }}/just-lib.sh" set -eo pipefail - args=({{ paths }}) + source scripts/maintenance-lib.sh + _maintenance_select '{{ scope }}' etc {{ paths }} + "$maintenance_run" || exit 0 if [ ${#args[@]} -eq 0 ]; then mapfile -t args < <(find etc -type f ! -name .ignore | sort) fi @@ -926,6 +933,8 @@ etc-merge *paths: # Copy one or more /etc/<path> regular files into the repo's etc/ tree etc-add +paths: #!/usr/bin/env bash + . "{{ justfile_directory() }}/just-lib.sh" + _require_host || exit 1 set -eo pipefail for path in {{ paths }}; do case "$path" in @@ -943,15 +952,19 @@ etc-add +paths: echo "Run 'chezmoi apply' to sync (no-op content-wise, refreshes deploy hash)." # Re-add changes from live /etc back into the repo (no args = all tracked files) -etc-re-add *paths: (_etc-re-add paths) _apply-etc-re-add +etc-re-add *paths: (_etc-re-add "host" paths) (_maintenance-apply-etc "host" paths) -_etc-re-add *paths: +_etc-re-add scope *paths: #!/usr/bin/env bash + . "{{ justfile_directory() }}/just-lib.sh" set -eo pipefail + source scripts/maintenance-lib.sh + _maintenance_select '{{ scope }}' etc {{ paths }} + "$maintenance_run" || exit 0 # Build target list: explicit paths, or every tracked repo file. targets=() - if [ -n "{{ paths }}" ]; then - for raw in {{ paths }}; do + if [ ${#args[@]} -gt 0 ]; then + for raw in "${args[@]}"; do case "$raw" in *..*|*/./*|./*|../*) echo "error: unsafe path: $raw" >&2; exit 1 ;; esac @@ -999,6 +1012,8 @@ etc-forget +paths: (_etc-forget paths) _apply-etc-forget _etc-forget +paths: #!/usr/bin/env bash + . "{{ justfile_directory() }}/just-lib.sh" + _require_host || exit 1 set -eo pipefail for raw in {{ paths }}; do case "$raw" in @@ -1022,6 +1037,8 @@ etc-reset +paths: (_etc-reset paths) _apply-etc-reset _etc-reset +paths: #!/usr/bin/env bash + . "{{ justfile_directory() }}/just-lib.sh" + _require_host || exit 1 set -eo pipefail for raw in {{ paths }}; do case "$raw" in @@ -1058,7 +1075,13 @@ _etc-reset +paths: # Stop tracking one or more /etc files: reset to pristine, deploy, then drop from repo etc-untrack +paths: (_etc-reset paths) _apply-etc-reset (_etc-forget paths) _apply-etc-forget -_apply-etc-re-add: +_maintenance-apply-etc scope *paths: + #!/usr/bin/env bash + set -eo pipefail + source just-lib.sh + source scripts/maintenance-lib.sh + _maintenance_select '{{ scope }}' etc {{ paths }} + "$maintenance_run" || exit 0 chezmoi apply -S . -v _apply-etc-reset: @@ -1070,6 +1093,8 @@ _apply-etc-forget: # Restore live /etc/<path> to pristine pacman contents (bypasses the repo) etc-restore +paths: #!/usr/bin/env bash + . "{{ justfile_directory() }}/just-lib.sh" + _require_host || exit 1 set -eo pipefail for raw in {{ paths }}; do case "$raw" in @@ -1112,6 +1137,8 @@ etc-restore +paths: pkg-status: #!/usr/bin/env dash . "{{ justfile_directory() }}/just-lib.sh" + [ "$(_machine_role)" = host ] || { echo "Use canonical-check for corporate status."; exit 0; } + . "{{ justfile_directory() }}/just-lib.sh" flatpaks=$(flatpak list --user --app --columns=application 2>/dev/null || true) echo "=== Package drift ===" _active_pacman_packages | while read -r pkg; do @@ -1130,11 +1157,15 @@ pkg-status: undeclared: #!/usr/bin/env dash . "{{ justfile_directory() }}/just-lib.sh" + _require_host || exit 1 + . "{{ justfile_directory() }}/just-lib.sh" _undeclared_packages # Show per-group install coverage; pass a group name for a per-package breakdown pkg-list group="": #!/usr/bin/env dash + . "{{ justfile_directory() }}/just-lib.sh" + [ "$(_machine_role)" = host ] || { echo "Use canonical-check for corporate status."; exit 0; } is_installed() { # $1: group name, $2: package/app id if [ "$1" = "flatpak" ]; then @@ -1194,6 +1225,12 @@ pkg-list group="": # Install one or more package groups, or all groups if none given (e.g. just pkg-apply base intel) pkg-apply *groups: #!/usr/bin/env dash + . "{{ justfile_directory() }}/just-lib.sh" + role=$(_machine_role) || exit 1 + case "$role" in + canonical) exec python3 scripts/canonical.py install ;; + vm) exit 0 ;; + esac set -eu . "{{ justfile_directory() }}/just-lib.sh" # Keep declared packages marked explicit in the local pacman DB. @@ -1228,6 +1265,12 @@ pkg-apply *groups: pkg-fix: #!/usr/bin/env dash . "{{ justfile_directory() }}/just-lib.sh" + role=$(_machine_role) || exit 1 + case "$role" in + canonical) exec python3 scripts/canonical.py install ;; + vm) exit 0 ;; + esac + . "{{ justfile_directory() }}/just-lib.sh" flatpaks=$(flatpak list --user --app --columns=application 2>/dev/null || true) for file in meta/*.txt; do group=$(basename "$file" .txt) @@ -1258,6 +1301,8 @@ pkg-fix: # Append one or more packages to a group list and install them (e.g. just pkg-add base ripgrep fd) pkg-add group +pkgs: #!/usr/bin/env dash + . "{{ justfile_directory() }}/just-lib.sh" + _require_host || exit 1 set -eu file="meta/{{ group }}.txt" if [ ! -f "$file" ]; then @@ -1283,6 +1328,8 @@ pkg-add group +pkgs: # Remove one or more packages from a group list (does NOT uninstall; the package may belong to other groups) pkg-forget group +pkgs: #!/usr/bin/env dash + . "{{ justfile_directory() }}/just-lib.sh" + _require_host || exit 1 set -eu file="meta/{{ group }}.txt" if [ ! -f "$file" ]; then @@ -1307,8 +1354,14 @@ _chezmoi-init: chezmoi init -S . _install-hooks: - # Let the user-level git hooks dispatch project hooks. - git config --local --unset core.hooksPath 2>/dev/null || true + #!/usr/bin/env bash + set -eu + source just-lib.sh + if [ "$(_machine_role)" = canonical ]; then + git config --local core.hooksPath .githooks + else + git config --local --unset core.hooksPath 2>/dev/null || true + fi # Install all flatpaks declared in meta/flatpak.txt. Flathub IDs are batched # into a single install call; URL bundles are downloaded and installed only @@ -1326,3 +1379,19 @@ _active-packages: #!/usr/bin/env dash . "{{ justfile_directory() }}/just-lib.sh" _active_pacman_packages + +_desktop-update: + #!/usr/bin/env bash + set -eu + source just-lib.sh + [ "$(_machine_role)" = canonical ] || exit 0 + gext update --install $(sed '/^#/d; /^$/d' meta/canonical/extensions.txt) + +_canonical-finish: + #!/usr/bin/env bash + set -eu + source just-lib.sh + [ "$(_machine_role)" = canonical ] || exit 0 + bash scripts/canonical-system.sh + python3 scripts/canonical.py extensions + /usr/bin/python3 dot_local/lib/dotfiles/canonical_desktop.py settings diff --git a/meta/canonical/apt.txt b/meta/canonical/apt.txt new file mode 100644 index 0000000..e8b5885 --- /dev/null +++ b/meta/canonical/apt.txt @@ -0,0 +1,8 @@ +git +flatpak +uidmap +gir1.2-gda-5.0 +gir1.2-gsound-1.0 +zbar-tools +pinentry-gnome3 +python3-gi diff --git a/meta/canonical/extensions.txt b/meta/canonical/extensions.txt new file mode 100644 index 0000000..e3450d2 --- /dev/null +++ b/meta/canonical/extensions.txt @@ -0,0 +1,3 @@ +paperwm@paperwm.github.com +copyous@boerdereinar.dev +emoji-copy@felipeftn diff --git a/meta/canonical/flatpak.txt b/meta/canonical/flatpak.txt new file mode 100644 index 0000000..6f0b46b --- /dev/null +++ b/meta/canonical/flatpak.txt @@ -0,0 +1,4 @@ +org.pwmt.zathura +com.github.dynobo.normcap +com.dec05eba.gpu_screen_recorder +im.nheko.Nheko diff --git a/meta/canonical/snap.txt b/meta/canonical/snap.txt new file mode 100644 index 0000000..b9f79a9 --- /dev/null +++ b/meta/canonical/snap.txt @@ -0,0 +1,8 @@ +firefox +thunderbird +ghostty +mattermost-desktop +zoom-client +okular +libreoffice +keybase diff --git a/nix/canonical.nix b/nix/canonical.nix new file mode 100644 index 0000000..0f37427 --- /dev/null +++ b/nix/canonical.nix @@ -0,0 +1,24 @@ +{ pkgs, lib, ... }: +{ + imports = [ ./common.nix ]; + home.username = builtins.getEnv "USER"; + home.homeDirectory = builtins.getEnv "HOME"; + home.sessionVariables.NVIM_TREESITTER_CC = "${pkgs.stdenv.cc}/bin/cc"; + home.packages = with pkgs; [ + external-editor-revived + gnome-extensions-cli + wl-clipboard + qrencode + libnotify + playerctl + pulseaudio + (tesseract.override { + enableLanguages = [ + "eng" + "por" + ]; + }) + whisper-cpp + (import ./whisper-model.nix { inherit pkgs lib; }) + ]; +} diff --git a/nix/flake.nix b/nix/flake.nix index d871ce7..b140bf0 100644 --- a/nix/flake.nix +++ b/nix/flake.nix @@ -260,6 +260,10 @@ devShells.${system}.default = pkgs.mkShellNoCC { packages = with pkgs; [ basedpyright + chezmoi + git + jq + python3 just nixfmt prettier @@ -275,6 +279,7 @@ homeConfigurations = { vm = mkProfile ./vm.nix; host = mkProfile ./host.nix; + canonical = mkProfile ./canonical.nix; }; }; } diff --git a/nix/host.nix b/nix/host.nix index 588d635..b3d9d60 100644 --- a/nix/host.nix +++ b/nix/host.nix @@ -9,26 +9,7 @@ # Arch host Home-Manager package profile. let - whisper-cpp-model-base = pkgs.stdenvNoCC.mkDerivation rec { - pname = "whisper-cpp-model-base"; - version = "1.0"; - src = pkgs.fetchurl { - url = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin"; - hash = "sha256-YO1bw90U7qhWST0zQ0m0BXgt3K8AKNS130CINF+6Lv4="; - }; - dontUnpack = true; - installPhase = '' - runHook preInstall - install -Dm644 $src $out/share/whisper-cpp-models/ggml-base.bin - runHook postInstall - ''; - meta = with lib; { - description = "Whisper.cpp ggml-base.bin model (142 MB, multilingual)"; - homepage = "https://huggingface.co/ggerganov/whisper.cpp"; - license = licenses.mit; - platforms = platforms.all; - }; - }; + whisper-cpp-model-base = import ./whisper-model.nix { inherit pkgs lib; }; pass-secret-service-rust = pkgs.rustPlatform.buildRustPackage rec { pname = "pass-secret-service"; version = "0.7.0"; diff --git a/nix/justfile b/nix/justfile index 4ecd75b..7ea7b30 100644 --- a/nix/justfile +++ b/nix/justfile @@ -13,7 +13,7 @@ pull: # Rebuild Home-Manager profile from the current checkout (no pull) switch: - home-manager switch --impure --flake '{{ justfile_directory() }}#vm' -b backup + bash '{{ justfile_directory() }}/switch.sh' vm # Apply VM dotfiles with chezmoi apply: _ensure-vm-chezmoi-config @@ -37,6 +37,9 @@ _ensure-vm-chezmoi-config: fix-gpg-agent: #!/usr/bin/env sh set -eu + . "{{ justfile_directory() }}/../just-lib.sh" + DOTFILES_SOURCE="{{ justfile_directory() }}/.." + [ "$(_machine_role)" = vm ] gpgconf_bin="$HOME/.nix-profile/bin/gpgconf" gpg_connect_agent_bin="$HOME/.nix-profile/bin/gpg-connect-agent" [ -x "$gpgconf_bin" ] || gpgconf_bin=$(command -v gpgconf) @@ -59,7 +62,7 @@ fix-gpg-agent: "$gpg_connect_agent_bin" 'getinfo version' /bye # One-time migration from the old VM Home-Manager symlink deployment to chezmoi -migrate-chezmoi: pull switch fix-gpg-agent _cleanup-home-manager-dotfiles apply +migrate-chezmoi: pull _ensure-vm-chezmoi-config switch fix-gpg-agent _cleanup-home-manager-dotfiles apply _cleanup-home-manager-dotfiles: _ensure-vm-chezmoi-config #!/usr/bin/env bash @@ -90,7 +93,7 @@ _cleanup-home-manager-dotfiles: _ensure-vm-chezmoi-config # remove the old file when it still exactly matches the repo source. ssh_config="$HOME/.ssh/config" if [ -f "$ssh_config" ] && [ ! -L "$ssh_config" ]; then - if cmp -s "$ssh_config" "$src/private_dot_ssh/config"; then + if cmp -s "$ssh_config" <(chezmoi execute-template -S "$src" --file "$src/private_dot_ssh/config.tmpl"); then rm -f "$ssh_config" else printf 'refusing to overwrite modified %s; merge it before migrating\n' "$ssh_config" >&2 diff --git a/nix/switch.sh b/nix/switch.sh new file mode 100644 index 0000000..ce63855 --- /dev/null +++ b/nix/switch.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +cd "$root" +source "$root/just-lib.sh" +profile=$(_machine_role) +if ! command -v nix >/dev/null 2>&1; then + [[ $profile != canonical ]] || { + echo 'Install upstream Nix first.' >&2 + exit 1 + } + echo 'Nix is not installed; skipping Home-Manager.' >&2 + exit 0 +fi +if [[ -n ${1:-} && $profile != "$1" ]]; then + echo "error: expected role $1, got $profile" >&2 + exit 1 +fi +export USER="${USER:-$(id -un)}" +export HOME="${HOME:?HOME must be set}" +generation=$(sh "$root/nix/with-github-auth.sh" \ + nix --extra-experimental-features 'nix-command flakes' build --impure \ + --no-link --print-out-paths "$root/nix#homeConfigurations.$profile.activationPackage") +HOME_MANAGER_BACKUP_EXT=backup "$generation/activate" +if [[ $profile != canonical ]]; then + shell="$HOME/.nix-profile/bin/zsh" + if ! grep -qxF "$shell" /etc/shells; then + printf '%s\n' "$shell" | sudo tee -a /etc/shells >/dev/null + fi + if [[ $(getent passwd "$USER" | cut -d: -f7) != "$shell" ]]; then + sudo chsh -s "$shell" "$USER" + fi +fi diff --git a/nix/whisper-model.nix b/nix/whisper-model.nix new file mode 100644 index 0000000..3582bd3 --- /dev/null +++ b/nix/whisper-model.nix @@ -0,0 +1,21 @@ +{ pkgs, lib }: +pkgs.stdenvNoCC.mkDerivation rec { + pname = "whisper-cpp-model-base"; + version = "1.0"; + src = pkgs.fetchurl { + url = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin"; + hash = "sha256-YO1bw90U7qhWST0zQ0m0BXgt3K8AKNS130CINF+6Lv4="; + }; + dontUnpack = true; + installPhase = '' + runHook preInstall + install -Dm644 $src $out/share/whisper-cpp-models/ggml-base.bin + runHook postInstall + ''; + meta = with lib; { + description = "Whisper.cpp ggml-base.bin model (142 MB, multilingual)"; + homepage = "https://huggingface.co/ggerganov/whisper.cpp"; + license = licenses.mit; + platforms = platforms.all; + }; +} diff --git a/private_dot_gnupg/gpg-agent.conf b/private_dot_gnupg/gpg-agent.conf deleted file mode 100644 index 46879fd..0000000 --- a/private_dot_gnupg/gpg-agent.conf +++ /dev/null @@ -1,3 +0,0 @@ -enable-ssh-support -pinentry-program /home/sommerfeld/.nix-profile/bin/pinentry-curses -allow-loopback-pinentry diff --git a/private_dot_gnupg/gpg-agent.conf.tmpl b/private_dot_gnupg/gpg-agent.conf.tmpl new file mode 100644 index 0000000..ca074fb --- /dev/null +++ b/private_dot_gnupg/gpg-agent.conf.tmpl @@ -0,0 +1,7 @@ +enable-ssh-support +{{ if eq (default "host" (index . "machineRole")) "canonical" }} +pinentry-program /usr/bin/pinentry-gnome3 +{{ else }} +pinentry-program {{ .chezmoi.homeDir }}/.nix-profile/bin/pinentry-curses +{{ end }} +allow-loopback-pinentry diff --git a/private_dot_gnupg/gpg.conf b/private_dot_gnupg/gpg.conf.tmpl index 69ceb5a..ed5ceb5 100644 --- a/private_dot_gnupg/gpg.conf +++ b/private_dot_gnupg/gpg.conf.tmpl @@ -8,4 +8,8 @@ keyserver-options auto-key-retrieve keyid-format 0xlong with-fingerprint +{{ if eq (default "host" (index . "machineRole")) "canonical" }} +default-key {{ .workSigningKey }} +{{ else }} default-key B79DF5F37D7F9B0F390238D53298945F717C85F8 +{{ end }} diff --git a/private_dot_ssh/config b/private_dot_ssh/config.tmpl index 8df3687..b6dce43 100644 --- a/private_dot_ssh/config +++ b/private_dot_ssh/config.tmpl @@ -1,3 +1,10 @@ +{{ if eq (default "host" (index . "machineRole")) "canonical" }} +Include config.local +Host * + HashKnownHosts yes + ForwardAgent no + ServerAliveInterval 120 +{{ else }} # Ensure KnownHosts are unreadable if leaked HashKnownHosts yes @@ -17,3 +24,4 @@ Match host * exec "gpg-connect-agent UPDATESTARTUPTTY /bye" Match host *.wg,*.pulpo IdentityFile ~/.ssh/nym.pub +{{ end }} diff --git a/run_after_canonical-desktop.sh.tmpl b/run_after_canonical-desktop.sh.tmpl new file mode 100644 index 0000000..54e0a72 --- /dev/null +++ b/run_after_canonical-desktop.sh.tmpl @@ -0,0 +1,8 @@ +{{ if eq (default "host" (index . "machineRole")) "canonical" -}} +#!/bin/sh +set -eu +case "${XDG_CURRENT_DESKTOP:-}" in + *GNOME*|*gnome*) /usr/bin/python3 "$HOME/.local/lib/dotfiles/canonical_desktop.py" settings ;; + *) echo 'Run just canonical-desktop from GNOME to apply desktop keys.' ;; +esac +{{ end -}} diff --git a/run_before_canonical-nvim-lock.sh.tmpl b/run_before_canonical-nvim-lock.sh.tmpl new file mode 100644 index 0000000..3aa3606 --- /dev/null +++ b/run_before_canonical-nvim-lock.sh.tmpl @@ -0,0 +1,9 @@ +{{ if eq (default "host" (index . "machineRole")) "canonical" -}} +#!/bin/sh +set -eu +target="${XDG_CONFIG_HOME:-$HOME/.config}/nvim/nvim-pack-lock.json" +if [ ! -e "$target" ] && [ ! -L "$target" ]; then + mkdir -p "$(dirname "$target")" + cp {{ printf "%s/dot_config/nvim/nvim-pack-lock.json" .chezmoi.sourceDir | quote }} "$target" +fi +{{ end -}} diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/scripts/__init__.py diff --git a/scripts/canonical-system.sh b/scripts/canonical-system.sh new file mode 100644 index 0000000..e6f0825 --- /dev/null +++ b/scripts/canonical-system.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +source just-lib.sh +[[ $(_machine_role) == canonical ]] +[[ $( + # shellcheck disable=SC1091 + . /etc/os-release + echo "$ID" +) == ubuntu ]] +# Parse before replacing the installed profile. +sudo apparmor_parser --skip-kernel-load --skip-cache canonical/apparmor/dotfiles-nix +sudo install -m 644 canonical/apparmor/dotfiles-nix /etc/apparmor.d/dotfiles-nix +sudo apparmor_parser --replace /etc/apparmor.d/dotfiles-nix +sudo snap connect thunderbird:gpg-keys +systemctl --user daemon-reload +systemctl --user enable --now gpg-agent.socket gpg-agent-ssh.socket podman.socket +echo 'Existing GPG agent processes keep their executable until the next login.' diff --git a/scripts/canonical.py b/scripts/canonical.py new file mode 100644 index 0000000..2ff6310 --- /dev/null +++ b/scripts/canonical.py @@ -0,0 +1,137 @@ +"""Install declared corporate packages without removing existing packages.""" + +import argparse +import json +import os +import platform +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def packages(source: str) -> list[str]: + return [ + line.strip() + for line in (ROOT / "meta/canonical" / f"{source}.txt").read_text().splitlines() + if line.strip() and not line.startswith("#") + ] + + +def snap_install_commands() -> list[list[str]]: + return [ + ["sudo", "snap", "install", name, "--channel=stable"] + + (["--classic"] if name == "ghostty" else []) + for name in packages("snap") + ] + + +def flatpak_install_commands() -> list[list[str]]: + return [ + [ + "flatpak", + "remote-add", + "--user", + "--if-not-exists", + "flathub", + "https://flathub.org/repo/flathub.flatpakrepo", + ], + [ + "flatpak", + "install", + "--user", + "--assumeyes", + "flathub", + *packages("flatpak"), + ], + ] + + +def update_commands() -> list[list[str]]: + # An untargeted refresh respects Snap holds. Explicit targets override them. + return [ + ["sudo", "apt-get", "update"], + ["sudo", "apt-get", "upgrade"], + ["sudo", "snap", "refresh"], + ] + + +def require_canonical() -> None: + data = json.loads( + subprocess.check_output(["chezmoi", "data", "-S", str(ROOT)], text=True) + ) + if data.get("machineRole") != "canonical": + raise SystemExit("This command requires machineRole=canonical.") + if platform.freedesktop_os_release().get("ID") != "ubuntu": + raise SystemExit("This command requires Ubuntu.") + + +def install() -> None: + subprocess.run(["sudo", "apt-get", "update"], check=True) + subprocess.run(["sudo", "apt-get", "install", *packages("apt")], check=True) + for command in snap_install_commands(): + if subprocess.run( + ["snap", "list", command[3]], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode: + subprocess.run(command, check=True) + for command in flatpak_install_commands(): + subprocess.run(command, check=True) + + +def check() -> None: + commands = [ + ["lsb_release", "-ds"], + *[ + ["systemctl", "is-active", unit] + for unit in ["display-manager", "snapd", "apparmor", "nix-daemon"] + ], + ["landscape-config", "--actively-registered"], + ["snap", "connections", "thunderbird"], + ["snap", "list", *packages("snap")], + ["gnome-extensions", "list", "--enabled"], + ["flatpak", "list", "--user", "--app"], + *[["flatpak", "info", "--user", app] for app in packages("flatpak")], + ["getent", "passwd", str(os.getuid())], + ["getsubids", os.environ.get("USER", "")], + ["getsubids", "-g", os.environ.get("USER", "")], + ] + failed = False + for command in commands: + print("\n> " + " ".join(command), flush=True) + try: + failed |= subprocess.run(command, check=False).returncode != 0 + except FileNotFoundError: + print(f"Missing: {command[0]}") + failed = True + if failed: + raise SystemExit(1) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "action", choices=["install", "update", "flatpak-update", "extensions", "check"] + ) + args = parser.parse_args() + require_canonical() + if args.action == "install": + install() + elif args.action == "check": + check() + else: + commands = update_commands() + if args.action == "flatpak-update": + commands = [ + ["flatpak", "update", "--user", "--assumeyes", *packages("flatpak")] + ] + elif args.action == "extensions": + commands = [["gext", "install", *packages("extensions")]] + for command in commands: + subprocess.run(command, check=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/canonical_profiles.py b/scripts/canonical_profiles.py new file mode 100644 index 0000000..4c12085 --- /dev/null +++ b/scripts/canonical_profiles.py @@ -0,0 +1,74 @@ +"""Add owned preferences to existing Snap browser profiles.""" + +import configparser +import json +import shutil +from pathlib import Path + +from scripts.canonical import ROOT, require_canonical + +START = "// dotfiles: begin" +END = "// dotfiles: end" + + +def preferences(existing: str, owned: str) -> str: + if START in existing: + before, remainder = existing.split(START, 1) + if END not in remainder: + raise ValueError("Incomplete dotfiles preference block") + _, after = remainder.split(END, 1) + existing = before.rstrip() + after + return existing.rstrip() + "\n" + START + "\n" + owned.rstrip() + "\n" + END + "\n" + + +def profiles(root: Path) -> list[Path]: + ini = configparser.ConfigParser(interpolation=None) + ini.read(root / "profiles.ini") + result = [] + for section in ini.sections(): + if not section.startswith("Profile") or "Path" not in ini[section]: + continue + path = Path(ini[section]["Path"]) + if ini[section].get("IsRelative", "1") == "1": + path = root / path + if path.resolve().is_relative_to(root.resolve()) and path.is_dir(): + result.append(path) + return result + + +def deploy(root: Path, source: Path) -> None: + found = profiles(root) + if not found: + print(f"No profile under {root}. Start the app once, close it, then retry.") + for profile in found: + target = profile / "user.js" + existing = target.read_text() if target.exists() else "" + updated = preferences(existing, source.read_text()) + if updated == existing: + continue + if target.exists() and not target.with_suffix(".js.pre-dotfiles").exists(): + shutil.copy2(target, target.with_suffix(".js.pre-dotfiles")) + target.write_text(updated) + print(f"Updated {target}") + + +def main() -> None: + require_canonical() + home = Path.home() + deploy(home / "snap/firefox/common/.mozilla/firefox", ROOT / "canonical/firefox.js") + deploy( + home / "snap/thunderbird/common/.thunderbird", ROOT / "canonical/thunderbird.js" + ) + source = ( + home + / ".nix-profile/lib/mozilla/native-messaging-hosts/external_editor_revived.json" + ) + manifest = json.loads(source.read_text()) + target = home / ".mozilla/native-messaging-hosts/external_editor_revived.json" + target.parent.mkdir(parents=True, exist_ok=True) + manifest["path"] = str(home / ".nix-profile/bin/external-editor-revived") + target.write_text(json.dumps(manifest, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/maintenance-lib.sh b/scripts/maintenance-lib.sh new file mode 100644 index 0000000..33e7e07 --- /dev/null +++ b/scripts/maintenance-lib.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# Set args and maintenance_run for one domain of a maintenance request. +# shellcheck disable=SC2034 +_maintenance_select() { + local scope=$1 domain=$2 role raw target + shift 2 + role=$(_machine_role) || return 1 + args=() + maintenance_run=false + if [ "$scope" = host ]; then + _require_host || return 1 + args=("$@") + maintenance_run=true + return + fi + for raw in "$@"; do + case "$raw" in + /etc/* | etc/*) + [ "$role" = host ] || { + echo 'error: /etc paths require the host role' >&2 + return 1 + } + target=etc + ;; + */*) target=home ;; + *) + echo "error: expected a file path: $raw" >&2 + return 1 + ;; + esac + [ "$target" != "$domain" ] || args+=("$raw") + done + if [ ${#args[@]} -gt 0 ] || { [ $# -eq 0 ] && { [ "$domain" = home ] || [ "$role" = host ]; }; }; then + maintenance_run=true + fi + return 0 +} diff --git a/scripts/nix-daemon-update.sh b/scripts/nix-daemon-update.sh new file mode 100644 index 0000000..2b2d12c --- /dev/null +++ b/scripts/nix-daemon-update.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ -e /nix/nix-installer || -e /etc/nix/nix.custom.conf ]] || dpkg-query -W nix-bin >/dev/null 2>&1; then + echo 'error: this recipe is only for the upstream multi-user installer' >&2 + exit 1 +fi +nix=/nix/var/nix/profiles/default/bin/nix +[[ -x $nix ]] +sudo "$nix" --extra-experimental-features nix-command upgrade-nix --profile /nix/var/nix/profiles/default +sudo systemctl daemon-reload +sudo systemctl restart nix-daemon.service +"$nix" --version diff --git a/tests/test_canonical.py b/tests/test_canonical.py new file mode 100644 index 0000000..d1c2367 --- /dev/null +++ b/tests/test_canonical.py @@ -0,0 +1,163 @@ +import importlib.util +import json +import os +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( + "canonical", ROOT / "scripts/canonical.py" +) +assert SPEC and SPEC.loader +canonical = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(canonical) + + +class PackageTests(unittest.TestCase): + def test_non_corporate_role_is_rejected_before_system_access(self): + with ( + patch.object( + canonical.subprocess, + "check_output", + return_value='{"machineRole":"host"}', + ), + self.assertRaises(SystemExit), + ): + canonical.require_canonical() + + def test_classic_permission_is_explicit(self): + commands = canonical.snap_install_commands() + self.assertIn( + ["sudo", "snap", "install", "ghostty", "--channel=stable", "--classic"], + commands, + ) + self.assertEqual(sum("--classic" in command for command in commands), 1) + + def test_flatpaks_are_user_scoped(self): + commands = canonical.flatpak_install_commands() + self.assertTrue(all("--user" in command for command in commands)) + self.assertIn("im.nheko.Nheko", commands[-1]) + + def test_no_destructive_package_updates(self): + commands = canonical.update_commands() + self.assertIn(["sudo", "apt-get", "upgrade"], commands) + self.assertFalse( + any( + "autoremove" in command or "dist-upgrade" in command + for command in commands + ) + ) + self.assertFalse(any("--ignore-running" in command for command in commands)) + + +class RoleTests(unittest.TestCase): + def command(self, role: str, *args: str) -> list[str]: + return [ + "chezmoi", + "--config", + "/dev/null", + "--config-format", + "toml", + "--override-data", + json.dumps( + { + "machineRole": role, + "workName": "Work User", + "workEmail": "work@canonical.com", + "workSigningKey": "A" * 40, + } + ), + "-S", + str(ROOT), + *args, + ] + + def test_canonical_file_boundary(self): + files = subprocess.check_output( + self.command("canonical", "managed", "--include=files,scripts,symlinks"), + text=True, + ).splitlines() + for required in [ + ".ssh/config", + ".gnupg/gpg.conf", + ".config/git/config", + ".config/ghostty/config", + ".local/bin/rqr", + ]: + self.assertIn(required, files) + for path in files: + self.assertFalse( + any( + part in path + for part in [ + "sway", + "waybar", + "nym.pub", + "sshcontrol", + "pass-secret-service", + ".config/git/hooks", + "deploy-etc", + ] + ) + ) + self.assertNotIn("__pycache__", path) + self.assertNotEqual(path, ".config/nvim/nvim-pack-lock.json") + if path.endswith(".sh") and not path.startswith("."): + self.assertIn(path, ["canonical-desktop.sh", "canonical-nvim-lock.sh"]) + + def test_canonical_lockfile_is_seeded_without_overwriting(self): + rendered = subprocess.check_output( + self.command( + "canonical", + "execute-template", + "--file", + str(ROOT / "run_before_canonical-nvim-lock.sh.tmpl"), + ), + text=True, + ) + with tempfile.TemporaryDirectory() as directory: + env = { + **os.environ, + "HOME": directory, + "XDG_CONFIG_HOME": directory + "/config", + } + target = Path(directory) / "config/nvim/nvim-pack-lock.json" + subprocess.run(["sh", "-c", rendered], env=env, check=True) + self.assertEqual( + target.read_bytes(), + (ROOT / "dot_config/nvim/nvim-pack-lock.json").read_bytes(), + ) + target.write_text('{"local": true}\n') + subprocess.run(["sh", "-c", rendered], env=env, check=True) + self.assertEqual(target.read_text(), '{"local": true}\n') + + def test_work_identity_is_rendered_without_personal_identity(self): + for source in [ + "dot_config/git/config.tmpl", + "private_dot_ssh/config.tmpl", + "private_dot_gnupg/gpg.conf.tmpl", + ]: + rendered = subprocess.check_output( + self.command( + "canonical", "execute-template", "--file", str(ROOT / source) + ), + text=True, + ) + self.assertNotIn("sommerfeld", rendered) + self.assertNotIn("nym.pub", rendered) + self.assertNotIn("proton/", rendered) + + def test_host_and_vm_do_not_receive_corporate_autostart(self): + for role in ["host", "vm"]: + files = subprocess.check_output( + self.command(role, "managed", "--include=files,symlinks"), text=True + ) + self.assertNotIn(".config/autostart/dotfiles-", files) + self.assertNotIn("gpg-agent.service.d/canonical.conf", files) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_canonical_desktop.py b/tests/test_canonical_desktop.py new file mode 100644 index 0000000..cdbc812 --- /dev/null +++ b/tests/test_canonical_desktop.py @@ -0,0 +1,79 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "desktop", ROOT / "dot_local/lib/dotfiles/canonical_desktop.py" +) +assert SPEC and SPEC.loader +desktop = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(desktop) + + +class DesktopTests(unittest.TestCase): + def test_unknown_keys_fail_without_writing(self): + settings = MagicMock() + settings.props.settings_schema.list_keys.return_value = [] + with ( + patch.object(desktop, "settings_object", return_value=settings), + self.assertRaisesRegex(RuntimeError, "Unknown setting"), + ): + desktop.write_key("schema", "missing", "value", {}) + settings.set_value.assert_not_called() + + def test_launcher_uses_gnome_application_view_key(self): + with ( + patch.object(desktop, "merge_key"), + patch.object(desktop, "shortcuts"), + patch.object(desktop, "workspaces"), + patch.object(desktop, "write_key") as write, + ): + desktop.apply_settings({}) + write.assert_any_call( + "org.gnome.shell.keybindings", "toggle-application-view", ["<Super>d"], {} + ) + + def test_locked_keys_are_never_written(self): + settings = MagicMock() + settings.props.settings_schema.list_keys.return_value = ["key"] + settings.is_writable.return_value = False + with patch.object(desktop, "settings_object", return_value=settings): + desktop.write_key("schema", "key", "value", {}) + settings.set_value.assert_not_called() + + def test_first_snapshot_is_kept_on_repeated_apply(self): + settings = MagicMock() + settings.props.settings_schema.list_keys.return_value = ["key"] + settings.get_user_value.return_value.print_.return_value = "'original'" + glib = MagicMock() + glib.Variant.return_value.print_.return_value = "'managed'" + saved = {} + with ( + tempfile.TemporaryDirectory() as directory, + patch.object(desktop, "STATE", Path(directory) / "state.json"), + patch.object(desktop, "settings_object", return_value=settings), + patch.object(desktop.importlib, "import_module", return_value=glib), + ): + desktop.write_key("schema", "key", "managed", saved) + settings.get_user_value.return_value.print_.return_value = "'changed'" + desktop.write_key("schema", "key", "managed", saved) + self.assertEqual(next(iter(saved.values()))["before"], "'original'") + + def test_merge_keeps_unrelated_entries(self): + settings = MagicMock() + settings.get_strv.return_value = ["company", "personal"] + with ( + patch.object(desktop, "settings_object", return_value=settings), + patch.object(desktop, "write_key") as write, + ): + desktop.merge_key("schema", "key", ["personal", "new"], {}) + write.assert_called_once_with( + "schema", "key", ["company", "personal", "new"], {} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_canonical_profiles.py b/tests/test_canonical_profiles.py new file mode 100644 index 0000000..e066661 --- /dev/null +++ b/tests/test_canonical_profiles.py @@ -0,0 +1,33 @@ +import tempfile +import unittest +from pathlib import Path + +from scripts.canonical_profiles import preferences, profiles + + +class ProfileTests(unittest.TestCase): + def test_preferences_preserve_user_lines_and_are_idempotent(self): + original = 'user_pref("local.setting", true);\n' + owned = 'user_pref("mail.biff.show_alert", true);\n' + result = preferences(original, owned) + self.assertIn(original, result) + self.assertEqual(preferences(result, owned), result) + self.assertNotIn("show_alert", preferences(result, "")) + + def test_incomplete_marker_is_not_overwritten(self): + with self.assertRaises(ValueError): + preferences("// dotfiles: begin\nlocal data", "") + + def test_profile_paths_stay_inside_the_snap_directory(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "work@canonical.com").mkdir() + (root / "profiles.ini").write_text( + "[Profile0]\nPath=work@canonical.com\nIsRelative=1\n" + "[Profile1]\nPath=/etc\nIsRelative=0\n" + ) + self.assertEqual(profiles(root), [root / "work@canonical.com"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_canonical_record.py b/tests/test_canonical_record.py new file mode 100644 index 0000000..cfc9faf --- /dev/null +++ b/tests/test_canonical_record.py @@ -0,0 +1,51 @@ +import importlib.util +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( + "record", ROOT / "dot_local/lib/dotfiles/record.py" +) +assert SPEC and SPEC.loader +record = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(record) + + +class RecorderTests(unittest.TestCase): + def test_start_is_idempotent(self): + with ( + patch.object(record, "active", return_value=True), + patch.object(record.subprocess, "run") as run, + ): + record.start(Path("/unused")) + run.assert_not_called() + + def test_stop_before_portal_selection_stops_only_owned_service(self): + with ( + tempfile.TemporaryDirectory() as directory, + patch.object(record, "active", return_value=True), + patch.object(record.subprocess, "run") as run, + ): + record.stop(Path(directory)) + run.assert_called_once_with( + ["systemctl", "--user", "stop", "dotfiles-record.service"], check=True + ) + + def test_stop_uses_recorder_ipc_when_available(self): + with tempfile.TemporaryDirectory() as directory: + runtime = Path(directory) + (runtime / "control.sock").touch() + with ( + patch.object(record, "active", return_value=True), + patch.object(record.subprocess, "run") as run, + ): + record.stop(runtime) + command = run.call_args.args[0] + self.assertIn("--command=gsr-cli", command) + self.assertEqual(command[-1], "stop") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_recipes.py b/tests/test_recipes.py new file mode 100644 index 0000000..772833d --- /dev/null +++ b/tests/test_recipes.py @@ -0,0 +1,94 @@ +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class RecipeTests(unittest.TestCase): + def invoke(self, role, *recipes): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + commands = { + "chezmoi": 'if [ "$1" = data ]; then printf \'%s\\n\' "$ROLE_DATA"; else echo "chezmoi $*"; fi', + "sudo": 'echo "UNEXPECTED sudo"; exit 99', + "flatpak": 'echo "UNEXPECTED flatpak"; exit 99', + "pacman": 'echo "UNEXPECTED pacman"; exit 99', + } + for name, body in commands.items(): + executable = path / name + executable.write_text("#!/bin/sh\n" + body + "\n") + executable.chmod(0o755) + return subprocess.run( + ["just", *recipes], + cwd=ROOT, + env={ + **os.environ, + "PATH": f"{path}:{os.environ['PATH']}", + "ROLE_DATA": json.dumps({"machineRole": role}), + }, + capture_output=True, + text=True, + check=False, + ) + + def test_invalid_role_stops_package_commands(self): + for recipe in ["pkg-apply", "pkg-fix", "flatpak-update"]: + with self.subTest(recipe=recipe): + result = self.invoke("invalid", recipe) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("UNEXPECTED", result.stdout) + + def test_non_host_maintenance_only_uses_chezmoi(self): + for role in ["vm", "canonical"]: + for recipe in ["diff", "merge", "re-add"]: + with self.subTest(role=role, recipe=recipe): + result = self.invoke(role, recipe) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("chezmoi", result.stdout) + self.assertNotIn("UNEXPECTED", result.stdout) + + def test_non_host_etc_paths_fail_before_home_changes(self): + for recipe in ["diff", "merge", "re-add"]: + result = self.invoke("canonical", recipe, ".config/zsh", "etc/hosts") + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("chezmoi", result.stdout) + + def test_vm_migration_initializes_role_before_switch(self): + result = subprocess.check_output( + ["just", "--justfile", "nix/justfile", "--dry-run", "migrate-chezmoi"], + cwd=ROOT, + stderr=subprocess.STDOUT, + text=True, + ) + self.assertLess(result.index("chezmoi init"), result.index("switch.sh")) + + def test_host_home_paths_do_not_select_etc(self): + for recipe in ["diff", "merge", "re-add"]: + result = self.invoke("host", recipe, ".config/zsh") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(result.stdout.splitlines()), 1) + self.assertIn(".config/zsh", result.stdout) + + def test_host_mixed_paths_are_split_by_domain(self): + for domain, expected in [("home", ".config/zsh"), ("etc", "etc/hosts")]: + result = subprocess.check_output( + [ + "bash", + "-c", + ( + "source scripts/maintenance-lib.sh; " + "_machine_role() { echo host; }; " + '_maintenance_select auto "$1" .config/zsh etc/hosts; ' + 'printf "%s\\n" "$maintenance_run" "${args[@]}"' + ), + "test", + domain, + ], + cwd=ROOT, + text=True, + ) + self.assertEqual(result.splitlines(), ["true", expected]) |
