293 lines
15 KiB
Bash
Executable File
293 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Adding a settings page means editing four separate files, and missing one
|
|
# fails quietly rather than loudly:
|
|
#
|
|
# services/SettingsRoutes.qml the taxonomy: which category the page belongs
|
|
# to, and whether it is a tab inside one or a category
|
|
# of its own. This is the single source of truth -- the
|
|
# sidebar, the tab strip, ShellState's route
|
|
# resolution and the launcher command generator all
|
|
# derive from it, so a page absent here is a page that
|
|
# exists nowhere
|
|
# SettingsShell.qml the case that maps a leaf to a component, AND the
|
|
# Component declaration itself
|
|
# modules/settings/qmldir the component registration -- without it the page
|
|
# is "not a type" and the whole settings window fails
|
|
# to load, taking every other page with it
|
|
# the .qml file itself
|
|
#
|
|
# ShellState no longer keeps its own allow-list: it asks SettingsRoutes to
|
|
# resolve whatever id it is handed. That removed a fifth place to forget, and
|
|
# this contract pins that it stays removed -- a literal list reappearing there
|
|
# would silently disagree with the taxonomy again.
|
|
#
|
|
# Nothing at runtime cross-checks any of this. This does, statically.
|
|
|
|
set -euo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
|
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
|
|
shell_file="$settings_dir/SettingsShell.qml"
|
|
qmldir="$settings_dir/qmldir"
|
|
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
|
|
|
|
fail() {
|
|
printf 'settings nav contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
for required in "$routes" "$shell_file" "$qmldir" "$shell_state"; do
|
|
[[ -r "$required" ]] || fail "cannot read $required"
|
|
done
|
|
|
|
# ── Reading the taxonomy ─────────────────────────────────────────────────────
|
|
# A category line carries an icon; a tab line does not. A category whose tabs
|
|
# are empty is a leaf itself, which is why the two shapes are read separately.
|
|
category_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon:' "$routes" \
|
|
| sed -E 's/\{ page: "([a-z-]+)".*/\1/')"
|
|
tabless_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes" \
|
|
| sed -E 's/\{ page: "([a-z-]+)".*/\1/')"
|
|
tab_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" \
|
|
| sed -E 's/\{ page: "([a-z-]+)".*/\1/')"
|
|
|
|
# A third shape: a leaf that is reachable but is not a tab of anything. The
|
|
# manual is the only one -- it is reference material opened from About's Manual
|
|
# card and from deep links, not a control surface worth a permanent slot in the
|
|
# System strip. It still has to resolve, still has to have a SettingsShell case,
|
|
# and still has to be addressable by every caller holding its id, so it is a
|
|
# leaf for every purpose below except appearing in a tab strip.
|
|
hidden_block="$(sed -n '/property var hiddenLeaves:/,/\]/p' "$routes")"
|
|
hidden_ids="$(grep -oE 'page: "[a-z-]+"' <<<"$hidden_block" | sed -E 's/page: "([a-z-]+)"/\1/')"
|
|
|
|
[[ -n "$category_ids" ]] || fail 'no categories found in SettingsRoutes -- this contract is not reading it correctly'
|
|
[[ -n "$tab_ids" ]] || fail 'no tabs found in SettingsRoutes -- this contract is not reading it correctly'
|
|
[[ -n "$hidden_ids" ]] \
|
|
|| fail 'no hiddenLeaves found in SettingsRoutes -- the manual is a routable non-tab leaf and this contract is not reading the mechanism that makes it one'
|
|
|
|
# The leaves: every page a person can actually land on.
|
|
leaves="$(printf '%s\n%s\n%s\n' "$tabless_ids" "$tab_ids" "$hidden_ids" | sed '/^$/d')"
|
|
|
|
# A hidden leaf that is also a tab, or also a tabless category, puts one page
|
|
# in two places -- and unlike the tab/category overlap below, nothing visible
|
|
# would show it, because the hidden half draws no row anywhere.
|
|
while read -r page; do
|
|
[[ -n "$page" ]] || continue
|
|
grep -qx "$page" <<<"$tab_ids" \
|
|
&& fail "\"$page\" is a hidden leaf and also a tab, so the same page id names two different places"
|
|
grep -qx "$page" <<<"$tabless_ids" \
|
|
&& fail "\"$page\" is a hidden leaf and also a category of its own"
|
|
done <<<"$hidden_ids"
|
|
|
|
# ── The taxonomy addresses each leaf exactly once ─────────────────────────────
|
|
# ShellState.settingsPage holds a leaf id, and the sidebar highlights the
|
|
# category that owns it. A leaf in two categories makes "which row lights up"
|
|
# depend on iteration order, and makes the breadcrumb a coin flip.
|
|
duplicate_tabs="$(sort <<<"$tab_ids" | uniq -d)"
|
|
[[ -z "$duplicate_tabs" ]] \
|
|
|| fail "these tab pages appear in more than one category, so the sidebar highlight and the breadcrumb become ambiguous: $(tr '\n' ' ' <<<"$duplicate_tabs")"
|
|
|
|
# A category with tabs may share its id with its first tab -- "applications",
|
|
# "users" and "privacy" do, and both readings land on the same category. A
|
|
# category *without* tabs is a leaf, so sharing an id with a tab elsewhere
|
|
# would put one page in two places.
|
|
while read -r page; do
|
|
[[ -n "$page" ]] || continue
|
|
grep -qx "$page" <<<"$tab_ids" \
|
|
&& fail "\"$page\" is a category with no tabs and also a tab of another category, so the same page id names two different places"
|
|
done <<<"$tabless_ids"
|
|
|
|
duplicate_categories="$(sort <<<"$category_ids" | uniq -d)"
|
|
[[ -z "$duplicate_categories" ]] \
|
|
|| fail "these category ids are declared twice: $(tr '\n' ' ' <<<"$duplicate_categories")"
|
|
|
|
# ── Retired ids still land on a real page ────────────────────────────────────
|
|
# Old Vicinae commands, shell history and muscle memory keep handing over page
|
|
# ids that no longer exist, and resolve() consults the retired map before
|
|
# anything else. An entry aimed at a leaf that has itself since been renamed
|
|
# sends every one of those callers to Home without saying so, and an entry that
|
|
# names a live leaf shadows the real page.
|
|
retired_block="$(sed -n '/property var retired:/,/})/p' "$routes")"
|
|
retired_pairs="$(grep -oE '"[a-z-]+"[[:space:]]*:[[:space:]]*"[a-z-]+"' <<<"$retired_block" || true)"
|
|
retired_count=0
|
|
while read -r pair; do
|
|
[[ -n "$pair" ]] || continue
|
|
retired_id="$(sed -E 's/"([a-z-]+)".*/\1/' <<<"$pair")"
|
|
retired_target="$(sed -E 's/.*"([a-z-]+)"$/\1/' <<<"$pair")"
|
|
retired_count=$((retired_count + 1))
|
|
|
|
if ! grep -qx "$retired_target" <<<"$leaves"; then
|
|
fail "the retired id \"$retired_id\" resolves to \"$retired_target\", which is not a leaf, so everyone still holding it silently lands on Home"
|
|
fi
|
|
if grep -qx "$retired_id" <<<"$leaves"; then
|
|
fail "\"$retired_id\" is listed as retired and is also a live leaf, so resolve() answers with the retired target instead of the page itself"
|
|
fi
|
|
done <<<"$retired_pairs"
|
|
|
|
# ── The System strip is nine tabs, and the two it lost are still reachable ───
|
|
#
|
|
# System had grown to ten tabs, which is more than a strip can show without
|
|
# becoming a second sidebar. Two left: Region & Language merged into Date &
|
|
# Time, because a date format and the clock that shows it are one subject, and
|
|
# the Manual became a hidden leaf.
|
|
#
|
|
# Agents is the ninth, added deliberately rather than by accretion: it is where
|
|
# the preferred agent is chosen, and every rung of the escalation ladder --
|
|
# crash notifications, failed reloads, red health checks -- is dark until that
|
|
# choice is made, so it has to be somewhere a person can find without being
|
|
# told the id.
|
|
#
|
|
# The count is pinned rather than derived because the number is the point: this
|
|
# is the horizontal space one row of tabs has. Anything that needs a tenth
|
|
# subject needs a decision, not another entry.
|
|
python3 - "$routes" <<'PY' || fail 'the System category is not the approved nine-tab strip'
|
|
import re
|
|
import sys
|
|
|
|
expected = [
|
|
"about", "updates", "services", "agents", "storage",
|
|
"snapshots", "containers", "datetime", "sync",
|
|
]
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\n \] \}', text, re.S)
|
|
if not block:
|
|
raise SystemExit("the System category could not be read")
|
|
found = re.findall(r'\{ page: "([a-z-]+)", label: "[^"]*" \}', block.group(1))
|
|
if found != expected:
|
|
raise SystemExit(f"System tabs are {found}, expected {expected}")
|
|
PY
|
|
|
|
# `region` retiring is only safe because every caller still holding it lands on
|
|
# the tab that absorbed it. The generic retired-map check above proves the
|
|
# target is a leaf; this proves it is the RIGHT leaf, which is the half a
|
|
# rename cannot get wrong quietly.
|
|
grep -qE '"region"[[:space:]]*:[[:space:]]*"datetime"' <<<"$retired_block" \
|
|
|| fail 'the retired "region" id does not resolve to "datetime", so every Vicinae command, deep link, and search result holding it lands on Home'
|
|
[[ ! -e "$settings_dir/RegionPage.qml" ]] \
|
|
|| fail 'RegionPage.qml still exists, so the retired route has a live page behind it after all'
|
|
|
|
# The manual is a leaf but not a tab. Said both ways: a strip entry would put
|
|
# reference material back in the System strip that the merge just freed, and
|
|
# losing the leaf would break About's Manual card and every deep link.
|
|
grep -qx 'manual' <<<"$hidden_ids" \
|
|
|| fail 'the manual is not a hidden leaf, so opening it from About or a deep link has no destination'
|
|
grep -qx 'manual' <<<"$tab_ids" \
|
|
&& fail 'the manual is a tab again, which is the System strip slot the consolidation just freed'
|
|
|
|
# ── Every leaf resolves everywhere ───────────────────────────────────────────
|
|
while read -r page; do
|
|
[[ -n "$page" ]] || continue
|
|
|
|
# Home is the switch's default arm rather than a case, since it is also
|
|
# where an unknown page falls back to.
|
|
if [[ "$page" != "home" ]]; then
|
|
grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \
|
|
|| fail "SettingsRoutes offers \"$page\" but SettingsShell has no case for it, so opening it shows Home"
|
|
fi
|
|
done <<<"$leaves"
|
|
|
|
# ── ShellState defers to the taxonomy instead of restating it ────────────────
|
|
grep -q 'SettingsRoutes\.resolve(' "$shell_state" \
|
|
|| fail 'showSettings() does not route through SettingsRoutes.resolve, so a category id or an unknown page has no defined destination'
|
|
grep -q 'const allowed = \[' "$shell_state" \
|
|
&& fail 'ShellState has grown a literal allow-list again -- it will drift from SettingsRoutes, and a page missing from it silently redirects to Home'
|
|
|
|
# ── Every routed component is declared and registered ────────────────────────
|
|
# The case arms name a Component id; each must have a declaration, and the type
|
|
# it instantiates must appear in qmldir.
|
|
while read -r component; do
|
|
[[ -n "$component" ]] || continue
|
|
|
|
declaration="$(grep -oE "Component \{ id: $component; [A-Za-z]+ \{\} \}" "$shell_file")" \
|
|
|| fail "SettingsShell routes to \"$component\" but never declares it"
|
|
|
|
type_name="$(sed -E 's/.*; ([A-Za-z]+) \{\} \}/\1/' <<<"$declaration")"
|
|
grep -qE "^$type_name [0-9.]+ $type_name\.qml$" "$qmldir" \
|
|
|| fail "$type_name is not registered in modules/settings/qmldir -- it will fail to load as \"not a type\", and the whole settings window fails with it"
|
|
|
|
[[ -r "$settings_dir/$type_name.qml" ]] \
|
|
|| fail "$type_name is registered in qmldir but $type_name.qml does not exist"
|
|
done < <({
|
|
grep -oE 'case "[a-z-]+": return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
|
|
grep -oE 'default: return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
|
|
} | sort -u)
|
|
|
|
# ── Every page file is reachable ─────────────────────────────────────────────
|
|
# A page nobody can navigate to is dead code that still has to compile. The
|
|
# scaffold SettingsPage.qml is the one file here that is a base class rather
|
|
# than a page.
|
|
while read -r page_file; do
|
|
type_name="$(basename "$page_file" .qml)"
|
|
[[ "$type_name" == "SettingsPage" ]] && continue
|
|
|
|
grep -qE "; $type_name \{\} \}" "$shell_file" \
|
|
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
|
|
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
|
|
|
|
# ── The search index lands on leaves, not on ids that used to be leaves ──────
|
|
#
|
|
# SettingsSearch is the fifth place a page id is written down, and the only one
|
|
# where being wrong is silent: `resolve()` turns anything it does not recognise
|
|
# into Home, so a result whose page id was retired still opens a window, still
|
|
# looks like it worked, and lands somewhere else. That is exactly what the two
|
|
# Region & Language entries would have done -- and they are the entries most
|
|
# likely to be searched for by somebody who could not find the setting.
|
|
#
|
|
# Checked against the leaves this file already derived, so a page consolidated
|
|
# next time cannot leave a search result pointing at its old name.
|
|
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
|
|
[[ -r "$search" ]] || fail "cannot read $search"
|
|
while read -r page; do
|
|
[[ -n "$page" ]] || continue
|
|
grep -qx "$page" <<<"$leaves" && continue
|
|
if grep -qE "\"[a-z-]+\"[[:space:]]*:[[:space:]]*\"$page\"" <<<"$retired_block"; then
|
|
fail "the search index routes to \"$page\", which is a retired id -- resolve() answers with its target, so the result lands somewhere the row never named"
|
|
fi
|
|
fail "the search index routes to \"$page\", which is not a leaf, so that result silently opens Home"
|
|
done < <({
|
|
grep -oE 'page: "[a-z-]+"' "$search"
|
|
sed -n '/property var groupPages:/,/})/p' "$search" | grep -oE ': "[a-z-]+"'
|
|
} | sed -E 's/.*"([a-z-]+)".*/\1/' | sort -u)
|
|
|
|
# The subjects the consolidation moved, each findable by its own name and each
|
|
# landing on the tab that now owns it. Region & Language merged into Date, Time
|
|
# & Region, so the words people arrive with for a format have to reach it; and
|
|
# About and Sync & Backup grew rows nobody could search for at all.
|
|
while IFS='|' read -r label page; do
|
|
[[ -n "$label" ]] || continue
|
|
python3 - "$search" "$label" "$page" <<'PY' \
|
|
|| fail "the search index does not offer \"$label\" on the $page page"
|
|
import re
|
|
import sys
|
|
|
|
text, label, page = open(sys.argv[1], encoding="utf-8").read(), sys.argv[2], sys.argv[3]
|
|
pattern = rf'\{{ label: "{re.escape(label)}",[^\n]*page: "([a-z-]+)" \}}'
|
|
match = re.search(pattern, text)
|
|
if not match:
|
|
raise SystemExit(f'no entry labelled "{label}"')
|
|
if match.group(1) != page:
|
|
raise SystemExit(f'"{label}" routes to {match.group(1)}, expected {page}')
|
|
PY
|
|
done <<'SEARCHABLE'
|
|
Hostname|about
|
|
Kernel version|about
|
|
Device model|about
|
|
Installed memory|about
|
|
Uptime|about
|
|
Serial number|about
|
|
Export settings|sync
|
|
Import settings|sync
|
|
Language|datetime
|
|
Regional formats|datetime
|
|
Currency|datetime
|
|
Measurement units|datetime
|
|
Paper size|datetime
|
|
First day of the week|datetime
|
|
Manual|manual
|
|
SEARCHABLE
|
|
|
|
printf 'settings nav contract: PASS (%d categories, %d leaves of which %d hidden, %d retired ids)\n' \
|
|
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" \
|
|
"$(grep -c . <<<"$hidden_ids")" "$retired_count"
|