diff --git a/config/dot/quickshell/modules/settings/HomePhonePage.qml b/config/dot/quickshell/modules/settings/HomePhonePage.qml index a692298..39fab71 100644 --- a/config/dot/quickshell/modules/settings/HomePhonePage.qml +++ b/config/dot/quickshell/modules/settings/HomePhonePage.qml @@ -211,16 +211,28 @@ SettingsPage { spacing: 8 SettingsButton { + id: clearTokenButton text: "Clear token" enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) onClicked: HomeAssistantConfig.clearToken() + Keys.onReturnPressed: if (enabled) HomeAssistantConfig.clearToken() + Keys.onSpacePressed: if (enabled) HomeAssistantConfig.clearToken() } SettingsButton { + id: saveHomeConfigButton text: HomeAssistantConfig.busy ? "Saving…" : "Save" tone: "accent" enabled: !HomeAssistantConfig.busy + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 0 + border.color: activeFocus ? Theme.fg : "transparent" onClicked: root.saveHomeAssistantConfig() + Keys.onReturnPressed: if (enabled) root.saveHomeAssistantConfig() + Keys.onSpacePressed: if (enabled) root.saveHomeAssistantConfig() } } } diff --git a/config/dot/quickshell/modules/settings/PasswordField.qml b/config/dot/quickshell/modules/settings/PasswordField.qml index 2382fd6..7a0dd61 100644 --- a/config/dot/quickshell/modules/settings/PasswordField.qml +++ b/config/dot/quickshell/modules/settings/PasswordField.qml @@ -48,6 +48,7 @@ Rectangle { anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter + activeFocusOnTab: true color: Theme.fg selectionColor: Theme.alpha(Theme.accent, 0.5) selectedTextColor: Theme.fg diff --git a/config/dot/quickshell/services/HomeAssistantConfig.qml b/config/dot/quickshell/services/HomeAssistantConfig.qml index b1f191b..ca016f9 100644 --- a/config/dot/quickshell/services/HomeAssistantConfig.qml +++ b/config/dot/quickshell/services/HomeAssistantConfig.qml @@ -18,6 +18,7 @@ Singleton { property bool configured: false property string lastError: "" property string pendingPayload: "" + property var refreshHomeAssistant: function() { HomeAssistant.refresh(); } readonly property bool busy: statusProc.running || writeProc.running signal configurationSaved @@ -52,7 +53,7 @@ Singleton { root.lastError = ""; if (saved) { root.configurationSaved(); - HomeAssistant.refresh(); + root.refreshHomeAssistant(); } } @@ -102,7 +103,6 @@ Singleton { command: [root.helperPath, "write"] stdinEnabled: true stdout: StdioCollector { - id: writeOutput onStreamFinished: root.applyResult(this.text, true) } onStarted: { diff --git a/tests/quickshell/HomeAssistantConfigHarness.qml b/tests/quickshell/HomeAssistantConfigHarness.qml index 54d09a8..42ca5b9 100644 --- a/tests/quickshell/HomeAssistantConfigHarness.qml +++ b/tests/quickshell/HomeAssistantConfigHarness.qml @@ -5,7 +5,21 @@ import QtQuick import qs.services ShellRoot { - Component.onCompleted: HomeAssistant.fixtureMode = true + id: root + + property int refreshCalls: 0 + + FileView { + id: tokenFile + path: Quickshell.env("PANAMA_TEST_TOKEN_FILE") + blockLoading: true + printErrors: false + } + + Component.onCompleted: { + HomeAssistant.fixtureMode = true; + HomeAssistantConfig.refreshHomeAssistant = function() { root.refreshCalls++; }; + } IpcHandler { target: "home-assistant-config-test" @@ -14,6 +28,10 @@ ShellRoot { return HomeAssistantConfig.save(url, entities, ""); } + function saveWithToken(url: string, entities: string): bool { + return HomeAssistantConfig.save(url, entities, tokenFile.text()); + } + function clearToken(): bool { return HomeAssistantConfig.clearToken(); } @@ -30,7 +48,8 @@ ShellRoot { configured: HomeAssistantConfig.configured, busy: HomeAssistantConfig.busy, lastError: HomeAssistantConfig.lastError, - pendingPayloadEmpty: HomeAssistantConfig.pendingPayload === "" + pendingPayloadEmpty: HomeAssistantConfig.pendingPayload === "", + refreshCalls: root.refreshCalls }); } } diff --git a/tests/quickshell/home-assistant-config-contract.sh b/tests/quickshell/home-assistant-config-contract.sh index 6e9c387..b520dd5 100755 --- a/tests/quickshell/home-assistant-config-contract.sh +++ b/tests/quickshell/home-assistant-config-contract.sh @@ -6,6 +6,7 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config" service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml" page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml" +password_field="$repo_dir/config/dot/quickshell/modules/settings/PasswordField.qml" harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml" work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)" env_file="$work/env" @@ -34,8 +35,13 @@ if rg -q 'command:.*(token|pendingPayload)' "$service"; then fail 'credential data can reach a process command line' fi rg -Fq 'PasswordField {' "$page" || fail 'Home Assistant token is not entered through the masked field' +rg -Fq 'activeFocusOnTab: true' "$password_field" || fail 'masked credential field is not keyboard reachable' rg -Fq 'HomeAssistantConfig.save(' "$page" || fail 'Home Assistant configuration cannot be saved from Settings' rg -Fq 'HomeAssistantConfig.clearToken()' "$page" || fail 'stored Home Assistant token cannot be cleared' +rg -Fq 'id: clearTokenButton' "$page" || fail 'clear-token action has no keyboard control identity' +rg -Fq 'id: saveHomeConfigButton' "$page" || fail 'save action has no keyboard control identity' +rg -Fq 'activeFocusOnTab: enabled' "$page" || fail 'credential actions are not in tab order' +rg -Fq 'Keys.onReturnPressed:' "$page" || fail 'credential actions have no keyboard activation' cat >"$env_file" <<'EOF' # Existing private shell settings must survive byte-for-byte. @@ -124,12 +130,16 @@ cp -a "$repo_dir/config/dot/quickshell" "$config_path" cp "$harness_fixture" "$harness" printf '%s\n' \ "export PANAMA_HOME_ASSISTANT_URL='https://qml-old.example.test'" \ - "export PANAMA_HOME_ASSISTANT_TOKEN='qml-private-token'" \ + "export PANAMA_HOME_ASSISTANT_TOKEN=''" \ "export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'" >"$env_file" chmod 0600 "$env_file" +qml_token_file="$work/qml-token" +printf '%s' 'qml-private-token' >"$qml_token_file" +chmod 0600 "$qml_token_file" qs_for_test() { PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" \ + PANAMA_TEST_TOKEN_FILE="$qml_token_file" \ XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \ qs -p "$harness" "$@" } @@ -151,16 +161,35 @@ for _ in $(seq 1 60); do jq -e '.busy == false and .url == "https://qml-old.example.test"' <<<"$qml_status" >/dev/null && break sleep 0.1 done -jq -e '.configured == true and .tokenConfigured == true and .pendingPayloadEmpty == true' \ +jq -e '.configured == false and .tokenConfigured == false and .pendingPayloadEmpty == true' \ <<<"$qml_status" >/dev/null || fail "QML service did not load redacted state: $qml_status" -qs_for_test ipc call home-assistant-config-test save \ +qs_for_test ipc call home-assistant-config-test saveWithToken \ https://qml-new.example.test 'light.office,light.hall' >/dev/null \ + || fail 'QML service refused a private token-file update' +for _ in $(seq 1 60); do + qml_status="$(qs_for_test ipc call home-assistant-config-test status)" + jq -e '.busy == false and .configured == true and .tokenConfigured == true + and .refreshCalls > 0' <<<"$qml_status" >/dev/null && break + sleep 0.1 +done +jq -e '.configured == true and .tokenConfigured == true + and .url == "https://qml-new.example.test" + and .entities == ["light.office", "light.hall"] + and .pendingPayloadEmpty == true and .lastError == "" and .refreshCalls > 0' \ + <<<"$qml_status" >/dev/null || fail "QML secret stdin save did not settle safely: $qml_status" +rg -Fq 'qml-private-token' "$env_file" || fail 'QML secret stdin save did not store the token' +if ps -o args= -p "$(qs_for_test list | awk '/Process ID:/ {print $3; exit}')" | rg -Fq 'qml-private-token'; then + fail 'QML token appeared in the shell process arguments' +fi + +qs_for_test ipc call home-assistant-config-test save \ + https://qml-final.example.test 'light.bedroom,light.hall' >/dev/null \ || fail 'QML service refused a non-secret update' for _ in $(seq 1 60); do qml_status="$(qs_for_test ipc call home-assistant-config-test status)" - jq -e '.busy == false and .url == "https://qml-new.example.test" - and .entities == ["light.office", "light.hall"]' <<<"$qml_status" >/dev/null && break + jq -e '.busy == false and .url == "https://qml-final.example.test" + and .entities == ["light.bedroom", "light.hall"]' <<<"$qml_status" >/dev/null && break sleep 0.1 done jq -e '.configured == true and .tokenConfigured == true