Persist Home accessory preferences
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
module qs.config
|
module qs.config
|
||||||
singleton DesktopPreferences 1.0 DesktopPreferences.qml
|
singleton DesktopPreferences 1.0 DesktopPreferences.qml
|
||||||
|
singleton HomePreferences 1.0 HomePreferences.qml
|
||||||
singleton Settings 1.0 Settings.qml
|
singleton Settings 1.0 Settings.qml
|
||||||
singleton Theme 1.0 Theme.qml
|
singleton Theme 1.0 Theme.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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
@@ -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'
|
||||||
Reference in New Issue
Block a user