#!/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" # Every application a role is curated to must be installed by Panama's own # package lists. # # Seeding is deliberately conservative: a role whose preferred applications are # all missing is left alone rather than forced. That is the right behavior and # it is also silent -- so a curated handler nobody installs presents as the # machine quietly going back to deciding defaults by installation order, which # is the whole problem this was built to fix. # # The package name for each handler is spelled out here because a desktop id # does not carry one; rpm and flatpak name the same application differently and # neither name is derivable from the other. declare -A HANDLER_PACKAGE=( [org.gnome.Loupe.desktop]=loupe [org.gnome.Papers.desktop]=papers [org.gnome.Decibels.desktop]=decibels [org.gnome.Nautilus.desktop]=nautilus [io.mpv.Mpv.desktop]=io.mpv.Mpv ) declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$' | sort -u)" [[ -n "$declared" ]] || fail 'no package lists found' preferred="$(python3 - "$helper" <<'PREFERRED' import ast, re, sys source = open(sys.argv[1]).read() table = ast.literal_eval(re.search(r"PREFERRED_HANDLERS = (\{.*?\n\})", source, re.S).group(1)) for role, candidates in table.items(): # Only the first choice has to be installable: the rest are fallbacks for # machines that happen to have something else, and demanding all of them be # declared would mean installing three image viewers. print(role + "\t" + candidates[0]) PREFERRED )" || fail 'could not read PREFERRED_HANDLERS from the helper' while IFS=$'\t' read -r role handler; do [[ -n "$role" ]] || continue # Entries this repository ships itself are installed by link-dotfiles, not # by a package manager. [[ -r "$repo_dir/config/local/share/applications/$handler" ]] && continue package="${HANDLER_PACKAGE[$handler]:-}" [[ -n "$package" ]] \ || fail "the \"$role\" role prefers $handler, which this contract has no package name for -- add it to HANDLER_PACKAGE" grep -qx "$package" <<<"$declared" \ || fail "the \"$role\" role prefers $handler but nothing declares \"$package\", so a fresh machine seeds nothing for it" done <<<"$preferred" # 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"