Make Applications a real app manager, and clean up storage without the racket
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1,5 +1,22 @@
|
||||
#!/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() {
|
||||
@@ -8,45 +25,174 @@ fail() {
|
||||
}
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
page="$project_root/config/dot/quickshell/modules/settings/ApplicationsPage.qml"
|
||||
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"
|
||||
|
||||
[[ -f "$page" ]] || fail 'Applications page is missing'
|
||||
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 'activatable:'
|
||||
assert_contains 'ActionRow {'
|
||||
assert_contains 'TextRow {'
|
||||
|
||||
for label in Browser Mail Files Terminal Music Images Video; do
|
||||
assert_contains "label: \"$label\""
|
||||
# ── 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
|
||||
|
||||
assert_contains 'title: "Default applications"'
|
||||
assert_contains 'title: "User autostart"'
|
||||
assert_contains 'title: "Compositor autostart"'
|
||||
# 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 'categories'
|
||||
assert_contains 'genericName'
|
||||
assert_contains '.sort('
|
||||
assert_contains 'currentEntry'
|
||||
assert_contains 'label: "Compositor autostart"'
|
||||
assert_contains 'read-only'
|
||||
assert_contains 'choices.push(currentEntry)'
|
||||
assert_contains 'label: "Application settings need attention"'
|
||||
assert_contains 'DefaultApps.busy ? "Loading…"'
|
||||
assert_contains 'visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0'
|
||||
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 \])/);
|
||||
@@ -58,7 +204,14 @@ if (!rolesSource || !matcherSource) {
|
||||
|
||||
const roles = Function(`return (${rolesSource[1]})`)();
|
||||
const matchesRole = Function("entry", "role", matcherSource[1]);
|
||||
const role = key => roles.find(candidate => candidate.key === key);
|
||||
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",
|
||||
@@ -101,6 +254,30 @@ const fixtures = [
|
||||
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
|
||||
}
|
||||
];
|
||||
|
||||
@@ -113,27 +290,29 @@ for (const fixture of fixtures) {
|
||||
}
|
||||
'
|
||||
|
||||
if rg --quiet 'Component\.onCompleted|DesktopEntries\.(byId|heuristicLookup)' "$page"; then
|
||||
fail 'page snapshots or performs a one-time desktop-entry lookup'
|
||||
# ── 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
|
||||
if rg --quiet '#[0-9A-Fa-f]{3,8}' "$page"; then
|
||||
fail 'page introduces a color literal instead of the shared visual system'
|
||||
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
|
||||
|
||||
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|
||||
|| fail 'default and autostart rows are not both whole-row activatable'
|
||||
|
||||
picker="$project_root/config/dot/quickshell/modules/settings/AutostartAppPicker.qml"
|
||||
qmldir="$project_root/config/dot/quickshell/modules/settings/qmldir"
|
||||
[[ -f "$picker" ]] || fail 'autostart application picker is missing'
|
||||
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'
|
||||
rg -q '^AutostartAppPicker 1\.0 AutostartAppPicker\.qml$' "$qmldir" \
|
||||
|| fail 'autostart picker is not registered in the Settings module'
|
||||
# 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'
|
||||
|
||||
Reference in New Issue
Block a user