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 --- .chezmoi.toml.tmpl | 11 +- .chezmoiignore | 79 +++++- .githooks/post-commit | 2 +- .github/workflows/check.yml | 6 + .gitignore | 2 + KEYBINDS.md | 28 +++ canonical/apparmor/dotfiles-nix | 10 + canonical/firefox.js | 2 + canonical/thunderbird.js | 4 + dot_config/autostart/dotfiles-keybase.desktop | 5 + dot_config/autostart/dotfiles-mattermost.desktop | 5 + dot_config/autostart/dotfiles-nheko.desktop | 5 + dot_config/autostart/dotfiles-thunderbird.desktop | 5 + dot_config/containers/storage.conf.tmpl | 2 +- dot_config/dotfiles/role.tmpl | 1 + dot_config/ghostty/config | 35 --- dot_config/ghostty/config.tmpl | 40 +++ dot_config/git/config | 154 ------------ dot_config/git/config.tmpl | 164 ++++++++++++ dot_config/mimeapps.list | 117 --------- dot_config/mimeapps.list.tmpl | 131 ++++++++++ dot_config/nix/nix.conf | 2 + .../user/gpg-agent.service.d/canonical.conf.tmpl | 3 + dot_config/systemd/user/podman.service.tmpl | 9 + dot_config/systemd/user/podman.socket | 9 + dot_config/zsh/dot_zprofile | 12 +- dot_config/zsh/dot_zshrc | 14 +- dot_local/bin/executable_dictate | 4 +- dot_local/bin/executable_linkhandler | 3 + dot_local/bin/executable_ocr | 3 + dot_local/bin/executable_record | 3 + dot_local/bin/executable_wqr | 8 +- dot_local/lib/dotfiles/canonical_desktop.py | 201 +++++++++++++++ dot_local/lib/dotfiles/record.py | 97 ++++++++ just-lib.sh | 17 ++ justfile | 275 +++++++++++++-------- meta/canonical/apt.txt | 8 + meta/canonical/extensions.txt | 3 + meta/canonical/flatpak.txt | 4 + meta/canonical/snap.txt | 8 + nix/canonical.nix | 24 ++ nix/flake.nix | 5 + nix/host.nix | 21 +- nix/justfile | 9 +- nix/switch.sh | 33 +++ nix/whisper-model.nix | 21 ++ private_dot_gnupg/gpg-agent.conf | 3 - private_dot_gnupg/gpg-agent.conf.tmpl | 7 + private_dot_gnupg/gpg.conf | 11 - private_dot_gnupg/gpg.conf.tmpl | 15 ++ private_dot_ssh/config | 19 -- private_dot_ssh/config.tmpl | 27 ++ run_after_canonical-desktop.sh.tmpl | 8 + run_before_canonical-nvim-lock.sh.tmpl | 9 + scripts/__init__.py | 0 scripts/canonical-system.sh | 18 ++ scripts/canonical.py | 137 ++++++++++ scripts/canonical_profiles.py | 74 ++++++ scripts/maintenance-lib.sh | 38 +++ scripts/nix-daemon-update.sh | 12 + tests/test_canonical.py | 163 ++++++++++++ tests/test_canonical_desktop.py | 79 ++++++ tests/test_canonical_profiles.py | 33 +++ tests/test_canonical_record.py | 51 ++++ tests/test_recipes.py | 94 +++++++ 65 files changed, 1919 insertions(+), 483 deletions(-) create mode 100644 canonical/apparmor/dotfiles-nix create mode 100644 canonical/firefox.js create mode 100644 canonical/thunderbird.js create mode 100644 dot_config/autostart/dotfiles-keybase.desktop create mode 100644 dot_config/autostart/dotfiles-mattermost.desktop create mode 100644 dot_config/autostart/dotfiles-nheko.desktop create mode 100644 dot_config/autostart/dotfiles-thunderbird.desktop create mode 100644 dot_config/dotfiles/role.tmpl delete mode 100644 dot_config/ghostty/config create mode 100644 dot_config/ghostty/config.tmpl delete mode 100644 dot_config/git/config create mode 100644 dot_config/git/config.tmpl delete mode 100644 dot_config/mimeapps.list create mode 100644 dot_config/mimeapps.list.tmpl create mode 100644 dot_config/nix/nix.conf create mode 100644 dot_config/systemd/user/gpg-agent.service.d/canonical.conf.tmpl create mode 100644 dot_config/systemd/user/podman.service.tmpl create mode 100644 dot_config/systemd/user/podman.socket create mode 100644 dot_local/lib/dotfiles/canonical_desktop.py create mode 100644 dot_local/lib/dotfiles/record.py create mode 100644 meta/canonical/apt.txt create mode 100644 meta/canonical/extensions.txt create mode 100644 meta/canonical/flatpak.txt create mode 100644 meta/canonical/snap.txt create mode 100644 nix/canonical.nix create mode 100644 nix/switch.sh create mode 100644 nix/whisper-model.nix delete mode 100644 private_dot_gnupg/gpg-agent.conf create mode 100644 private_dot_gnupg/gpg-agent.conf.tmpl delete mode 100644 private_dot_gnupg/gpg.conf create mode 100644 private_dot_gnupg/gpg.conf.tmpl delete mode 100644 private_dot_ssh/config create mode 100644 private_dot_ssh/config.tmpl create mode 100644 run_after_canonical-desktop.sh.tmpl create mode 100644 run_before_canonical-nvim-lock.sh.tmpl create mode 100644 scripts/__init__.py create mode 100644 scripts/canonical-system.sh create mode 100644 scripts/canonical.py create mode 100644 scripts/canonical_profiles.py create mode 100644 scripts/maintenance-lib.sh create mode 100644 scripts/nix-daemon-update.sh create mode 100644 tests/test_canonical.py create mode 100644 tests/test_canonical_desktop.py create mode 100644 tests/test_canonical_profiles.py create mode 100644 tests/test_canonical_record.py create mode 100644 tests/test_recipes.py 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 diff --git a/.gitignore b/.gitignore index 490507e..ce223a1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 , +include + +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 deleted file mode 100644 index 8a1faf6..0000000 --- a/dot_config/ghostty/config +++ /dev/null @@ -1,35 +0,0 @@ -# Theme (verify: ghostty +list-themes | grep -i gruvbox) -theme = Gruvbox Dark - -# Cursor — block blink; disable shell integration cursor override -cursor-style = block -cursor-style-blink = true -shell-integration-features = no-cursor,ssh-env,ssh-terminfo - -# OSC 52 clipboard for zellij (default is "ask" which prompts each time) -clipboard-read = allow - -# Bell — request urgency hint on BEL so sway/waybar highlight the workspace -bell-features = attention,title - -# Window — no decorations (sway manages windows) -window-decoration = none -confirm-close-surface = false -resize-overlay = never - -# Disable built-in tab/split keybinds (zellij handles multiplexing) -keybind = ctrl+shift+t=unbind -keybind = ctrl+shift+n=unbind -keybind = ctrl+shift+o=unbind -keybind = ctrl+shift+enter=unbind - -# Scroll keybinds (for use outside zellij) -keybind = ctrl+shift+up=scroll_page_lines:-1 -keybind = ctrl+shift+down=scroll_page_lines:1 -# Copy URL under cursor to clipboard (works inside zellij without mouse) -keybind = ctrl+shift+u=copy_url_to_clipboard - -keybind = alt+u=scroll_page_up -keybind = alt+d=scroll_page_down -keybind = alt+g=scroll_to_top -keybind = alt+shift+g=scroll_to_bottom diff --git a/dot_config/ghostty/config.tmpl b/dot_config/ghostty/config.tmpl new file mode 100644 index 0000000..605c6d1 --- /dev/null +++ b/dot_config/ghostty/config.tmpl @@ -0,0 +1,40 @@ +# Theme (verify: ghostty +list-themes | grep -i gruvbox) +theme = Gruvbox Dark + +# Cursor — block blink; disable shell integration cursor override +cursor-style = block +cursor-style-blink = true +shell-integration-features = no-cursor,ssh-env,ssh-terminfo + +# OSC 52 clipboard for zellij (default is "ask" which prompts each time) +clipboard-read = allow + +# Bell — request urgency hint on BEL so sway/waybar highlight the workspace +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 + +# Disable built-in tab/split keybinds (zellij handles multiplexing) +keybind = ctrl+shift+t=unbind +keybind = ctrl+shift+n=unbind +keybind = ctrl+shift+o=unbind +keybind = ctrl+shift+enter=unbind + +# Scroll keybinds (for use outside zellij) +keybind = ctrl+shift+up=scroll_page_lines:-1 +keybind = ctrl+shift+down=scroll_page_lines:1 +# Copy URL under cursor to clipboard (works inside zellij without mouse) +keybind = ctrl+shift+u=copy_url_to_clipboard + +keybind = alt+u=scroll_page_up +keybind = alt+d=scroll_page_down +keybind = alt+g=scroll_to_top +keybind = alt+shift+g=scroll_to_bottom diff --git a/dot_config/git/config b/dot_config/git/config deleted file mode 100644 index 4237892..0000000 --- a/dot_config/git/config +++ /dev/null @@ -1,154 +0,0 @@ -[user] - email = sommerfeld@sommerfeld.dev - name = sommerfeld - signingkey = 3298945F717C85F8 -[push] - autoSetupRemote = true - followTags = true - gpgSign = if-asked -[core] - whitespace = trailing-space,cr-at-eol - pager = delta - hooksPath = ~/.config/git/hooks - sshCommand = "ssh -i ~/.ssh/nym.pub" -[branch] - sort=-committerdate -[diff] - tool = nvimdiff - algorithm = histogram - colorMoved = default - colorMovedWS = allow-indentation-change - mnemonicPrefix = true - relative = true - renameLimit = 10000 -[difftool] - prompt = false - trustExitCode = true -[pager] - difftool = true -[difftool "nvimdiff"] - cmd = $EDITOR -d $LOCAL $REMOTE -[difftool "difftastic"] - cmd = difft "$MERGED" "$LOCAL" "abcdef1" "100644" "$REMOTE" "abcdef2" "100644" -[rebase] - autoStash = true - autoSquash = true - updateRefs = true -[fetch] - recurseSubmodules = on-demand - prune = true -[rerere] - enabled = true -[pull] - ff = only -[merge] - tool = conflictmarker - conflictstyle = zdiff3 -[merge "mergiraf"] - name = mergiraf - driver = mergiraf merge --git %O %A %B -s %S -x %X -y %Y -p %P -[mergetool] - keepBackup = false -[mergetool "conflictmarker"] - cmd = $EDITOR "$MERGED" -[commit] - gpgsign = true -[tag] - gpgSign = true -[status] - short = true - branch = true - showUntrackedFiles = all - showStash = true - submoduleSummary = true -[help] - autocorrect = prompt -[log] - abbrevCommit = true - date = short -[interactive] - singleKey = true - diffFilter = delta --color-only -[advice] - detachedHead = false - skippedCherryPicks = false -[column] - ui = auto -[format] - pretty = oneline -[delta] - navigate = true - features = gruvmax-fang zebra-dark - dark = true - side-by-side = true - line-numbers = true - relative-paths = true - hyperlinks = false -[absorb] - autoStageIfNothingStaged = true -[alias] - resign = "!git show -s --format=%B HEAD | sed \"/^Co-authored-by:/Id\" | git -c core.hooksPath=/dev/null commit --amend --reset-author --no-verify --cleanup=strip -S -F -" - ghost-rebase = "!sh -c 'b=$0; u=${1:-origin/main}; wt=$(mktemp -d -t rebwt.XXXXXX); git worktree add -f "$wt" "$b"; git -C "$wt" rebase "$u" || echo "Rebase incomplete"; git worktree remove --force "$wt"'" - a = add - ab=absorb --and-rebase - b = branch -vv - bd = branch -D - c = commit - ca = commit --amend --no-edit - cp = cherry-pick - d = diff - dft = -c diff.external=difft diff - dl = -c diff.external=difft log -p --ext-diff - ds = -c diff.external=difft show --ext-diff - dt = difftool - f = fetch - fa = fetch --all - ff = merge --ff-only - find-merge = "!sh -c 'commit=$0 && branch=${1:-HEAD} && (git rev-list $commit..$branch --ancestry-path | cat -n; git rev-list $commit..$branch --first-parent | cat -n) | sort -k2 -s | uniq -f1 -d | sort -n | tail -1 | cut -f2'" - fmt = clang-format -f - fp = push --force-with-lease - gone = !git for-each-ref --format '%(refname:short) %(upstream:track)' | awk '$2 == \"[gone]\" {print $1}' | xargs -r git branch -D - l = log - lg = log --graph --oneline - lm = log --pretty=medium - m = merge - mt = mergetool - p = push - r = rebase - rnh = "!f() { git -c core.hooksPath=/dev/null rebase \"$@\"; }; f" - re = remote -v - ri = rebase -i - rt = restore - s = status - st = status --no-short - staged = diff --cached - sub = submodule - subf = submodule foreach - sw = switch - swc = switch -c - swd = switch -d - unstage = reset HEAD -- - update=!git fetch && git merge --ff-only -[includeIf "gitdir:~/doxfiles/.git"] - path = config-personal -[includeIf "gitdir:~/dev/personal/"] - path = config-personal -[includeIf "gitdir:~/dev/canonical/"] - path = config-work -[sendemail] - smtpServer = 127.0.0.1 - smtpServerPort = 1016 - smtpEncryption = tls - smtpDomain = localhost - smtpSslCertPath = - confirm = auto - suppresscc = self - chainReplyTo = false - thread = true - annotate = true - 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" -[include] - ; Machine-local overrides. Git silently skips this if the file is absent. - path = ~/.config/git/config.local diff --git a/dot_config/git/config.tmpl b/dot_config/git/config.tmpl new file mode 100644 index 0000000..9987261 --- /dev/null +++ b/dot_config/git/config.tmpl @@ -0,0 +1,164 @@ +[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 + gpgSign = if-asked +[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] + tool = nvimdiff + algorithm = histogram + colorMoved = default + colorMovedWS = allow-indentation-change + mnemonicPrefix = true + relative = true + renameLimit = 10000 +[difftool] + prompt = false + trustExitCode = true +[pager] + difftool = true +[difftool "nvimdiff"] + cmd = $EDITOR -d $LOCAL $REMOTE +[difftool "difftastic"] + cmd = difft "$MERGED" "$LOCAL" "abcdef1" "100644" "$REMOTE" "abcdef2" "100644" +[rebase] + autoStash = true + autoSquash = true + updateRefs = true +[fetch] + recurseSubmodules = on-demand + prune = true +[rerere] + enabled = true +[pull] + ff = only +[merge] + tool = conflictmarker + conflictstyle = zdiff3 +[merge "mergiraf"] + name = mergiraf + driver = mergiraf merge --git %O %A %B -s %S -x %X -y %Y -p %P +[mergetool] + keepBackup = false +[mergetool "conflictmarker"] + cmd = $EDITOR "$MERGED" +[commit] + gpgsign = true +[tag] + gpgSign = true +[status] + short = true + branch = true + showUntrackedFiles = all + showStash = true + submoduleSummary = true +[help] + autocorrect = prompt +[log] + abbrevCommit = true + date = short +[interactive] + singleKey = true + diffFilter = delta --color-only +[advice] + detachedHead = false + skippedCherryPicks = false +[column] + ui = auto +[format] + pretty = oneline +[delta] + navigate = true + features = gruvmax-fang zebra-dark + dark = true + side-by-side = true + line-numbers = true + relative-paths = true + hyperlinks = false +[absorb] + autoStageIfNothingStaged = true +[alias] + resign = "!git show -s --format=%B HEAD | sed \"/^Co-authored-by:/Id\" | git -c core.hooksPath=/dev/null commit --amend --reset-author --no-verify --cleanup=strip -S -F -" + ghost-rebase = "!sh -c 'b=$0; u=${1:-origin/main}; wt=$(mktemp -d -t rebwt.XXXXXX); git worktree add -f "$wt" "$b"; git -C "$wt" rebase "$u" || echo "Rebase incomplete"; git worktree remove --force "$wt"'" + a = add + ab=absorb --and-rebase + b = branch -vv + bd = branch -D + c = commit + ca = commit --amend --no-edit + cp = cherry-pick + d = diff + dft = -c diff.external=difft diff + dl = -c diff.external=difft log -p --ext-diff + ds = -c diff.external=difft show --ext-diff + dt = difftool + f = fetch + fa = fetch --all + ff = merge --ff-only + find-merge = "!sh -c 'commit=$0 && branch=${1:-HEAD} && (git rev-list $commit..$branch --ancestry-path | cat -n; git rev-list $commit..$branch --first-parent | cat -n) | sort -k2 -s | uniq -f1 -d | sort -n | tail -1 | cut -f2'" + fmt = clang-format -f + fp = push --force-with-lease + gone = !git for-each-ref --format '%(refname:short) %(upstream:track)' | awk '$2 == \"[gone]\" {print $1}' | xargs -r git branch -D + l = log + lg = log --graph --oneline + lm = log --pretty=medium + m = merge + mt = mergetool + p = push + r = rebase + rnh = "!f() { git -c core.hooksPath=/dev/null rebase \"$@\"; }; f" + re = remote -v + ri = rebase -i + rt = restore + s = status + st = status --no-short + staged = diff --cached + sub = submodule + subf = submodule foreach + sw = switch + swc = switch -c + 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/"] + path = config-personal +[includeIf "gitdir:~/dev/canonical/"] + path = config-work +[sendemail] + smtpServer = 127.0.0.1 + smtpServerPort = 1016 + smtpEncryption = tls + smtpDomain = localhost + smtpSslCertPath = + confirm = auto + suppresscc = self + chainReplyTo = false + thread = true + annotate = true + 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 deleted file mode 100644 index ab2f7e2..0000000 --- a/dot_config/mimeapps.list +++ /dev/null @@ -1,117 +0,0 @@ -[Default Applications] -audio/x-vorbis+ogg=io.mpv.Mpv.desktop -audio/aac=io.mpv.Mpv.desktop -audio/x-aac=io.mpv.Mpv.desktop -audio/m4a=io.mpv.Mpv.desktop -audio/x-m4a=io.mpv.Mpv.desktop -audio/mp1=io.mpv.Mpv.desktop -audio/x-mp1=io.mpv.Mpv.desktop -audio/mp2=io.mpv.Mpv.desktop -audio/x-mp2=io.mpv.Mpv.desktop -audio/mp3=io.mpv.Mpv.desktop -audio/x-mp3=io.mpv.Mpv.desktop -audio/mpeg=io.mpv.Mpv.desktop -audio/x-mpeg=io.mpv.Mpv.desktop -audio/mpegurl=io.mpv.Mpv.desktop -audio/x-mpegurl=io.mpv.Mpv.desktop -audio/mpg=io.mpv.Mpv.desktop -audio/x-mpg=io.mpv.Mpv.desktop -audio/rn-mpeg=io.mpv.Mpv.desktop -audio/ogg=io.mpv.Mpv.desktop -audio/scpls=io.mpv.Mpv.desktop -audio/x-scpls=io.mpv.Mpv.desktop -audio/vnd.rn-realaudio=io.mpv.Mpv.desktop -audio/wav=io.mpv.Mpv.desktop -audio/x-pn-windows-pcm=io.mpv.Mpv.desktop -audio/x-realaudio=io.mpv.Mpv.desktop -audio/x-pn-realaudio=io.mpv.Mpv.desktop -audio/x-ms-wma=io.mpv.Mpv.desktop -audio/x-pls=io.mpv.Mpv.desktop -audio/x-wav=io.mpv.Mpv.desktop -audio/x-flac=io.mpv.Mpv.desktop -audio/x-shorten=io.mpv.Mpv.desktop -audio/x-ape=io.mpv.Mpv.desktop -audio/x-wavpack=io.mpv.Mpv.desktop -audio/x-tta=io.mpv.Mpv.desktop -audio/AMR=io.mpv.Mpv.desktop -audio/ac3=io.mpv.Mpv.desktop -audio/flac=io.mpv.Mpv.desktop -audio/mp4=io.mpv.Mpv.desktop -video/x-ogm+ogg=io.mpv.Mpv.desktop -video/mpeg=io.mpv.Mpv.desktop -video/x-mpeg=io.mpv.Mpv.desktop -video/x-mpeg2=io.mpv.Mpv.desktop -video/mp4=io.mpv.Mpv.desktop -video/msvideo=io.mpv.Mpv.desktop -video/x-msvideo=io.mpv.Mpv.desktop -video/ogg=io.mpv.Mpv.desktop -video/quicktime=io.mpv.Mpv.desktop -video/vnd.rn-realvideo=io.mpv.Mpv.desktop -video/x-ms-afs=io.mpv.Mpv.desktop -video/x-ms-asf=io.mpv.Mpv.desktop -video/x-ms-wmv=io.mpv.Mpv.desktop -video/x-ms-wmx=io.mpv.Mpv.desktop -video/x-ms-wvxvideo=io.mpv.Mpv.desktop -video/x-avi=io.mpv.Mpv.desktop -video/x-fli=io.mpv.Mpv.desktop -video/x-flv=io.mpv.Mpv.desktop -video/x-theora=io.mpv.Mpv.desktop -video/x-matroska=io.mpv.Mpv.desktop -video/webm=io.mpv.Mpv.desktop -video/mp2t=io.mpv.Mpv.desktop -image/x-nikon-nef=imv.desktop -image/jpeg=imv.desktop -image/png=imv.desktop -image/gif=io.mpv.Mpv.desktop -image/svg+xml=io.gitlab.librewolf-community.desktop -text/markdown=org.kde.okular.desktop -text/plain=nvim.desktop -text/x-python=nvim.desktop -text/x-chdr=nvim.desktop -text/x-tex=nvim.desktop -application/x-shellscript=nvim.desktop -application/x-bittorrent=transmission.desktop -application/pdf=org.pwmt.zathura.desktop -application/postscript=org.pwmt.zathura.desktop -application/rss+xml=rss.desktop -x-scheme-handler/magnet=transmission.desktop -x-scheme-handler/mailto=org.mozilla.thunderbird.desktop -application/msword-template=xdot.desktop -x-scheme-handler/http=io.gitlab.librewolf-community.desktop -x-scheme-handler/https=io.gitlab.librewolf-community.desktop -x-scheme-handler/chrome=io.gitlab.librewolf-community.desktop -text/html=io.gitlab.librewolf-community.desktop -application/x-extension-htm=io.gitlab.librewolf-community.desktop -application/x-extension-html=io.gitlab.librewolf-community.desktop -application/x-extension-shtml=io.gitlab.librewolf-community.desktop -application/xhtml+xml=io.gitlab.librewolf-community.desktop -application/x-extension-xhtml=io.gitlab.librewolf-community.desktop -application/x-extension-xht=io.gitlab.librewolf-community.desktop -message/rfc822=org.mozilla.thunderbird.desktop -x-scheme-handler/mid=org.mozilla.thunderbird.desktop -x-scheme-handler/webcal=org.mozilla.thunderbird.desktop -text/calendar=org.mozilla.thunderbird.desktop -application/x-extension-ics=org.mozilla.thunderbird.desktop -x-scheme-handler/webcals=org.mozilla.thunderbird.desktop - -[Added Associations] -application/pdf=org.pwmt.zathura.desktop; -application/postscript=org.pwmt.zathura.desktop; -x-scheme-handler/http=io.gitlab.librewolf-community.desktop; -x-scheme-handler/https=io.gitlab.librewolf-community.desktop; -x-scheme-handler/chrome=io.gitlab.librewolf-community.desktop; -text/html=io.gitlab.librewolf-community.desktop; -application/x-extension-htm=io.gitlab.librewolf-community.desktop; -application/x-extension-html=io.gitlab.librewolf-community.desktop; -application/x-extension-shtml=io.gitlab.librewolf-community.desktop; -application/xhtml+xml=io.gitlab.librewolf-community.desktop; -application/x-extension-xhtml=io.gitlab.librewolf-community.desktop; -application/x-extension-xht=io.gitlab.librewolf-community.desktop; -x-scheme-handler/mailto=org.mozilla.thunderbird.desktop; -x-scheme-handler/mid=org.mozilla.thunderbird.desktop; -x-scheme-handler/webcal=org.mozilla.thunderbird.desktop; -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; diff --git a/dot_config/mimeapps.list.tmpl b/dot_config/mimeapps.list.tmpl new file mode 100644 index 0000000..f5b6d20 --- /dev/null +++ b/dot_config/mimeapps.list.tmpl @@ -0,0 +1,131 @@ +{{ 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 +audio/x-aac=io.mpv.Mpv.desktop +audio/m4a=io.mpv.Mpv.desktop +audio/x-m4a=io.mpv.Mpv.desktop +audio/mp1=io.mpv.Mpv.desktop +audio/x-mp1=io.mpv.Mpv.desktop +audio/mp2=io.mpv.Mpv.desktop +audio/x-mp2=io.mpv.Mpv.desktop +audio/mp3=io.mpv.Mpv.desktop +audio/x-mp3=io.mpv.Mpv.desktop +audio/mpeg=io.mpv.Mpv.desktop +audio/x-mpeg=io.mpv.Mpv.desktop +audio/mpegurl=io.mpv.Mpv.desktop +audio/x-mpegurl=io.mpv.Mpv.desktop +audio/mpg=io.mpv.Mpv.desktop +audio/x-mpg=io.mpv.Mpv.desktop +audio/rn-mpeg=io.mpv.Mpv.desktop +audio/ogg=io.mpv.Mpv.desktop +audio/scpls=io.mpv.Mpv.desktop +audio/x-scpls=io.mpv.Mpv.desktop +audio/vnd.rn-realaudio=io.mpv.Mpv.desktop +audio/wav=io.mpv.Mpv.desktop +audio/x-pn-windows-pcm=io.mpv.Mpv.desktop +audio/x-realaudio=io.mpv.Mpv.desktop +audio/x-pn-realaudio=io.mpv.Mpv.desktop +audio/x-ms-wma=io.mpv.Mpv.desktop +audio/x-pls=io.mpv.Mpv.desktop +audio/x-wav=io.mpv.Mpv.desktop +audio/x-flac=io.mpv.Mpv.desktop +audio/x-shorten=io.mpv.Mpv.desktop +audio/x-ape=io.mpv.Mpv.desktop +audio/x-wavpack=io.mpv.Mpv.desktop +audio/x-tta=io.mpv.Mpv.desktop +audio/AMR=io.mpv.Mpv.desktop +audio/ac3=io.mpv.Mpv.desktop +audio/flac=io.mpv.Mpv.desktop +audio/mp4=io.mpv.Mpv.desktop +video/x-ogm+ogg=io.mpv.Mpv.desktop +video/mpeg=io.mpv.Mpv.desktop +video/x-mpeg=io.mpv.Mpv.desktop +video/x-mpeg2=io.mpv.Mpv.desktop +video/mp4=io.mpv.Mpv.desktop +video/msvideo=io.mpv.Mpv.desktop +video/x-msvideo=io.mpv.Mpv.desktop +video/ogg=io.mpv.Mpv.desktop +video/quicktime=io.mpv.Mpv.desktop +video/vnd.rn-realvideo=io.mpv.Mpv.desktop +video/x-ms-afs=io.mpv.Mpv.desktop +video/x-ms-asf=io.mpv.Mpv.desktop +video/x-ms-wmv=io.mpv.Mpv.desktop +video/x-ms-wmx=io.mpv.Mpv.desktop +video/x-ms-wvxvideo=io.mpv.Mpv.desktop +video/x-avi=io.mpv.Mpv.desktop +video/x-fli=io.mpv.Mpv.desktop +video/x-flv=io.mpv.Mpv.desktop +video/x-theora=io.mpv.Mpv.desktop +video/x-matroska=io.mpv.Mpv.desktop +video/webm=io.mpv.Mpv.desktop +video/mp2t=io.mpv.Mpv.desktop +image/x-nikon-nef=imv.desktop +image/jpeg=imv.desktop +image/png=imv.desktop +image/gif=io.mpv.Mpv.desktop +image/svg+xml=io.gitlab.librewolf-community.desktop +text/markdown=org.kde.okular.desktop +text/plain=nvim.desktop +text/x-python=nvim.desktop +text/x-chdr=nvim.desktop +text/x-tex=nvim.desktop +application/x-shellscript=nvim.desktop +application/x-bittorrent=transmission.desktop +application/pdf=org.pwmt.zathura.desktop +application/postscript=org.pwmt.zathura.desktop +application/rss+xml=rss.desktop +x-scheme-handler/magnet=transmission.desktop +x-scheme-handler/mailto=org.mozilla.thunderbird.desktop +application/msword-template=xdot.desktop +x-scheme-handler/http=io.gitlab.librewolf-community.desktop +x-scheme-handler/https=io.gitlab.librewolf-community.desktop +x-scheme-handler/chrome=io.gitlab.librewolf-community.desktop +text/html=io.gitlab.librewolf-community.desktop +application/x-extension-htm=io.gitlab.librewolf-community.desktop +application/x-extension-html=io.gitlab.librewolf-community.desktop +application/x-extension-shtml=io.gitlab.librewolf-community.desktop +application/xhtml+xml=io.gitlab.librewolf-community.desktop +application/x-extension-xhtml=io.gitlab.librewolf-community.desktop +application/x-extension-xht=io.gitlab.librewolf-community.desktop +message/rfc822=org.mozilla.thunderbird.desktop +x-scheme-handler/mid=org.mozilla.thunderbird.desktop +x-scheme-handler/webcal=org.mozilla.thunderbird.desktop +text/calendar=org.mozilla.thunderbird.desktop +application/x-extension-ics=org.mozilla.thunderbird.desktop +x-scheme-handler/webcals=org.mozilla.thunderbird.desktop + +[Added Associations] +application/pdf=org.pwmt.zathura.desktop; +application/postscript=org.pwmt.zathura.desktop; +x-scheme-handler/http=io.gitlab.librewolf-community.desktop; +x-scheme-handler/https=io.gitlab.librewolf-community.desktop; +x-scheme-handler/chrome=io.gitlab.librewolf-community.desktop; +text/html=io.gitlab.librewolf-community.desktop; +application/x-extension-htm=io.gitlab.librewolf-community.desktop; +application/x-extension-html=io.gitlab.librewolf-community.desktop; +application/x-extension-shtml=io.gitlab.librewolf-community.desktop; +application/xhtml+xml=io.gitlab.librewolf-community.desktop; +application/x-extension-xhtml=io.gitlab.librewolf-community.desktop; +application/x-extension-xht=io.gitlab.librewolf-community.desktop; +x-scheme-handler/mailto=org.mozilla.thunderbird.desktop; +x-scheme-handler/mid=org.mozilla.thunderbird.desktop; +x-scheme-handler/webcal=org.mozilla.thunderbird.desktop; +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": ("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() 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 + } +} diff --git a/justfile b/justfile index 68a9b83..6d2c2e9 100644 --- a/justfile +++ b/justfile @@ -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 ` (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/ against live /etc/ (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/ 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/ 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/ 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 deleted file mode 100644 index 69ceb5a..0000000 --- a/private_dot_gnupg/gpg.conf +++ /dev/null @@ -1,11 +0,0 @@ -personal-digest-preferences SHA512 -cert-digest-algo SHA512 -default-preference-list SHA512 SHA384 SHA256 SHA224 AES256 AES192 AES CAST5 ZLIB BZIP2 ZIP Uncompressed -personal-cipher-preferences TWOFISH CAMELLIA256 AES 3DES - -keyserver-options auto-key-retrieve - -keyid-format 0xlong -with-fingerprint - -default-key B79DF5F37D7F9B0F390238D53298945F717C85F8 diff --git a/private_dot_gnupg/gpg.conf.tmpl b/private_dot_gnupg/gpg.conf.tmpl new file mode 100644 index 0000000..ed5ceb5 --- /dev/null +++ b/private_dot_gnupg/gpg.conf.tmpl @@ -0,0 +1,15 @@ +personal-digest-preferences SHA512 +cert-digest-algo SHA512 +default-preference-list SHA512 SHA384 SHA256 SHA224 AES256 AES192 AES CAST5 ZLIB BZIP2 ZIP Uncompressed +personal-cipher-preferences TWOFISH CAMELLIA256 AES 3DES + +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 deleted file mode 100644 index 8df3687..0000000 --- a/private_dot_ssh/config +++ /dev/null @@ -1,19 +0,0 @@ -# Ensure KnownHosts are unreadable if leaked -HashKnownHosts yes - -Compression no -ControlMaster auto -ControlPersist 10 -AddressFamily inet -ServerAliveInterval 120 -IdentitiesOnly yes - -Host git.sommerfeld.dev - Hostname mandibles.wg - -Include work.config - -Match host * exec "gpg-connect-agent UPDATESTARTUPTTY /bye" - -Match host *.wg,*.pulpo - IdentityFile ~/.ssh/nym.pub diff --git a/private_dot_ssh/config.tmpl b/private_dot_ssh/config.tmpl new file mode 100644 index 0000000..b6dce43 --- /dev/null +++ b/private_dot_ssh/config.tmpl @@ -0,0 +1,27 @@ +{{ 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 + +Compression no +ControlMaster auto +ControlPersist 10 +AddressFamily inet +ServerAliveInterval 120 +IdentitiesOnly yes + +Host git.sommerfeld.dev + Hostname mandibles.wg + +Include work.config + +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 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", ["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]) -- cgit v1.3.1