319 lines
14 KiB
Bash
Executable File
319 lines
14 KiB
Bash
Executable File
#!/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'
|