#!/usr/bin/env bash set -euo pipefail fail() { printf 'default apps contract: %s\n' "$1" >&2 exit 1 } project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$project_root/config/dot/quickshell/scripts/panama-default-apps" service="$project_root/config/dot/quickshell/services/DefaultApps.qml" test_root="$(mktemp -d /tmp/panama-default-apps.XXXXXX)" trap 'rm -rf "$test_root"' EXIT [[ -x "$helper" ]] || fail 'helper is missing or not executable' [[ -f "$service" ]] || fail 'DefaultApps service is missing' assert_service_contains() { rg -F --quiet "$1" "$service" || fail "service is missing: $1" } assert_service_contains 'pragma Singleton' assert_service_contains 'property var handlers' assert_service_contains 'property var autostartEntries' assert_service_contains 'property var luaAutostartEntries' assert_service_contains 'readonly property bool busy' assert_service_contains 'property string lastError' assert_service_contains 'function refresh(): void' assert_service_contains 'function setDefault(role: string, desktopId: string): void' assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void' assert_service_contains 'function addAutostart(desktopId: string): void' assert_service_contains 'DesktopEntries.applications.values' if rg --quiet 'command\s*:\s*"' "$service"; then fail 'Process command must be an argument array' fi config_home="$test_root/config" data_home="$test_root/data" data_dirs="$test_root/data-dirs" fake_bin="$test_root/bin" call_log="$test_root/calls" lua_fixture="$test_root/autostart.lua" mkdir -p "$config_home/autostart" "$data_home/applications" "$data_dirs" "$fake_bin" write_application() { local desktop_id="$1" local name="$2" local generic_name="$3" local categories="$4" cat >"$data_home/applications/$desktop_id" <"$config_home/autostart/nextcloud.desktop" <<'EOF' [Desktop Entry] Type=Application Name=Nextcloud Exec=nextcloud --background Hidden=true EOF cat >"$lua_fixture" <<'EOF' hl.on("hyprland.start", function() hl.exec_cmd("quickshell --daemonize") hl.exec_cmd("nextcloud --background") end) hl.on("hyprland.shutdown", function() hl.exec_cmd("systemctl --user stop hyprland-session.target") end) EOF cat >"$fake_bin/xdg-settings" <<'EOF' #!/usr/bin/env bash set -euo pipefail printf '%s\n' "$@" >>"$PANAMA_CALL_LOG" if [[ "$1" == "get" && "$2" == "default-web-browser" ]]; then printf '%s\n' 'org.mozilla.firefox.desktop' exit 0 fi EOF chmod +x "$fake_bin/xdg-settings" cat >"$fake_bin/xdg-mime" <<'EOF' #!/usr/bin/env bash set -euo pipefail printf '%s\n' "$@" >>"$PANAMA_CALL_LOG" if [[ "$1" == "query" && "$2" == "default" ]]; then case "$3" in x-scheme-handler/mailto) printf '%s\n' 'org.gnome.Geary.desktop' ;; inode/directory) printf '%s\n' 'org.gnome.Nautilus.desktop' ;; x-scheme-handler/terminal) printf '%s\n' 'org.gnome.Ptyxis.desktop' ;; 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 fi EOF chmod +x "$fake_bin/xdg-mime" export XDG_CONFIG_HOME="$config_home" export XDG_DATA_HOME="$data_home" export XDG_DATA_DIRS="$data_dirs" export PANAMA_HYPR_AUTOSTART="$lua_fixture" export PANAMA_CALL_LOG="$call_log" 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")" == "9" ]] \ || fail 'MIME handlers were queried more than once' jq -e ' .handlers == { browser: "org.mozilla.firefox.desktop", mail: "org.gnome.Geary.desktop", files: "org.gnome.Nautilus.desktop", terminal: "org.gnome.Ptyxis.desktop", music: "org.gnome.Rhythmbox3.desktop", images: "org.gnome.Loupe.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 ([.luaAutostartEntries[] | .enabled == true and .readOnly == true and .source == "config/dot/hypr/autostart.lua" and (.id | startswith("hyprland:")) and (.name | length > 0) and (.command | length > 0) ] | all) and ([.luaAutostartEntries[].command] | index("systemctl --user stop hyprland-session.target") == null) ' <<<"$snapshot" >/dev/null || fail 'snapshot shape, handlers, or autostart parsing is incorrect' assert_call() { local expected="$1" local actual actual="$(cat "$call_log")" [[ "$actual" == "$expected" ]] || { printf 'expected argv:\n%s\nactual argv:\n%s\n' "$expected" "$actual" >&2 fail 'setter did not pass separate arguments' } } : >"$call_log" $helper set-default browser org.mozilla.firefox.desktop assert_call $'set\ndefault-web-browser\norg.mozilla.firefox.desktop' # 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 "$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 fail 'unknown role was accepted' fi [[ ! -s "$call_log" ]] || fail 'unknown role reached an xdg command' if $helper set-default browser org.example.Missing.desktop >/dev/null 2>&1; then fail 'undiscovered desktop id was accepted' fi if $helper set-default browser ../escape.desktop >/dev/null 2>&1; then fail 'unsafe desktop id was accepted' fi $helper set-autostart nextcloud.desktop true rg --quiet '^Hidden=false$' "$config_home/autostart/nextcloud.desktop" \ || fail 'enabling autostart did not set Hidden=false' [[ "$(rg --count '^Hidden=' "$config_home/autostart/nextcloud.desktop")" == "1" ]] \ || fail 'enabling autostart duplicated Hidden' rg --quiet '^Exec=nextcloud --background$' "$config_home/autostart/nextcloud.desktop" \ || fail 'autostart update damaged another desktop key' jq -e '.autostartEntries == [{id: "nextcloud.desktop", name: "Nextcloud", enabled: true}]' \ <<<"$($helper snapshot)" >/dev/null || fail 'enabled state did not round-trip' $helper set-autostart nextcloud.desktop false rg --quiet '^Hidden=true$' "$config_home/autostart/nextcloud.desktop" \ || fail 'disabling autostart did not set Hidden=true' outside_entry="$test_root/outside.desktop" cp "$config_home/autostart/nextcloud.desktop" "$outside_entry" ln -s "$outside_entry" "$config_home/autostart/linked.desktop" if $helper set-autostart linked.desktop true >/dev/null 2>&1; then fail 'autostart symlink escaping XDG config was accepted' fi rg --quiet '^Hidden=true$' "$outside_entry" || fail 'outside autostart file was modified' if $helper set-autostart missing.desktop true >/dev/null 2>&1; then fail 'unknown autostart desktop id was accepted' fi if $helper set-autostart 'hyprland:1' false >/dev/null 2>&1; then fail 'read-only compositor entry was accepted for mutation' fi $helper add-autostart org.mozilla.firefox.desktop firefox_autostart="$config_home/autostart/org.mozilla.firefox.desktop" [[ -f "$firefox_autostart" && ! -L "$firefox_autostart" ]] \ || fail 'adding an installed application did not create a regular user autostart entry' rg --quiet '^Name=Firefox$' "$firefox_autostart" \ || fail 'adding an application did not preserve its desktop entry' rg --quiet '^Hidden=false$' "$firefox_autostart" \ || fail 'a newly added application was not enabled' [[ "$(rg --count '^Hidden=' "$firefox_autostart")" == "1" ]] \ || fail 'adding an application wrote more than one Hidden key' $helper set-autostart org.mozilla.firefox.desktop false $helper add-autostart org.mozilla.firefox.desktop rg --quiet '^Hidden=false$' "$firefox_autostart" \ || fail 'adding an existing disabled application did not re-enable it' if $helper add-autostart org.example.Missing.desktop >/dev/null 2>&1; then fail 'an undiscovered application was accepted for autostart' fi 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" # ── One type on its own ────────────────────────────────────────────────────── # # The role rows set a whole family together, which is right almost always and # wrong for the person whose .heic files should open somewhere other than the # rest of their pictures. `set-type` is that escape hatch, and it is the one # verb here that writes a type nobody curated -- so what it accepts is the # question. # # Two guards, and they do different jobs: the type has to be one this system # knows (a typo becomes a handler entry for a MIME type that will never exist), # and the application has to be one that is installed (the same rule the role # rows already keep). assert_service_contains 'function searchTypes(query: string): void' assert_service_contains 'function setType(mime: string, desktopId: string): void' # The type database, as this fake machine has it. mkdir -p "$data_home/mime" cat >"$data_home/mime/globs2" <<'GLOBS' # weight:type:glob 50:image/png:*.png 50:image/jpeg:*.jpg 50:image/heic:*.heic 50:text/markdown:*.md 50:application/pdf:*.pdf GLOBS # Two applications that declare what they open, so "candidates" has something # true to report rather than passing on an empty list. printf 'MimeType=image/png;image/jpeg;image/heic;\n' >>"$data_home/applications/org.gnome.Loupe.desktop" printf 'MimeType=text/markdown;text/plain;\n' >>"$data_home/applications/panama-nvim.desktop" : >"$call_log" search="$($helper search-types heic)" || fail 'search-types failed' jq -e '(.types | type == "array") and has("truncated")' <<<"$search" >/dev/null \ || fail "search-types does not report a list of types and whether it was cut short: $search" jq -e '[.types[] | has("mime") and has("handler") and has("candidates")] | all' <<<"$search" >/dev/null \ || fail "a search result is missing its type, its current handler, or the applications that could open it: $search" jq -e '[.types[].mime] | index("image/heic") != null' <<<"$search" >/dev/null \ || fail "searching an extension did not find the type it belongs to: $search" jq -e '[.types[] | select(.mime == "image/heic") | .candidates[].id] | index("org.gnome.Loupe.desktop") != null' <<<"$search" >/dev/null \ || fail "an application that declares the type is not offered for it: $search" # Candidates are installed applications, never a name read out of a registry. discovered="$(cd "$data_home/applications" && ls)" while read -r candidate; do [[ -n "$candidate" ]] || continue rg -Fxq "$candidate" <<<"$discovered" \ || fail "search-types offers '$candidate', which is not installed on this machine" done < <(jq -r '[.types[].candidates[].id] | unique[]' <<<"$search") # A one-character query is not a search: it would return the whole database. jq -e '(.types | length) == 0' <<<"$($helper search-types a)" >/dev/null \ || fail 'a single character was treated as a search of the whole type database' : >"$call_log" $helper set-type image/heic org.gnome.Loupe.desktop assert_call $'default\norg.gnome.Loupe.desktop\nimage/heic' # One type, and only that type: the whole point of the escape hatch is that it # leaves the rest of the family where it was. [[ "$(rg --count '^default$' "$call_log")" == "1" ]] \ || fail 'setting one file type wrote more than one' : >"$call_log" if $helper set-type image/heic org.example.Missing.desktop >/dev/null 2>&1; then fail 'set-type accepted an application that is not installed' fi if $helper set-type image/heic ../escape.desktop >/dev/null 2>&1; then fail 'set-type accepted an unsafe desktop id' fi for bad_type in 'image' 'image/' '/png' 'image/png;rm -rf /' '' '../../etc/passwd' \ 'image/does-not-exist'; do if $helper set-type "$bad_type" org.gnome.Loupe.desktop >/dev/null 2>&1; then fail "set-type accepted a type this system does not have: ${bad_type@Q}" fi done [[ ! -s "$call_log" ]] || fail "a refused set-type still reached xdg-mime: $(cat "$call_log")" printf 'default apps contract: PASS\n'