Home is now Overview | My Home | Phone. Overview leads with quick-action tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the findings card — updates fold in, the reclaim-space prompt is gone on purpose — and adds glance cards, the next calendar event, and weather. My Home groups every light by Home Assistant area: the helper gained an `areas` command (one REST template render, no websocket), and the rooms degrade to a flat list on setups without areas. The favorites editor and connection card moved intact. Phone gains a vitals strip — battery and cell signal read from KDE Connect's plugin D-Bus objects, where absence is data, not an error — beside ring, clipboard, send-a-file, and the BlueBubbles handoff. The retired home-phone id resolves to my-home forever via a new alias map in SettingsRoutes (with a hasOwnProperty guard so prototype names cannot leak into settingsPage). Storage no longer claims 0 B free — the old page read a field the disks helper never emitted. Contracts updated alongside; per the new workflow, the full suite runs once at the end of the redesign (see the test backlog note). Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
347 lines
13 KiB
Bash
Executable File
347 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
|
|
fail() {
|
|
printf 'settings pages contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
pages=(Home MyHome Phone Displays Connectivity Sound Dictation Notifications ScreenIntelligence Health About)
|
|
for page in "${pages[@]}"; do
|
|
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
|
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
|
|
|
root_type="$(awk '
|
|
/^import / { next }
|
|
/^[[:space:]]*\/\// { next }
|
|
/^[[:space:]]*$/ { next }
|
|
match($0, /^[[:space:]]*([A-Za-z][A-Za-z0-9]*)[[:space:]]*\{/, found) {
|
|
print found[1]
|
|
exit
|
|
}
|
|
' "$page_file")"
|
|
[[ "$root_type" == "SettingsPage" ]] \
|
|
|| fail "${page}Page.qml root is ${root_type:-unknown}, expected SettingsPage"
|
|
! rg -q '^[[:space:]]*Flickable[[:space:]]*\{' "$page_file" \
|
|
|| fail "${page}Page.qml still copies the page Flickable scaffold"
|
|
done
|
|
|
|
require_row() {
|
|
local file="$1"
|
|
local row_type="$2"
|
|
local setting="$3"
|
|
|
|
python3 - "$file" "$row_type" "$setting" <<'PY' || \
|
|
fail "$(basename "$file") is missing $row_type for $setting"
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
row_type = re.escape(sys.argv[2])
|
|
setting = re.escape(sys.argv[3])
|
|
pattern = rf"{row_type}\s*\{{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{{).)*?setting\s*:\s*\"{setting}\""
|
|
raise SystemExit(0 if re.search(pattern, text, re.S) else 1)
|
|
PY
|
|
}
|
|
|
|
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
|
|
require_row "$home_page" ChoiceRow temperatureUnit
|
|
require_row "$home_page" SliderRow weatherRefreshMinutes
|
|
# vitalsIntervalMs moved to Appearance, beside the toggles it governs. It sat
|
|
# on Home while showCpu/showMemory/showGpu sat on Appearance -- one concept
|
|
# across two pages, which the ownership rule forbids and which made a search
|
|
# for it open a page that did not contain it.
|
|
appearance_page="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
|
|
require_row "$appearance_page" SliderRow vitalsIntervalMs
|
|
|
|
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
|
|
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
|
|
require_row "$notifications_page" SliderRow "$setting"
|
|
done
|
|
python3 - "$notifications_page" <<'PY' || fail 'critical notification timeout does not render zero as Never'
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
block = re.search(
|
|
r'SliderRow\s*\{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{).)*?'
|
|
r'setting\s*:\s*"notificationTimeoutCriticalMs"(?P<tail>.*?)\n\s*\}',
|
|
text,
|
|
re.S,
|
|
)
|
|
raise SystemExit(0 if block and re.search(r'zeroLabel\s*:\s*"Never"', block.group(0)) else 1)
|
|
PY
|
|
|
|
intelligence_page="$repo_dir/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml"
|
|
# Free text, not a choice. Three preset folders could not include the one the
|
|
# rest of somebody's software already writes to, which is the only folder that
|
|
# matters -- so these became editable and the row type changed with them.
|
|
require_row "$intelligence_page" TextEntryRow screenshotDir
|
|
require_row "$intelligence_page" TextEntryRow recordingDir
|
|
require_row "$intelligence_page" ChoiceRow recorderArgs
|
|
|
|
# The source-only contract is safe during a shared Quickshell quiet window.
|
|
# The existing compositor integration checks remain available explicitly.
|
|
if [[ "${PANAMA_SETTINGS_STATIC_ONLY:-0}" == "1" ]]; then
|
|
printf 'settings pages contract: PASS (static)\n'
|
|
exit 0
|
|
fi
|
|
|
|
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
|
|
source_config_path="$repo_dir/config/dot/quickshell"
|
|
config_path="$state_home/quickshell"
|
|
harness="$config_path/settings-pages-harness.qml"
|
|
test_bin="$state_home/bin"
|
|
shell_log="$state_home/quickshell.log"
|
|
production_config_path="$HOME/.config/quickshell/shell.qml"
|
|
harness_pid=""
|
|
harness_shell_id=""
|
|
production_before=""
|
|
|
|
cleanup_bootstrap() {
|
|
rm -rf "$state_home"
|
|
}
|
|
trap cleanup_bootstrap EXIT
|
|
|
|
mkdir -p "$test_bin"
|
|
cp -a "$source_config_path" "$config_path"
|
|
python3 - "$config_path/shell.qml" "$harness" "$$" <<'PY'
|
|
import sys
|
|
|
|
source_path, harness_path, identity = sys.argv[1:]
|
|
source = open(source_path, encoding="utf-8").read()
|
|
needle = "ShellRoot {\n"
|
|
replacement = (
|
|
needle
|
|
+ f' readonly property string settingsPagesHarnessIdentity: "settings-pages-contract-{identity}"\n'
|
|
)
|
|
if source.count(needle) != 1:
|
|
raise SystemExit("shell.qml does not have exactly one ShellRoot")
|
|
with open(harness_path, "w", encoding="utf-8") as handle:
|
|
handle.write(source.replace(needle, replacement, 1))
|
|
PY
|
|
|
|
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
case "${1:-}" in
|
|
catalog)
|
|
printf '%s\n' '{"ok":false,"error":"test-helper"}'
|
|
;;
|
|
toggle|brightness)
|
|
printf '%s\n' '{"ok":true}'
|
|
;;
|
|
esac
|
|
EOF
|
|
chmod +x "$config_path/scripts/panama-home-assistant"
|
|
|
|
cat >"$test_bin/hyprctl" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
|
|
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Settings contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
|
|
exit 0
|
|
fi
|
|
if [[ "${1:-}" == "keyword" ]]; then
|
|
exit 0
|
|
fi
|
|
exec /usr/sbin/hyprctl "$@"
|
|
EOF
|
|
chmod +x "$test_bin/hyprctl"
|
|
|
|
cat >"$test_bin/flatpak" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
if [[ "${1:-}" == "info" ]]; then
|
|
exit 1
|
|
fi
|
|
exit 97
|
|
EOF
|
|
chmod +x "$test_bin/flatpak"
|
|
|
|
qs_for_test() {
|
|
if [[ "${1:-}" == "ipc" && "$harness_pid" =~ ^[0-9]+$ ]]; then
|
|
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
|
qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
|
|
else
|
|
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
|
qs -p "$harness" "$@"
|
|
fi
|
|
}
|
|
|
|
instances_for_path() {
|
|
local expected_path="$1" listing
|
|
|
|
listing="$(qs list --all 2>/dev/null)" || return 1
|
|
awk -v expected="$expected_path" '
|
|
/^Instance / { pid = ""; shell_id = "" }
|
|
/^[[:space:]]*Process ID:/ { pid = $3 }
|
|
/^[[:space:]]*Shell ID:/ { shell_id = $3 }
|
|
/^[[:space:]]*Config path:/ {
|
|
path = $0
|
|
sub(/^[[:space:]]*Config path: /, "", path)
|
|
if (path == expected && pid ~ /^[0-9]+$/ && shell_id != "")
|
|
print pid "|" shell_id
|
|
}
|
|
' <<<"$listing"
|
|
}
|
|
|
|
harness_identity_matches() {
|
|
local current
|
|
|
|
[[ "$harness_pid" =~ ^[0-9]+$ && -n "$harness_shell_id" ]] || return 1
|
|
current="$(instances_for_path "$harness")" || return 1
|
|
grep -Fxq "$harness_pid|$harness_shell_id" <<<"$current"
|
|
}
|
|
|
|
production_is_preserved() {
|
|
local current record pid shell_id
|
|
|
|
current="$(instances_for_path "$production_config_path")" || return 1
|
|
while IFS='|' read -r pid shell_id; do
|
|
[[ -n "$pid" ]] || continue
|
|
kill -0 "$pid" >/dev/null 2>&1 || return 1
|
|
record="$pid|$shell_id"
|
|
grep -Fxq "$record" <<<"$current" || return 1
|
|
done <<<"$production_before"
|
|
}
|
|
|
|
stop_harness() {
|
|
local remaining
|
|
|
|
if harness_identity_matches; then
|
|
kill "$harness_pid" >/dev/null 2>&1 || true
|
|
for _ in $(seq 1 80); do
|
|
! kill -0 "$harness_pid" >/dev/null 2>&1 && break
|
|
sleep 0.05
|
|
done
|
|
if kill -0 "$harness_pid" >/dev/null 2>&1 && harness_identity_matches; then
|
|
kill -KILL "$harness_pid" >/dev/null 2>&1 || true
|
|
for _ in $(seq 1 20); do
|
|
! kill -0 "$harness_pid" >/dev/null 2>&1 && break
|
|
sleep 0.05
|
|
done
|
|
fi
|
|
fi
|
|
remaining="$(instances_for_path "$harness")" || return 1
|
|
harness_pid=""
|
|
harness_shell_id=""
|
|
[[ -z "$remaining" ]]
|
|
}
|
|
|
|
cleanup() {
|
|
local cleanup_ok=0
|
|
|
|
stop_harness || cleanup_ok=1
|
|
production_is_preserved || cleanup_ok=1
|
|
if (( cleanup_ok == 0 )); then
|
|
rm -rf "$state_home"
|
|
else
|
|
printf 'settings pages contract: isolated harness cleanup failed; retained %s\n' \
|
|
"$state_home" >&2
|
|
fi
|
|
return "$cleanup_ok"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
start_test_shell() {
|
|
local harness_instances production_pid production_shell_id
|
|
|
|
harness_instances="$(instances_for_path "$harness")" \
|
|
|| fail 'could not inspect Quickshell instances before starting the runtime harness'
|
|
[[ -z "$harness_instances" ]] \
|
|
|| fail 'an unexpected process already uses the runtime harness path'
|
|
for _attempt in 1 2; do
|
|
qs_for_test --daemonize >"$shell_log" 2>&1
|
|
for _ in $(seq 1 80); do
|
|
harness_instances="$(instances_for_path "$harness")" \
|
|
|| fail 'could not inspect the runtime harness instance'
|
|
[[ -n "$harness_instances" ]] && break
|
|
sleep 0.05
|
|
done
|
|
if [[ "$(wc -l <<<"$harness_instances")" == 1 && -n "$harness_instances" ]]; then
|
|
IFS='|' read -r harness_pid harness_shell_id <<<"$harness_instances"
|
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
|
|| fail 'runtime harness did not expose a numeric PID'
|
|
|
|
while IFS='|' read -r production_pid production_shell_id; do
|
|
[[ -n "$production_pid" ]] || continue
|
|
[[ "$harness_shell_id" != "$production_shell_id" ]] || {
|
|
stop_harness
|
|
fail 'runtime harness shares a Shell ID with production'
|
|
}
|
|
done <<<"$production_before"
|
|
production_is_preserved || {
|
|
stop_harness
|
|
fail 'production changed before isolated page routing began'
|
|
}
|
|
|
|
for _ in $(seq 1 80); do
|
|
if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then
|
|
return
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
fi
|
|
stop_harness || fail 'failed runtime harness attempt did not stop cleanly'
|
|
done
|
|
sed -n '1,200p' "$shell_log" >&2
|
|
fail 'isolated branch shell did not start'
|
|
}
|
|
|
|
production_before="$(instances_for_path "$production_config_path")" \
|
|
|| fail 'could not list Quickshell instances for the production baseline'
|
|
production_is_preserved || fail 'could not capture a stable production instance set'
|
|
start_test_shell
|
|
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
|
shell_pid="$harness_pid"
|
|
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
|
|
|
# A spread of leaves rather than all of them: tabless categories, tabs from
|
|
# four different categories, and the page the tab strip was introduced for.
|
|
# Routing to a tab must land on that tab, not on whatever its category opens
|
|
# first, which is the failure the SettingsRoutes resolution could introduce.
|
|
pages=(home appearance displays connectivity my-home phone desktop sound dictation notifications screen-intelligence shortcuts services manual about)
|
|
for page in "${pages[@]}"; do
|
|
qs_for_test ipc call settings page "$page" >/dev/null
|
|
for _ in $(seq 1 20); do
|
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] && break
|
|
sleep 0.1
|
|
done
|
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
|
|
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
|
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|
|
|| fail "$page created a missing, floating, or duplicate Settings window"
|
|
done
|
|
|
|
qs_for_test ipc call settings page '__unsupported__' >/dev/null
|
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
|
|
|
|
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|
|
|| fail 'Super+I is not registered as Panama Settings'
|
|
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|
|
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
|
|
|
|
desktop_file="$HOME/.local/share/applications/panama-settings.desktop"
|
|
[[ -L "$desktop_file" || -f "$desktop_file" ]] || fail 'searchable desktop entry is not installed'
|
|
desktop-file-validate "$desktop_file" >/dev/null || fail 'desktop entry is invalid'
|
|
gnome_desktop_file="$HOME/.local/share/applications/gnome-settings-hyprland.desktop"
|
|
[[ -L "$gnome_desktop_file" || -f "$gnome_desktop_file" ]] || fail 'searchable GNOME Settings fallback is not installed'
|
|
desktop-file-validate "$gnome_desktop_file" >/dev/null || fail 'GNOME Settings fallback entry is invalid'
|
|
intelligence_desktop_file="$HOME/.local/share/applications/panama-screen-intelligence.desktop"
|
|
[[ -L "$intelligence_desktop_file" || -f "$intelligence_desktop_file" ]] || fail 'Screen Intelligence desktop entry is not installed'
|
|
desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen Intelligence desktop entry is invalid'
|
|
|
|
trap - EXIT
|
|
cleanup || fail 'runtime harness did not stop without disturbing production'
|
|
[[ ! -e "$state_home" ]] || fail 'temporary Settings state was not removed after shell exit'
|
|
production_pids="$(cut -d'|' -f1 <<<"$production_before" | paste -sd, -)"
|
|
[[ -n "$production_pids" ]] || production_pids="none"
|
|
printf 'settings pages contract: PASS (production PIDs preserved: %s)\n' "$production_pids"
|