#!/usr/bin/env bash # The Applications page manages applications now, rather than only pointing file # types at them. What is pinned here is the part that is easy to get subtly # wrong and impossible to notice: # # * the role matcher, which decides which applications a role may be set to. # It is extracted from the page and run against fixtures, because every bug # it has ever had was silent -- a role that offered nothing but the # application it already had, and nobody could tell whether that was the # matcher or the machine; # * removal being honest: Flatpak applications come off from here, system # packages do not, and the row says the command instead of pretending; # * the things a page can quietly lose in a rebuild -- the escape hatch for a # single file type, the read-only compositor autostart, the launcher chord # that is read rather than hardcoded. # # Reading only: nothing here runs the page or changes a setting. set -euo pipefail fail() { printf 'applications settings contract: %s\n' "$1" >&2 exit 1 } project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" settings="$project_root/config/dot/quickshell/modules/settings" page="$settings/ApplicationsPage.qml" row="$settings/InstalledAppRow.qml" picker="$settings/AutostartAppPicker.qml" types="$settings/FileTypePicker.qml" qmldir="$settings/qmldir" for path in "$page" "$row" "$picker" "$types" "$qmldir"; do [[ -f "$path" ]] || fail "missing $path" done assert_contains() { rg -F --quiet "$1" "$page" || fail "page is missing: $1" } assert_row_contains() { rg -F --quiet "$1" "$row" || fail "the installed-application row is missing: $1" } assert_contains 'SettingsPage {' assert_contains 'objectName: "applications"' assert_contains 'DesktopEntries.applications.values' assert_contains 'AppLibrary' assert_contains 'DefaultApps' assert_contains 'SettingsCard {' assert_contains 'SettingRow {' assert_contains 'ActionRow {' assert_contains 'TextRow {' # ── The cards, and what each one is for ────────────────────────────────────── for title in 'Installed applications' 'Browse the catalog' 'Default applications' \ 'Autostart' 'Search'; do rg -F --quiet "title: \"$title\"" "$page" || fail "the page has no \"$title\" card" done # Every role still has a row, and the role list is still ten long: a family that # quietly disappears takes its file types with it. for label in Browser Mail Files Terminal Images Music Video Documents Text Archives; do assert_contains "label: \"$label\"" done [[ "$(rg --count 'key: "' "$page")" == "10" ]] \ || fail 'the ten default-application roles are no longer ten' # The roles are pickers now, not an accordion of buttons. assert_contains 'OptionPickerRow {' assert_contains 'onPicked: value => DefaultApps.setDefault(' assert_contains 'DefaultApps.busy ? "Loading…"' assert_contains 'currentEntry' assert_contains 'choices.push(currentEntry)' # The escape hatch for one type, and the service call behind it. assert_contains 'label: "One file type"' assert_contains 'FileTypePicker {' assert_contains 'DefaultApps.searchTypes(query)' assert_contains 'DefaultApps.setType(mime, desktopId)' rg -Fq 'signal queried(string query)' "$types" \ || fail 'the file-type picker cannot ask for a search' rg -Fq 'signal chosen(string mime, string desktopId)' "$types" \ || fail 'the file-type picker cannot report which application was chosen for a type' # ── The installed list ─────────────────────────────────────────────────────── assert_contains 'InstalledAppRow {' assert_contains 'AppLibrary.matches(app, installedSearch.text)' assert_row_contains 'signal uninstallArmed' assert_row_contains 'signal uninstallConfirmed' assert_row_contains 'root.confirmingUninstall' assert_row_contains 'label: "Permissions"' assert_row_contains 'label: "Start with the session"' assert_row_contains 'label: "Elsewhere in Settings"' # Removing a system package is refused, by name, with the command that would do # it. This is the whole reason the page can be trusted with an Uninstall button # at all: it does one thing, and says plainly what it will not do. assert_row_contains 'label: "Managed by dnf"' assert_row_contains 'sudo dnf remove ' rg -v '^\s*//' "$page" | rg -q 'dnf remove' \ && fail 'the page offers a package removal outside the honest refusal row' python3 - "$page" "$row" <<'PY' || fail 'the page can run a package manager' import re import sys for path in sys.argv[1:]: text = open(path, encoding="utf-8").read() # Every argument list handed to a process. A dnf or rpm in one of these is a # settings page removing packages, whatever the button says. for match in re.finditer(r"exec\w*\(\s*(\[[^\]]*\])", text): argv = match.group(1) if re.search(r'"(dnf|rpm|pkexec|sudo|yum|rpm-ostree)"', argv): print(f"{path}: {argv.strip()[:120]}", file=sys.stderr) raise SystemExit(1) PY # Flatseal is offered only when it is installed: a button that does nothing is # worse than no button. assert_contains 'flatsealAvailable' assert_contains 'com.github.tchx84.Flatseal' # The jump chips are shown where a rule exists, not everywhere. assert_row_contains 'hasNotificationRule' assert_row_contains 'hasSoundRule' assert_contains 'Notifs.applications' assert_contains 'AudioDevices.applications' # ── The catalog ────────────────────────────────────────────────────────────── # # The id passed to install is the catalog line verbatim. Handing over the # human-readable `ref` instead would be refused by the helper for every Flathub # entry in the catalog -- the failure would look like "installing is broken". assert_contains 'AppLibrary.install(root.activeCategory,' rg -q 'AppLibrary\.install\([^)]*\.ref' "$page" \ && fail 'the catalog installs by the display ref rather than by the catalog id' assert_contains 'AppLibrary.entriesFor(' assert_contains 'system package, so installing asks for your password' assert_contains 'value: catalogRow.installed ? "Installed" : ""' # ── Autostart ──────────────────────────────────────────────────────────────── assert_contains 'AutostartAppPicker {' assert_contains 'DefaultApps.addAutostart(' assert_contains 'label: "Add an application"' assert_contains 'label: "Compositor autostart"' assert_contains 'read-only' assert_contains 'visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0' rg -Fq 'required property var existing' "$picker" \ || fail 'autostart picker cannot exclude existing entries' rg -Fq 'signal picked(string id)' "$picker" \ || fail 'autostart picker does not emit a validated desktop id' # The compositor's entries are described, never toggled: the file they live in # is read once at launch, so a switch here would silently do nothing. python3 - "$page" <<'PY' || fail 'a compositor autostart entry is offered as something to change' import re import sys text = open(sys.argv[1], encoding="utf-8").read() for match in re.finditer(r"model: DefaultApps\.luaAutostartEntries", text): tail = text[match.start():match.start() + 1200] if "SettingsToggle" in tail or "setAutostart" in tail: print(tail[:200], file=sys.stderr) raise SystemExit(1) PY # ── The launcher chord is read, not remembered ─────────────────────────────── # # "Super+Space" was hardcoded here through two rebinds of the launcher and told # the wrong story both times. assert_contains 'Keybinds.binds' assert_contains 'root.launcherChords' python3 - "$page" <<'PY' || fail 'the launcher chord is stated rather than read from the keymap' import re import sys source = open(sys.argv[1], encoding="utf-8").read() text = "\n".join("" if line.strip().startswith("//") else line for line in source.splitlines()) # The literal may survive as the fallback shown while the keymap is still being # read, but not as the value itself. for match in re.finditer(r'"Super ?\+ ?Space"', text): line = text[:match.start()].rsplit("\n", 1)[-1] + text[match.start():].split("\n", 1)[0] if "launcherChords" not in line: print(line.strip(), file=sys.stderr) raise SystemExit(1) PY # ── The role matcher, run rather than read ─────────────────────────────────── # # Extracted from the page and executed against fixtures. The interesting cases # are the near misses: a media centre is not a music player, a document scanner # is not an image viewer, and the categories arrive as a QML list rather than a # JavaScript array -- which is the exact shape that once made Archives match # nothing at all. PAGE_PATH="$page" bun -e ' const source = await Bun.file(process.env.PAGE_PATH).text(); const rolesSource = source.match(/readonly property var roles:\s*(\[[\s\S]*?\n \])/); const matcherSource = source.match(/function matchesRole\(entry: var, role: var\): bool \{([\s\S]*?)\n \}/); if (!rolesSource || !matcherSource) { console.error("applications settings contract: role matcher could not be loaded"); process.exit(1); } const roles = Function(`return (${rolesSource[1]})`)(); const matchesRole = Function("entry", "role", matcherSource[1]); const role = key => { const found = roles.find(candidate => candidate.key === key); if (!found) { console.error(`applications settings contract: no "${key}" role`); process.exit(1); } return found; }; const fixtures = [ { name: "AudioVideo does not imply music", entry: { name: "Kodi", genericName: "Media Center", comment: "Entertainment hub", categories: "AudioVideo;Player;" }, role: "music", expected: false }, { name: "Graphics does not imply image handler", entry: { name: "Document Scanner", genericName: "Document Scanner", comment: "Scan documents", categories: ["Graphics"] }, role: "images", expected: false }, { name: "Viewer does not imply image handler", entry: { name: "Papers", genericName: "Document Viewer", comment: "Read documents", categories: "Office;Viewer;" }, role: "images", expected: false }, { name: "comment does not nominate a default handler", entry: { name: "Settings", genericName: "System Settings", comment: "Configure your video player", categories: ["System"] }, role: "video", expected: false }, { name: "exact audio player categories match music", entry: { name: "Rhythmbox", genericName: "Music Player", comment: "Play music", categories: "AudioVideo;Audio;Player;" }, role: "music", expected: true }, { name: "exact video category matches video", entry: { name: "Videos", genericName: "Video Player", comment: "Play movies", categories: ["AudioVideo", "Video", "Player"] }, role: "video", expected: true }, { name: "descriptive metadata matches image handler", entry: { name: "Loupe", genericName: "Image Viewer", comment: "Browse pictures", categories: "Graphics;Viewer;" }, role: "images", expected: true }, { name: "an archive manager can be the archives handler", entry: { name: "File Roller", genericName: "Archive Manager", comment: "Open archives", categories: "Utility;Archiving;" }, role: "archives", expected: true }, { name: "categories that arrive as a list are read as categories", entry: { name: "Ark", genericName: "Ark", comment: "", categories: ["Utility", "Archiving"] }, role: "archives", expected: true }, { name: "a comma-separated category string is still a list of categories", entry: { name: "Ark", genericName: "Ark", comment: "", categories: "Utility,Archiving" }, role: "archives", expected: true }, { name: "a text editor is not a terminal", entry: { name: "Neovim", genericName: "Text Editor", comment: "Edit text", categories: "Utility;TextEditor;" }, role: "terminal", expected: false } ]; for (const fixture of fixtures) { const actual = matchesRole(fixture.entry, role(fixture.role)); if (actual !== fixture.expected) { console.error(`applications settings contract: ${fixture.name}: expected ${fixture.expected}, got ${actual}`); process.exit(1); } } ' # ── House rules ────────────────────────────────────────────────────────────── # # The page may ask its services to load when it opens -- AppLibrary reads # nothing until something wants it -- but it may not snapshot desktop entries or # look one up by hand: both produce a list that stops tracking what is # installed. if rg --quiet 'DesktopEntries\.(byId|heuristicLookup)' "$page"; then fail 'page performs a one-time desktop-entry lookup instead of tracking the live list' fi if rg -F --quiet 'label: "Could not apply the change"' "$page"; then fail 'error heading incorrectly describes read failures as apply failures' fi for path in "$page" "$row" "$types"; do if rg --quiet '#[0-9A-Fa-f]{3,8}' "$path"; then fail "$(basename "$path") introduces a color literal instead of the shared visual system" fi done # Every component the page draws is registered, or the page does not load at # all -- and a QML page that fails to load looks like an empty tab. for component in AutostartAppPicker InstalledAppRow FileTypePicker SettingsChip OptionPickerRow; do rg -q "^$component 1\.0 $component\.qml$" "$qmldir" \ || fail "$component is not registered in the Settings module" done printf 'applications settings contract: PASS\n'