397 lines
17 KiB
Bash
Executable File
397 lines
17 KiB
Bash
Executable File
#!/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" <<EOF
|
|
[Desktop Entry]
|
|
Type=Application
|
|
Name=$name
|
|
GenericName=$generic_name
|
|
Categories=$categories
|
|
Exec=/usr/bin/true
|
|
EOF
|
|
}
|
|
|
|
write_application org.mozilla.firefox.desktop Firefox 'Web Browser' 'Network;WebBrowser;'
|
|
write_application org.gnome.Geary.desktop Geary 'Mail Client' 'Network;Email;'
|
|
write_application org.gnome.Nautilus.desktop Files 'File Manager' 'System;FileManager;'
|
|
write_application org.gnome.Ptyxis.desktop Ptyxis Terminal 'System;TerminalEmulator;'
|
|
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]
|
|
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'
|