diff --git a/config/dot/quickshell/config/HomePreferences.qml b/config/dot/quickshell/config/HomePreferences.qml new file mode 100644 index 0000000..fba3f10 --- /dev/null +++ b/config/dot/quickshell/config/HomePreferences.qml @@ -0,0 +1,168 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property alias initialized: values.initialized + property alias favorites: values.favorites + property string saveError: "" + + FileView { + id: preferencesFile + + path: Quickshell.stateDir + "/panama-home.json" + blockLoading: true + printErrors: false + atomicWrites: true + onSaved: root.saveError = "" + onSaveFailed: error => root.saveError = "Could not save Home favourites." + + JsonAdapter { + id: values + + property bool initialized: false + property var favorites: [] + } + } + + Connections { + target: values + function onInitializedChanged(): void { persistTimer.restart(); } + function onFavoritesChanged(): void { persistTimer.restart(); } + } + + Component.onCompleted: preferencesFile.reload() + + Timer { + id: persistTimer + interval: 180 + repeat: false + onTriggered: preferencesFile.writeAdapter() + } + + function cloneFavorites(): var { + var clone = []; + for (var index = 0; index < values.favorites.length; index++) { + var favorite = values.favorites[index]; + clone.push({ + id: favorite.id, + alias: favorite.alias + }); + } + return clone; + } + + function isValidEntityId(entityId: var): bool { + return typeof entityId === "string" && /^light\.[a-z0-9_]+$/.test(entityId); + } + + function initialize(legacyIds: var): void { + if (values.initialized) { + return; + } + + var seededFavorites = []; + var seen = {}; + if (legacyIds && typeof legacyIds.length === "number") { + for (var index = 0; index < legacyIds.length; index++) { + var entityId = legacyIds[index]; + if (!isValidEntityId(entityId) || seen[entityId]) { + continue; + } + seen[entityId] = true; + seededFavorites.push({ id: entityId, alias: "" }); + } + } + + values.favorites = seededFavorites; + values.initialized = true; + } + + function isSelected(entityId: string): bool { + for (var index = 0; index < values.favorites.length; index++) { + if (values.favorites[index].id === entityId) { + return true; + } + } + return false; + } + + function aliasFor(entityId: string, sourceName: string): string { + for (var index = 0; index < values.favorites.length; index++) { + var favorite = values.favorites[index]; + if (favorite.id === entityId && favorite.alias !== "") { + return favorite.alias; + } + } + return sourceName; + } + + function add(entityId: string): void { + if (!isValidEntityId(entityId) || isSelected(entityId)) { + return; + } + + var nextFavorites = cloneFavorites(); + nextFavorites.push({ id: entityId, alias: "" }); + values.favorites = nextFavorites; + } + + function remove(entityId: string): void { + var nextFavorites = []; + var removed = false; + for (var index = 0; index < values.favorites.length; index++) { + var favorite = values.favorites[index]; + if (favorite.id === entityId) { + removed = true; + continue; + } + nextFavorites.push({ id: favorite.id, alias: favorite.alias }); + } + if (removed) { + values.favorites = nextFavorites; + } + } + + function setAlias(entityId: string, alias: string): void { + var nextFavorites = cloneFavorites(); + var updated = false; + var trimmedAlias = String(alias).trim(); + for (var index = 0; index < nextFavorites.length; index++) { + if (nextFavorites[index].id === entityId) { + nextFavorites[index] = { id: entityId, alias: trimmedAlias }; + updated = true; + break; + } + } + if (updated) { + values.favorites = nextFavorites; + } + } + + function move(entityId: string, targetIndex: int): void { + var nextFavorites = cloneFavorites(); + var currentIndex = -1; + for (var index = 0; index < nextFavorites.length; index++) { + if (nextFavorites[index].id === entityId) { + currentIndex = index; + break; + } + } + if (currentIndex === -1) { + return; + } + + var favorite = nextFavorites.splice(currentIndex, 1)[0]; + var clampedIndex = Math.max(0, Math.min(targetIndex, nextFavorites.length)); + nextFavorites.splice(clampedIndex, 0, favorite); + values.favorites = nextFavorites; + } + + function retrySave(): void { + preferencesFile.writeAdapter(); + } +} diff --git a/config/dot/quickshell/config/qmldir b/config/dot/quickshell/config/qmldir index 584b488..99214d5 100644 --- a/config/dot/quickshell/config/qmldir +++ b/config/dot/quickshell/config/qmldir @@ -1,4 +1,5 @@ module qs.config singleton DesktopPreferences 1.0 DesktopPreferences.qml +singleton HomePreferences 1.0 HomePreferences.qml singleton Settings 1.0 Settings.qml singleton Theme 1.0 Theme.qml diff --git a/config/dot/quickshell/home-preferences-harness.qml b/config/dot/quickshell/home-preferences-harness.qml new file mode 100644 index 0000000..eefbe15 --- /dev/null +++ b/config/dot/quickshell/home-preferences-harness.qml @@ -0,0 +1,25 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config + +ShellRoot { + IpcHandler { + target: "home-pref-test" + + function initialize(idsJson: string): void { HomePreferences.initialize(JSON.parse(idsJson)); } + function add(id: string): void { HomePreferences.add(id); } + function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); } + function move(id: string, index: int): void { HomePreferences.move(id, index); } + function remove(id: string): void { HomePreferences.remove(id); } + function status(): string { + return JSON.stringify({ + initialized: HomePreferences.initialized, + favorites: HomePreferences.favorites, + saveError: HomePreferences.saveError, + stateDir: Quickshell.stateDir + }); + } + } +} diff --git a/tests/quickshell/home-preferences-contract.sh b/tests/quickshell/home-preferences-contract.sh new file mode 100755 index 0000000..4eeedc7 --- /dev/null +++ b/tests/quickshell/home-preferences-contract.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/home-preferences-harness.qml" +state_home="$(mktemp -d /tmp/panama-home-preferences-state.XXXXXX)" + +fail() { + printf 'home preferences contract: %s\n' "$1" >&2 + exit 1 +} + +qs_for_harness() { + XDG_STATE_HOME="$state_home" qs -p "$harness" "$@" +} + +cleanup() { + qs_for_harness kill >/dev/null 2>&1 || true + rm -rf "$state_home" +} +trap cleanup EXIT + +start_harness() { + qs_for_harness --daemonize >/dev/null + for _ in $(seq 1 40); do + if qs_for_harness ipc show 2>/dev/null | rg -q '^target home-pref-test$'; then + # Construct the lazy singleton and let its explicit reload finish + # before issuing mutations through the IPC boundary. + qs_for_harness ipc call home-pref-test status >/dev/null + sleep 0.2 + return + fi + sleep 0.1 + done + fail 'test IPC target did not start' +} + +stop_harness() { + qs_for_harness kill >/dev/null 2>&1 || true + for _ in $(seq 1 40); do + if ! qs_for_harness ipc show >/dev/null 2>&1; then + return + fi + sleep 0.1 + done + fail 'test shell did not stop cleanly' +} + +status_without_state_dir() { + qs_for_harness ipc call home-pref-test status | jq -c 'del(.stateDir)' +} + +wait_for_status() { + local expected="$1" + local actual="" + + for _ in $(seq 1 40); do + actual="$(status_without_state_dir)" + if jq -e --argjson expected "$expected" \ + '.initialized == $expected.initialized and .favorites == $expected.favorites and .saveError == $expected.saveError' \ + <<<"$actual" >/dev/null; then + return + fi + sleep 0.1 + done + fail "unexpected status: $actual" +} + +wait_for_file_content() { + local expected="$1" + + for _ in $(seq 1 40); do + state_file="$(find "$state_home" -name panama-home.json -print -quit)" + if [[ -n "$state_file" ]] \ + && jq -e --argjson expected "$expected" \ + '.initialized == $expected.initialized and .favorites == $expected.favorites' \ + "$state_file" >/dev/null; then + return + fi + sleep 0.1 + done + fail 'preferences file did not contain the complete atomic update' +} + +# A leading JSON whitespace prevents qs from expanding the array into IPC +# positional arguments; JSON.parse() intentionally accepts that whitespace. +initial_ids=' ["light.kitchen","light.hall","light.desk"]' +expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}],"saveError":""}' +expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}' +empty_expected='{"initialized":true,"favorites":[],"saveError":""}' +empty_file='{"initialized":true,"favorites":[]}' + +start_harness +qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null +qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null +qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null +qs_for_harness ipc call home-pref-test remove light.hall >/dev/null +wait_for_status "$expected" +wait_for_file_content "$expected_file" + +stop_harness +start_harness +wait_for_status "$expected" + +qs_for_harness ipc call home-pref-test remove light.desk >/dev/null +qs_for_harness ipc call home-pref-test remove light.kitchen >/dev/null +wait_for_status "$empty_expected" +wait_for_file_content "$empty_file" +stop_harness +start_harness +wait_for_status "$empty_expected" +qs_for_harness ipc call home-pref-test initialize ' ["light.new","light.other"]' >/dev/null +wait_for_status "$empty_expected" + +jq -e '(keys | sort) == ["favorites", "initialized"]' "$state_file" >/dev/null \ + || fail 'preferences file contains keys other than initialized and favorites' +if jq -r '.. | strings' "$state_file" | rg -qi 'token|url|api'; then + fail 'preferences file contains credential-like data' +fi + +trap - EXIT +cleanup +printf 'home preferences contract: PASS\n'