From 3213a0989ab5ca111fb2506c6357271e8dd9c26c Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 17 Aug 2026 19:01:42 -0400 Subject: [PATCH] Fix Phone controls runtime accessibility --- .../modules/quicksettings/PhoneControls.qml | 34 ++++++-- .../dot/quickshell/phone-controls-harness.qml | 59 ++++++++++++++ .../fixtures/PhoneControlsKdeConnect.qml | 30 ++++++++ .../fixtures/PhoneControlsSystemSettings.qml | 13 ++++ tests/quickshell/phone-messages-contract.sh | 77 ++++++++++++++++++- 5 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 config/dot/quickshell/phone-controls-harness.qml create mode 100644 tests/quickshell/fixtures/PhoneControlsKdeConnect.qml create mode 100644 tests/quickshell/fixtures/PhoneControlsSystemSettings.qml diff --git a/config/dot/quickshell/modules/quicksettings/PhoneControls.qml b/config/dot/quickshell/modules/quicksettings/PhoneControls.qml index 9e539d3..12e0eaf 100644 --- a/config/dot/quickshell/modules/quicksettings/PhoneControls.qml +++ b/config/dot/quickshell/modules/quicksettings/PhoneControls.qml @@ -138,8 +138,20 @@ Item { color: actionMouse.containsMouse && actionMouse.enabled ? Theme.alpha(Theme.accent, 0.15) : Theme.alpha(Theme.accent, 0.075) + border.width: actionMouse.activeFocus ? 2 : 0 + border.color: Theme.accent opacity: actionMouse.enabled ? 1 : 0.48 + Accessible.role: Accessible.Button + Accessible.name: actionButton.modelData.label + Accessible.description: root.actionAccessibleDescription(actionButton.modelData.id) + Accessible.focusable: actionMouse.enabled + Accessible.focused: actionMouse.activeFocus + Accessible.onPressAction: { + if (actionMouse.enabled) + root.invoke(actionButton.modelData.id); + } + Text { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top @@ -162,11 +174,7 @@ Item { MouseArea { id: actionMouse anchors.fill: parent - enabled: actionButton.modelData.id === "messages" - ? SystemSettings.bluebubblesAvailable - : KdeConnect.phoneReachable - && !KdeConnect.transferActive - && KdeConnect.supports(actionButton.modelData.id) + enabled: root.actionEnabled(actionButton.modelData.id) hoverEnabled: true activeFocusOnTab: enabled cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor @@ -181,7 +189,7 @@ Item { Text { width: parent.width visible: root.phone && !KdeConnect.phoneReachable && root.actionModels.length > 0 - text: "Actions become available when the iPhone reconnects" + text: "KDE Connect actions are unavailable until the iPhone reconnects" color: Theme.fgMuted horizontalAlignment: Text.AlignHCenter font.family: Theme.fontFamily @@ -279,6 +287,20 @@ Item { } } + function actionEnabled(action: string): bool { + if (action === "messages") + return SystemSettings.bluebubblesAvailable; + return KdeConnect.phoneReachable + && !KdeConnect.transferActive + && KdeConnect.supports(action); + } + + function actionAccessibleDescription(action: string): string { + if (action === "messages") + return SystemSettings.bluebubblesAvailable ? "Opens BlueBubbles" : "BlueBubbles is not installed"; + return root.actionEnabled(action) ? "Available through KDE Connect" : "Unavailable through KDE Connect"; + } + function localPath(selectedUrl: url): string { const value = String(selectedUrl); if (!value.startsWith("file://")) diff --git a/config/dot/quickshell/phone-controls-harness.qml b/config/dot/quickshell/phone-controls-harness.qml new file mode 100644 index 0000000..caf9ae4 --- /dev/null +++ b/config/dot/quickshell/phone-controls-harness.qml @@ -0,0 +1,59 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.modules.quicksettings +import qs.services + +// Component-only diagnostic surface. The contract replaces the two services in +// its copied configuration with fixture singletons before this file is run. +ShellRoot { + PhoneControls { + id: phoneControls + width: 408 + } + + IpcHandler { + target: "phone-controls-test" + + function fixture(name: string): void { + KdeConnect.available = false; + KdeConnect.transferActive = false; + KdeConnect.lastError = ""; + + if (name === "offline") { + KdeConnect.devices = [{ + id: "fixture-phone", + name: "Fixture iPhone", + type: "phone", + paired: true, + reachable: false, + actions: [] + }]; + } else if (name === "transfer") { + KdeConnect.available = true; + KdeConnect.transferActive = true; + KdeConnect.devices = [{ + id: "fixture-phone", + name: "Fixture iPhone", + type: "phone", + paired: true, + reachable: true, + actions: ["share", "clipboard", "ring"] + }]; + } + } + + function status(): string { + return JSON.stringify({ + actions: phoneControls.actionModels.map(action => ({ + id: action.id, + enabled: phoneControls.actionEnabled(action.id) + })), + bluebubblesAvailable: SystemSettings.bluebubblesAvailable, + appLaunches: SystemSettings.launchCount, + phoneActions: KdeConnect.actionCount + }); + } + } +} diff --git a/tests/quickshell/fixtures/PhoneControlsKdeConnect.qml b/tests/quickshell/fixtures/PhoneControlsKdeConnect.qml new file mode 100644 index 0000000..a0141d6 --- /dev/null +++ b/tests/quickshell/fixtures/PhoneControlsKdeConnect.qml @@ -0,0 +1,30 @@ +pragma Singleton + +import QtQuick + +QtObject { + id: root + + property bool available: false + property var devices: [] + property bool transferActive: false + property string lastError: "" + property var recentExchange: null + property int actionCount: 0 + + readonly property var preferredPhone: { + const phones = root.devices.filter(device => device.type === "phone" && device.paired); + return phones.find(device => device.reachable) ?? phones[0] ?? null; + } + readonly property bool phoneReachable: root.preferredPhone?.reachable === true + readonly property var phoneActions: root.preferredPhone?.actions ?? [] + + function supports(action: string): bool { + return root.phoneActions.indexOf(action) >= 0; + } + + function refresh(): void {} + function sendFile(path: string): void { root.actionCount += 1; } + function sendClipboard(): void { root.actionCount += 1; } + function ring(): void { root.actionCount += 1; } +} diff --git a/tests/quickshell/fixtures/PhoneControlsSystemSettings.qml b/tests/quickshell/fixtures/PhoneControlsSystemSettings.qml new file mode 100644 index 0000000..2a57d07 --- /dev/null +++ b/tests/quickshell/fixtures/PhoneControlsSystemSettings.qml @@ -0,0 +1,13 @@ +pragma Singleton + +import QtQuick + +QtObject { + property bool bluebubblesAvailable: true + property int launchCount: 0 + + function openApplication(id: string): bool { + launchCount += 1; + return false; + } +} diff --git a/tests/quickshell/phone-messages-contract.sh b/tests/quickshell/phone-messages-contract.sh index 5c7f8fc..b755493 100755 --- a/tests/quickshell/phone-messages-contract.sh +++ b/tests/quickshell/phone-messages-contract.sh @@ -5,6 +5,11 @@ set -euo pipefail project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" phone_controls="$project_root/config/dot/quickshell/modules/quicksettings/PhoneControls.qml" system_settings="$project_root/config/dot/quickshell/services/SystemSettings.qml" +harness="$project_root/config/dot/quickshell/phone-controls-harness.qml" +kde_fixture="$project_root/tests/quickshell/fixtures/PhoneControlsKdeConnect.qml" +settings_fixture="$project_root/tests/quickshell/fixtures/PhoneControlsSystemSettings.qml" +state_home="$(mktemp -d /tmp/panama-phone-controls.XXXXXX)" +config_path="$state_home/quickshell" fail() { printf 'Phone Messages contract: %s\n' "$1" >&2 @@ -13,9 +18,55 @@ fail() { [[ -f "$phone_controls" ]] || fail 'Phone controls component is missing' [[ -f "$system_settings" ]] || fail 'System settings service is missing' +[[ -f "$harness" ]] || fail 'Phone controls runtime harness is missing' +[[ -f "$kde_fixture" ]] || fail 'Phone controls KDE fixture is missing' +[[ -f "$settings_fixture" ]] || fail 'Phone controls System Settings fixture is missing' -# This is a static contract only: it starts no shell, sends no IPC, and never -# evaluates the launch branch. Its only Flatpak use is source-text inspection. +cleanup() { + XDG_STATE_HOME="$state_home" QS_DISABLE_CRASH_HANDLER=1 \ + qs -p "$config_path/phone-controls-harness.qml" kill >/dev/null 2>&1 || true + rm -rf "$state_home" +} +trap cleanup EXIT + +qs_for_harness() { + XDG_STATE_HOME="$state_home" QS_DISABLE_CRASH_HANDLER=1 \ + qs -p "$config_path/phone-controls-harness.qml" "$@" +} + +start_harness() { + cp -a "$project_root/config/dot/quickshell" "$config_path" + cp "$kde_fixture" "$config_path/services/KdeConnect.qml" + cp "$settings_fixture" "$config_path/services/SystemSettings.qml" + qs_for_harness --daemonize >/dev/null + for _ in $(seq 1 40); do + if qs_for_harness ipc show 2>/dev/null | rg -q '^target phone-controls-test$'; then + return + fi + sleep 0.1 + done + fail 'isolated Phone controls harness did not start' +} + +assert_fixture_state() { + local fixture="$1" + local expected="$2" + + qs_for_harness ipc call phone-controls-test fixture "$fixture" >/dev/null + local actual + actual="$(qs_for_harness ipc call phone-controls-test status)" + [[ -n "$actual" ]] || fail 'Phone controls do not expose runtime action enablement' + jq -e --argjson expected "$expected" ' + .bluebubblesAvailable == true and .appLaunches == 0 and .phoneActions == 0 and + (.actions | length == 4) and + ([.actions[] | select(.id == "messages") | .enabled] == [true]) and + ([.actions[] | select(.id != "messages") | .enabled] == $expected) + ' <<<"$actual" >/dev/null \ + || fail "unexpected $fixture action state: $actual" +} + +# These source checks do not evaluate the launch branch. The isolated runtime +# harness below uses fixture singletons and only reads action-model status. rg -Fq '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings" \ || fail 'BlueBubbles does not use the fixed Flatpak argument vector' @@ -40,7 +91,9 @@ rg -Fq 'SystemSettings.bluebubblesAvailable' "$phone_controls" \ || fail 'Messages enablement does not read BlueBubbles availability' rg -Fq 'KdeConnect.phoneReachable' "$phone_controls" \ || fail 'KDE action reachability semantics are missing' -rg -Fq 'KdeConnect.supports(actionButton.modelData.id)' "$phone_controls" \ +rg -Fq 'function actionEnabled(action: string): bool' "$phone_controls" \ + || fail 'Phone controls do not expose action enablement' +rg -Fq 'KdeConnect.supports(action)' "$phone_controls" \ || fail 'KDE action capability semantics are missing' rg -Fq 'else if (action === "messages")' "$phone_controls" \ || fail 'Messages has no independent invocation branch' @@ -48,6 +101,20 @@ rg -Fq 'SystemSettings.openApplication("bluebubbles")' "$phone_controls" \ || fail 'Messages does not use the allow-listed BlueBubbles launcher' rg -Fq 'BlueBubbles is not installed' "$phone_controls" \ || fail 'Missing BlueBubbles has no quiet explanatory row' +rg -Fq 'KDE Connect actions are unavailable until the iPhone reconnects' "$phone_controls" \ + || fail 'Offline copy does not scope unavailability to KDE Connect actions' +rg -Fq 'border.width: actionMouse.activeFocus ? 2 : 0' "$phone_controls" \ + || fail 'Phone actions do not visibly indicate keyboard focus' +rg -Fq 'Accessible.role: Accessible.Button' "$phone_controls" \ + || fail 'Phone actions do not expose an Accessible button role' +rg -Fq 'Accessible.name: actionButton.modelData.label' "$phone_controls" \ + || fail 'Phone actions do not expose their accessible name' +rg -Fq 'Accessible.description: root.actionAccessibleDescription(actionButton.modelData.id)' "$phone_controls" \ + || fail 'Phone actions do not expose their availability state' +rg -Fq 'Accessible.focusable: actionMouse.enabled' "$phone_controls" \ + || fail 'Phone action accessibility does not respect disabled state' +rg -Fq 'Accessible.onPressAction:' "$phone_controls" \ + || fail 'Phone actions do not provide an accessible press handler' # A static negative guard keeps the Messages branch from accidentally inheriting # KDE Connect reachability, transfer, or plugin conditions. @@ -55,4 +122,8 @@ messages_branch="$(sed -n '/else if (action === "messages")/,/^ }/p' "$phone_ printf '%s\n' "$messages_branch" | rg -Fq 'KdeConnect.' \ && fail 'Messages is coupled to KDE Connect' +start_harness +assert_fixture_state offline '[false, false, false]' +assert_fixture_state transfer '[false, false, false]' + printf 'Phone Messages contract: PASS\n'