diff --git a/config/dot/quickshell/modules/settings/ApplicationsPage.qml b/config/dot/quickshell/modules/settings/ApplicationsPage.qml index 903dd9e..d7266a5 100644 --- a/config/dot/quickshell/modules/settings/ApplicationsPage.qml +++ b/config/dot/quickshell/modules/settings/ApplicationsPage.qml @@ -14,14 +14,21 @@ SettingsPage { property string expandedRole: "" property bool addingAutostart: false readonly property var applications: DesktopEntries.applications.values + // Each role governs a whole family of types, not one representative: setting + // "Images" writes PNG, JPEG, WebP and the rest together, so a file manager + // can never open one image in a viewer and its neighbour in an editor. + // The detail line names the family the way someone would describe it. readonly property var roles: [ { key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] }, { key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] }, { key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] }, { key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] }, - { key: "music", label: "Music", detail: "MP3 audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] }, - { key: "images", label: "Images", detail: "PNG images", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] }, - { key: "video", label: "Video", detail: "MP4 video", categorySets: [["video"]], terms: ["video player", "movie player"] } + { key: "images", label: "Images", detail: "PNG, JPEG, GIF, WebP, SVG and other pictures", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] }, + { key: "music", label: "Music", detail: "MP3, FLAC, Ogg and other audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] }, + { key: "video", label: "Video", detail: "MP4, MKV, WebM and other video", categorySets: [["video"]], terms: ["video player", "movie player"] }, + { key: "documents", label: "Documents", detail: "PDF and EPUB documents", categorySets: [["office", "viewer"]], terms: ["document viewer", "pdf viewer", "ebook", "e-book"] }, + { key: "text", label: "Text", detail: "Plain text, Markdown, and source files", categorySets: [["texteditor"]], terms: ["text editor", "code editor"] }, + { key: "archives", label: "Archives", detail: "Zip, tar, and other archives", categorySets: [["archiving"], ["filemanager"]], terms: ["archive manager", "file roller", "file manager"] } ] function desktopId(entry: var): string { diff --git a/config/dot/quickshell/scripts/panama-default-apps b/config/dot/quickshell/scripts/panama-default-apps index c060a20..1c0dc7d 100755 --- a/config/dot/quickshell/scripts/panama-default-apps +++ b/config/dot/quickshell/scripts/panama-default-apps @@ -14,14 +14,46 @@ import sys import tempfile +# A role owns a FAMILY of types, not one representative. +# +# Each role used to carry a single mime type, so setting "images" changed +# image/png and left image/jpeg wherever it happened to land. That is exactly +# how this machine ended up opening PNGs in a pixel-art editor, MP3s in a video +# transcoder and PDFs in GIMP: nobody chose any of it, the applications +# registered themselves and the roles only ever governed one type each. +# +# The FIRST entry in each list is the one queried when reporting the current +# handler; all of them are written when the role is set, so a family cannot +# drift apart again. ROLE_TARGETS = { - "browser": ("settings", "default-web-browser"), - "mail": ("mime", "x-scheme-handler/mailto"), - "files": ("mime", "inode/directory"), - "terminal": ("mime", "x-scheme-handler/terminal"), - "music": ("mime", "audio/mpeg"), - "images": ("mime", "image/png"), - "video": ("mime", "video/mp4"), + "browser": ("settings", ["default-web-browser"]), + "mail": ("mime", ["x-scheme-handler/mailto"]), + "files": ("mime", ["inode/directory"]), + "terminal": ("mime", ["x-scheme-handler/terminal"]), + "music": ("mime", [ + "audio/mpeg", "audio/flac", "audio/x-vorbis+ogg", "audio/ogg", + "audio/x-wav", "audio/mp4", "audio/aac", "audio/opus", + ]), + "images": ("mime", [ + "image/png", "image/jpeg", "image/gif", "image/webp", + "image/tiff", "image/bmp", "image/svg+xml", "image/avif", + ]), + "video": ("mime", [ + "video/mp4", "video/x-matroska", "video/webm", "video/quicktime", + "video/x-msvideo", "video/mpeg", + ]), + "documents": ("mime", [ + "application/pdf", "application/epub+zip", + ]), + "text": ("mime", [ + "text/plain", "text/markdown", "text/x-python", "text/x-csrc", + "text/x-chdr", "text/x-c++src", "text/x-shellscript", + "application/json", "application/x-yaml", "text/xml", + ]), + "archives": ("mime", [ + "application/zip", "application/x-tar", "application/gzip", + "application/x-7z-compressed", "application/vnd.rar", + ]), } DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$") EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)") @@ -70,7 +102,9 @@ def run(command: list[str]) -> str: def query_handlers() -> dict[str, str]: handlers: dict[str, str] = {} - for role, (kind, target) in ROLE_TARGETS.items(): + for role, (kind, targets) in ROLE_TARGETS.items(): + # The first type represents the family when reporting. + target = targets[0] command = ( ["xdg-settings", "get", target] if kind == "settings" @@ -179,13 +213,85 @@ def set_default(role: str, desktop_id: str) -> None: if target is None: raise BoundaryError("That default application role is not supported.") require_desktop_id(desktop_id, discovered=discovered_desktop_ids()) - kind, setting = target - command = ( - ["xdg-settings", "set", setting, desktop_id] - if kind == "settings" - else ["xdg-mime", "default", desktop_id, setting] - ) - run(command) + kind, settings = target + if kind == "settings": + run(["xdg-settings", "set", settings[0], desktop_id]) + return + # Every type in the family, so a role cannot be half-applied. xdg-mime + # accepts several types in one call, but they are written individually so a + # type this system does not know about cannot fail the whole role. + for setting in settings: + run(["xdg-mime", "default", desktop_id, setting]) + + +# What this desktop opens a file with, when nobody has said otherwise. +# +# Applications register themselves for every type they can technically read, so +# an unattended machine decides these by installation order: a pixel-art editor +# claims PNG, a video transcoder claims MP3, an image editor claims PDF. None of +# that is a choice anyone made, and it is only discovered by double-clicking. +# +# Each role lists candidates best-first; the first one installed wins. A role +# with no candidate installed is left alone rather than forced. +PREFERRED_HANDLERS = { + "images": ["org.gnome.Loupe.desktop", "org.gnome.eog.desktop"], + "music": ["org.gnome.Decibels.desktop", "io.bassi.Amberol.desktop", "io.mpv.Mpv.desktop"], + "video": ["io.mpv.Mpv.desktop", "mpv.desktop", "org.gnome.Totem.desktop"], + "documents": ["org.gnome.Papers.desktop", "org.gnome.Evince.desktop"], + "text": ["panama-nvim.desktop"], + "archives": ["org.gnome.Nautilus.desktop", "org.gnome.FileRoller.desktop"], +} + + +def user_mimeapps() -> Path: + config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return config_home / "mimeapps.list" + + +def chosen_types() -> set[str]: + """Types the person using this machine has already assigned by hand. + + A type listed under [Default Applications] in the user's own mimeapps.list + got there because someone picked "Open With" and made it stick, or because + the settings page wrote it. Seeding must never overrule that. + """ + path = user_mimeapps() + if not path.is_file(): + return set() + chosen: set[str] = set() + section = "" + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError): + return set() + for line in lines: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + section = stripped[1:-1] + continue + if section != "Default Applications" or "=" not in line or stripped.startswith("#"): + continue + chosen.add(line.split("=", 1)[0].strip()) + return chosen + + +def seed() -> None: + """Apply Panama's curated defaults to roles nobody has chosen for.""" + discovered = discovered_desktop_ids() + already = chosen_types() + for role, candidates in PREFERRED_HANDLERS.items(): + kind, targets = ROLE_TARGETS[role] + if kind != "mime": + continue + if any(target in already for target in targets): + print(f"{role}: keeping the existing choice") + continue + preferred = next((entry for entry in candidates if entry in discovered), None) + if preferred is None: + print(f"{role}: no preferred application installed, leaving it alone") + continue + set_default(role, preferred) + print(f"{role}: {preferred}") def with_hidden(original: str, *, hidden: bool) -> str: @@ -294,6 +400,8 @@ def main(arguments: list[str]) -> int: try: if arguments == ["snapshot"]: print(json.dumps(snapshot(), separators=(",", ":"))) + elif arguments == ["seed"]: + seed() elif len(arguments) == 3 and arguments[0] == "set-default": set_default(arguments[1], arguments[2]) elif len(arguments) == 3 and arguments[0] == "set-autostart": @@ -302,7 +410,7 @@ def main(arguments: list[str]) -> int: add_autostart(arguments[1]) else: raise BoundaryError( - "Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | " + "Usage: panama-default-apps snapshot | seed | set-default ROLE DESKTOP_ID | " "set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID" ) except BoundaryError as error: diff --git a/config/local/share/applications/panama-nvim.desktop b/config/local/share/applications/panama-nvim.desktop new file mode 100644 index 0000000..150d23d --- /dev/null +++ b/config/local/share/applications/panama-nvim.desktop @@ -0,0 +1,17 @@ +[Desktop Entry] +Type=Application +Name=Neovim +GenericName=Text Editor +Comment=Edit text in a Panama terminal window +# The stock nvim.desktop sets Terminal=true, which hands the launch to whatever +# the system considers the default terminal -- not necessarily the one this +# desktop ships and themes. Naming kitty explicitly means a text file opened +# from the file manager lands in the same terminal, with the same font and the +# same colour scheme, as one opened from the dock. +Exec=kitty --class panama-editor -e nvim %F +Icon=nvim +Terminal=false +StartupNotify=false +Categories=Utility;TextEditor; +MimeType=text/plain;text/markdown;text/english;text/x-makefile;text/x-c++hdr;text/x-c++src;text/x-chdr;text/x-csrc;text/x-java;text/x-moc;text/x-pascal;text/x-tcl;text/x-tex;application/x-shellscript;text/x-c;text/x-c++;text/x-python;application/json;text/xml; +Keywords=vim;neovim;editor;text;code; diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles index f8a02ae..70ef5c3 100755 --- a/setup/scripts/link-dotfiles +++ b/setup/scripts/link-dotfiles @@ -259,3 +259,18 @@ for desktop_file in "$PANAMA_APPLICATION_DIR"/*.desktop; do ln -s "$desktop_file" "$desktop_target" log "Linked $desktop_name → $desktop_target" done + +# Curate what files open with, now that Panama's own entries are linked. +# +# Left alone, a machine decides this by installation order -- whichever +# application registered for image/png last wins it -- which is how a pixel-art +# editor ends up owning screenshots and a video transcoder ends up owning MP3s. +# Seeding only fills roles nobody has chosen for; an existing "Open With" choice +# is always kept. +DEFAULT_APPS_HELPER="$PANAMA_PATH/config/dot/quickshell/scripts/panama-default-apps" +if [[ -x "$DEFAULT_APPS_HELPER" ]] && command -v xdg-mime >/dev/null 2>&1; then + update-desktop-database "$USER_APPLICATION_DIR" >/dev/null 2>&1 || true + while IFS= read -r seeded_line; do + log "Default applications: $seeded_line" + done < <("$DEFAULT_APPS_HELPER" seed 2>&1 || true) +fi diff --git a/tests/quickshell/default-apps-contract.sh b/tests/quickshell/default-apps-contract.sh index 0657b42..b602df9 100755 --- a/tests/quickshell/default-apps-contract.sh +++ b/tests/quickshell/default-apps-contract.sh @@ -65,6 +65,9 @@ write_application org.gnome.Ptyxis.desktop Ptyxis Terminal 'System;TerminalEmula write_application org.gnome.Rhythmbox3.desktop Rhythmbox 'Music Player' 'AudioVideo;Audio;Player;' write_application org.gnome.Loupe.desktop Loupe 'Image Viewer' 'Graphics;Viewer;' write_application org.gnome.Totem.desktop Videos 'Video Player' 'AudioVideo;Video;Player;' +write_application org.gnome.Papers.desktop Papers 'Document Viewer' 'Office;Viewer;' +write_application panama-nvim.desktop Neovim 'Text Editor' 'Utility;TextEditor;' +write_application org.gnome.FileRoller.desktop 'Archive Manager' 'Archive Manager' 'Utility;Archiving;' cat >"$config_home/autostart/nextcloud.desktop" <<'EOF' [Desktop Entry] @@ -108,6 +111,9 @@ if [[ "$1" == "query" && "$2" == "default" ]]; then audio/mpeg) printf '%s\n' 'org.gnome.Rhythmbox3.desktop' ;; image/png) printf '%s\n' 'org.gnome.Loupe.desktop' ;; video/mp4) printf '%s\n' 'org.gnome.Totem.desktop' ;; + application/pdf) printf '%s\n' 'org.gnome.Papers.desktop' ;; + text/plain) printf '%s\n' 'panama-nvim.desktop' ;; + application/zip) printf '%s\n' 'org.gnome.FileRoller.desktop' ;; *) exit 91 ;; esac exit 0 @@ -125,7 +131,7 @@ export PATH="$fake_bin:$PATH" snapshot="$($helper snapshot)" || fail 'snapshot command failed' [[ "$(rg --count '^get$' "$call_log")" == "1" ]] \ || fail 'browser handler was queried more than once' -[[ "$(rg --count '^query$' "$call_log")" == "6" ]] \ +[[ "$(rg --count '^query$' "$call_log")" == "9" ]] \ || fail 'MIME handlers were queried more than once' jq -e ' .handlers == { @@ -135,7 +141,10 @@ jq -e ' terminal: "org.gnome.Ptyxis.desktop", music: "org.gnome.Rhythmbox3.desktop", images: "org.gnome.Loupe.desktop", - video: "org.gnome.Totem.desktop" + video: "org.gnome.Totem.desktop", + documents: "org.gnome.Papers.desktop", + text: "panama-nvim.desktop", + archives: "org.gnome.FileRoller.desktop" } and .autostartEntries == [{id: "nextcloud.desktop", name: "Nextcloud", enabled: false}] and (.luaAutostartEntries | length == 2) and @@ -163,28 +172,49 @@ assert_call() { $helper set-default browser org.mozilla.firefox.desktop assert_call $'set\ndefault-web-browser\norg.mozilla.firefox.desktop' -roles=(mail files terminal music images video) -desktop_ids=( - org.gnome.Geary.desktop - org.gnome.Nautilus.desktop - org.gnome.Ptyxis.desktop - org.gnome.Rhythmbox3.desktop - org.gnome.Loupe.desktop - org.gnome.Totem.desktop -) -mime_types=( - x-scheme-handler/mailto - inode/directory - x-scheme-handler/terminal - audio/mpeg - image/png - video/mp4 -) -for index in "${!roles[@]}"; do +# Setting a role must write EVERY type in its family, in order. +# +# The families are spelled out here rather than read from the helper: a test +# that derives its expectation from the code it is testing would have passed +# just as happily when "images" governed image/png alone. +assert_family() { + local role="$1" desktop_id="$2" + shift 2 + local expected="" + local mime + for mime in "$@"; do + expected+=$'default\n'"$desktop_id"$'\n'"$mime"$'\n' + done : >"$call_log" - $helper set-default "${roles[$index]}" "${desktop_ids[$index]}" - assert_call $'default\n'"${desktop_ids[$index]}"$'\n'"${mime_types[$index]}" -done + $helper set-default "$role" "$desktop_id" + local actual + actual="$(cat "$call_log")" + [[ "$actual" == "${expected%$'\n'}" ]] || { + printf 'expected argv:\n%s\nactual argv:\n%s\n' "${expected%$'\n'}" "$actual" >&2 + fail "the \"$role\" role was applied to the wrong set of types" + } +} + +assert_family mail org.gnome.Geary.desktop x-scheme-handler/mailto +assert_family files org.gnome.Nautilus.desktop inode/directory +assert_family terminal org.gnome.Ptyxis.desktop x-scheme-handler/terminal +assert_family music org.gnome.Rhythmbox3.desktop \ + audio/mpeg audio/flac audio/x-vorbis+ogg audio/ogg \ + audio/x-wav audio/mp4 audio/aac audio/opus +assert_family images org.gnome.Loupe.desktop \ + image/png image/jpeg image/gif image/webp \ + image/tiff image/bmp image/svg+xml image/avif +assert_family video org.gnome.Totem.desktop \ + video/mp4 video/x-matroska video/webm video/quicktime \ + video/x-msvideo video/mpeg +assert_family documents org.gnome.Papers.desktop \ + application/pdf application/epub+zip +assert_family text panama-nvim.desktop \ + text/plain text/markdown text/x-python text/x-csrc text/x-chdr \ + text/x-c++src text/x-shellscript application/json application/x-yaml text/xml +assert_family archives org.gnome.FileRoller.desktop \ + application/zip application/x-tar application/gzip \ + application/x-7z-compressed application/vnd.rar : >"$call_log" if $helper set-default unknown org.mozilla.firefox.desktop >/dev/null 2>&1; then @@ -251,4 +281,36 @@ if $helper add-autostart ../escape.desktop >/dev/null 2>&1; then fail 'an unsafe desktop id was accepted for autostart' fi +# Seeding fills roles nobody has chosen for, and never overrules a choice. +# +# The distinction matters because seeding runs on every setup: someone who has +# deliberately pointed PNGs at an editor must not have that undone the next time +# they re-link their dotfiles. +: >"$call_log" +seed_output="$($helper seed)" || fail 'seed command failed' +rg --quiet '^images: org\.gnome\.Loupe\.desktop$' <<<"$seed_output" \ + || fail 'seeding did not curate an unclaimed role' +rg --quiet '^documents: org\.gnome\.Papers\.desktop$' <<<"$seed_output" \ + || fail 'seeding did not curate documents' +rg --quiet '^music: no preferred application installed' <<<"$seed_output" \ + || fail 'seeding must leave a role alone when none of its candidates is installed' +rg --quiet '^default$' "$call_log" \ + || fail 'seeding never reached xdg-mime' + +# A recorded choice wins, and only that role is skipped. +cat >"$config_home/mimeapps.list" <<'MIMEAPPS' +[Default Applications] +image/png=org.gnome.Totem.desktop +MIMEAPPS +: >"$call_log" +seed_output="$($helper seed)" || fail 'seed command failed on a machine with recorded choices' +rg --quiet '^images: keeping the existing choice$' <<<"$seed_output" \ + || fail 'seeding overruled a choice the user had already made' +rg --quiet '^documents: org\.gnome\.Papers\.desktop$' <<<"$seed_output" \ + || fail 'one recorded choice suppressed an unrelated role' +rg --quiet 'image/png' "$call_log" \ + && fail 'seeding rewrote a type the user had already chosen for' + +rm -f "$config_home/mimeapps.list" + printf 'default apps contract: PASS\n' diff --git a/tests/quickshell/default-apps-family-contract.sh b/tests/quickshell/default-apps-family-contract.sh new file mode 100755 index 0000000..0ac657c --- /dev/null +++ b/tests/quickshell/default-apps-family-contract.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash + +# A default-application role owns a FAMILY of types, and every type in it must +# agree. +# +# Each role used to map to a single representative type, so setting "images" +# changed image/png and left image/jpeg wherever it happened to land. That is +# how this desktop ended up opening PNGs in a pixel-art editor, MP3s in a video +# transcoder and PDFs in an image editor -- nobody chose any of it, the +# applications registered themselves and the roles governed one type each. +# +# The failure is invisible until someone double-clicks a file, which is the +# worst possible moment to discover it. +# +# Read-only: this inspects the schema of the roles and the state of the machine. +# It never changes a default, because doing so on the daily driver would be +# rude and the point is to detect drift, not create it. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-default-apps" + +fail() { + printf 'default apps family contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -x "$helper" ]] || fail 'panama-default-apps is missing or not executable' + +# The role table, read from the helper itself so the two cannot disagree. +families="$(python3 - "$helper" <<'PY' +import ast, re, sys +source = open(sys.argv[1]).read() +match = re.search(r"ROLE_TARGETS = (\{.*?\n\})", source, re.S) +if not match: + raise SystemExit("ROLE_TARGETS not found") +table = ast.literal_eval(match.group(1)) +for role, (kind, targets) in table.items(): + if kind != "mime": + continue + print(role + "\t" + ",".join(targets)) +PY +)" || fail 'could not read ROLE_TARGETS from the helper' + +[[ -n "$families" ]] || fail 'no mime-backed roles found' + +command -v xdg-mime >/dev/null 2>&1 || { printf 'default apps family contract: SKIP (no xdg-mime)\n'; exit 0; } + +checked=0 +while IFS=$'\t' read -r role types; do + [[ -n "$role" ]] || continue + + first="" + disagreeing="" + for mime in ${types//,/ }; do + handler="$(xdg-mime query default "$mime" 2>/dev/null)" + # A type nothing claims is not drift; it is simply unclaimed, and + # forcing a handler for every exotic type is not this role's job. + [[ -n "$handler" ]] || continue + if [[ -z "$first" ]]; then + first="$handler" + continue + fi + [[ "$handler" == "$first" ]] || disagreeing+="$mime->$handler " + done + + [[ -z "$disagreeing" ]] \ + || fail "the \"$role\" role is split: its first type opens with $first but $disagreeing-- setting the role must write every type in the family" + + checked=$((checked + 1)) +done <<<"$families" + +# Every role the helper can set must be reachable from the settings page, and +# every row on the page must name a role the helper knows. A role that exists +# only in the helper is unreachable from the UI; a row naming a role the helper +# does not have does nothing when someone taps it. +page="$repo_dir/config/dot/quickshell/modules/settings/ApplicationsPage.qml" +[[ -r "$page" ]] || fail 'ApplicationsPage.qml is missing' + +helper_roles="$(python3 - "$helper" <<'ROLES' +import ast, re, sys +source = open(sys.argv[1]).read() +table = ast.literal_eval(re.search(r"ROLE_TARGETS = (\{.*?\n\})", source, re.S).group(1)) +print("\n".join(sorted(table))) +ROLES +)" +page_roles="$(grep -o 'key: "[a-z]*"' "$page" | sed 's/key: "//; s/"//' | sort -u)" + +missing="$(comm -23 <(printf '%s\n' "$helper_roles") <(printf '%s\n' "$page_roles") | tr '\n' ' ')" +extra="$(comm -13 <(printf '%s\n' "$helper_roles") <(printf '%s\n' "$page_roles") | tr '\n' ' ')" +[[ -z "${missing// }" ]] || fail "the helper can set these roles but the settings page never offers them: $missing" +[[ -z "${extra// }" ]] || fail "the settings page offers roles the helper cannot set: $extra" + +# The editor Panama ships must launch in Panama's terminal. The stock +# nvim.desktop sets Terminal=true, which hands the launch to whatever the +# system considers default -- not necessarily the terminal this desktop themes. +entry="$repo_dir/config/local/share/applications/panama-nvim.desktop" +[[ -r "$entry" ]] || fail 'panama-nvim.desktop is missing' +grep -q '^Exec=kitty ' "$entry" || fail 'the shipped editor entry does not launch kitty explicitly' +grep -q '^Terminal=false' "$entry" \ + || fail 'the editor entry sets Terminal=true, which defers to the system terminal rather than kitty' + +printf 'default apps family contract: PASS (%d role families consistent)\n' "$checked"