From 09e5f1ad6b1bdbef5cf0d94e5e60ed0a1a5c88fc Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 08:15:01 -0400 Subject: [PATCH 01/20] Fix external monitor brightness OSD --- config/dot/quickshell/scripts/panama-osd | 147 ++++++++++++++++++++- tests/quickshell/osd-helper-contract.sh | 159 ++++++++++++++++++++++- 2 files changed, 299 insertions(+), 7 deletions(-) diff --git a/config/dot/quickshell/scripts/panama-osd b/config/dot/quickshell/scripts/panama-osd index 001aead..916c8db 100755 --- a/config/dot/quickshell/scripts/panama-osd +++ b/config/dot/quickshell/scripts/panama-osd @@ -2,6 +2,8 @@ set -u +readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + strict_delivery() { [[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]] } @@ -67,18 +69,151 @@ adjust_microphone() { show_volume "$target" microphone } +brightness_percent() { + local output="$1" percent + percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")" + [[ $percent =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$percent" +} + +brightness_error() { + local detail="$1" label="External brightness unavailable" + if [[ $detail == *udev* || $detail == *accessible* || $detail == *permission* ]]; then + label="Brightness needs permission" + fi + + show_message dialog-warning-symbolic "$label" || true + if command -v notify-send >/dev/null 2>&1; then + notify-send --app-name=Panama --icon=display-brightness-symbolic \ + "Brightness unavailable" "$detail" >/dev/null 2>&1 || true + fi +} + +discover_ddc_bus() { + local helper="$1" cache_file="$2" list_json focused selected error bus connector + + command -v jq >/dev/null 2>&1 || { + brightness_error "jq is required to discover DDC/CI displays." + return 1 + } + + list_json="$("$helper" list 2>/dev/null)" || { + brightness_error "The external brightness helper could not inspect connected displays." + return 1 + } + if ! jq -e 'type == "object" and (.displays | type == "array")' >/dev/null 2>&1 <<<"$list_json"; then + brightness_error "The external brightness helper returned invalid display information." + return 1 + fi + + error="$(jq -r '.error // empty' <<<"$list_json")" + if [[ -n $error ]]; then + brightness_error "$error" + return 1 + fi + + focused="$(hyprctl -j monitors 2>/dev/null \ + | jq -r '.[] | select(.focused == true) | .name' 2>/dev/null \ + | head -n1)" + selected="$(jq -r --arg connector "$focused" ' + ([.displays[] | select(.connector == $connector)][0] // .displays[0] // empty) + | [.bus, .connector] + | @tsv + ' <<<"$list_json")" + IFS=$'\t' read -r bus connector <<<"$selected" + if [[ ! $bus =~ ^[0-9]+$ ]]; then + brightness_error "No connected monitor exposes DDC/CI brightness control." + return 1 + fi + + umask 077 + printf '%s\t%s\n' "$bus" "$connector" >"$cache_file" + printf '%s\n' "$bus" +} + +adjust_ddc_brightness() { + local action="$1" step="$2" + local helper="${PANAMA_OSD_BRIGHTNESS_HELPER:-$PANAMA_OSD_SCRIPT_DIR/panama-brightness}" + local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}" + local cache_file="$runtime_dir/brightness-bus" lock_file="$runtime_dir/brightness.lock" + local bus="" connector="" current target lock_fd + + [[ -x $helper ]] || { + brightness_error "The external brightness helper is not installed." + return 0 + } + mkdir -p "$runtime_dir" || return 0 + chmod 700 "$runtime_dir" 2>/dev/null || true + + exec {lock_fd}>"$lock_file" || return 0 + # DDC transactions on one I2C bus cannot safely overlap. A short wait also + # sheds an excessive key-repeat backlog instead of replaying it seconds later. + flock -w 2 "$lock_fd" || return 0 + + if [[ -r $cache_file ]]; then + IFS=$'\t' read -r bus connector <"$cache_file" || true + [[ $bus =~ ^[0-9]+$ ]] || bus="" + fi + + if [[ -n $bus ]]; then + current="$("$helper" get "$bus" 2>/dev/null)" || current="" + if [[ ! $current =~ ^[0-9]+$ ]]; then + : >"$cache_file" + bus="" + fi + fi + + if [[ -z $bus ]]; then + bus="$(discover_ddc_bus "$helper" "$cache_file")" || return 0 + current="$("$helper" get "$bus" 2>/dev/null)" || current="" + fi + if [[ ! $current =~ ^[0-9]+$ ]]; then + brightness_error "The selected monitor stopped responding over DDC/CI." + return 0 + fi + + if [[ $action == up ]]; then + target=$(( current + step )) + else + target=$(( current - step )) + fi + (( target > 100 )) && target=100 + (( target < 0 )) && target=0 + + if ! "$helper" set "$bus" "$target" >/dev/null 2>&1; then + brightness_error "The selected monitor did not accept the brightness change." + return 0 + fi + show_progress brightness "$target" "${target}%" +} + adjust_brightness() { local action="${1:-}" step="${2:-5}" output percent + [[ $step =~ ^[0-9]+$ ]] || { + printf 'Usage: panama-osd brightness up|down [step]\n' >&2 + return 2 + } case "$action" in - up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;; - down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;; + up|down) ;; *) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;; esac - output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0 - percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")" - [[ $percent =~ ^[0-9]+$ ]] || return 0 - show_progress brightness "$percent" "${percent}%" + # Laptop panels expose a kernel backlight class and remain the fastest, + # most reliable path. Desktops fall through to DDC/CI monitor control. + output="$(brightnessctl -m -c backlight 2>/dev/null)" || output="" + if percent="$(brightness_percent "$output")"; then + if [[ $action == up ]]; then + brightnessctl -e4 -n2 -c backlight set "${step}%+" >/dev/null || return 0 + else + brightnessctl -e4 -n2 -c backlight set "${step}%-" >/dev/null || return 0 + fi + output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0 + percent="$(brightness_percent "$output")" || return 0 + show_progress brightness "$percent" "${percent}%" + return + fi + + adjust_ddc_brightness "$action" "$step" } media_action() { diff --git a/tests/quickshell/osd-helper-contract.sh b/tests/quickshell/osd-helper-contract.sh index 328a610..752cf94 100755 --- a/tests/quickshell/osd-helper-contract.sh +++ b/tests/quickshell/osd-helper-contract.sh @@ -26,10 +26,62 @@ printf 'brightnessctl' >>"$OSD_TEST_LOG" printf ' <%s>' "$@" >>"$OSD_TEST_LOG" printf '\n' >>"$OSD_TEST_LOG" if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then + [[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1 printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}" fi SH +cat >"$scratch/bin/panama-brightness" <<'SH' +#!/bin/bash +printf 'panama-brightness' >>"$OSD_TEST_LOG" +printf ' <%s>' "$@" >>"$OSD_TEST_LOG" +printf '\n' >>"$OSD_TEST_LOG" + +case "${1:-}" in + list) + if [[ -n ${DDC_LIST_JSON:-} ]]; then + printf '%s\n' "$DDC_LIST_JSON" + else + printf '%s\n' '{"displays":[],"error":"No displays"}' + fi + ;; + get) + [[ ${DDC_FAIL_GET_BUS:-} != "${2:-}" ]] || exit 1 + if [[ -s $OSD_DDC_STATE ]]; then + cat "$OSD_DDC_STATE" + else + printf '%s\n' "${DDC_GET_VALUE:-40}" + fi + ;; + set) + if [[ -n ${DDC_SET_DELAY:-} ]]; then + if ! mkdir "$OSD_DDC_PROBE" 2>/dev/null; then + printf 'ddc-overlap\n' >>"$OSD_TEST_LOG" + fi + sleep "$DDC_SET_DELAY" + rmdir "$OSD_DDC_PROBE" 2>/dev/null || true + fi + printf '%s\n' "${3:-0}" >"$OSD_DDC_STATE" + ;; + *) exit 2 ;; +esac +SH + +cat >"$scratch/bin/hyprctl" <<'SH' +#!/bin/bash +printf 'hyprctl' >>"$OSD_TEST_LOG" +printf ' <%s>' "$@" >>"$OSD_TEST_LOG" +printf '\n' >>"$OSD_TEST_LOG" +printf '[{"name":"%s","focused":true}]\n' "${FOCUSED_MONITOR:-DP-2}" +SH + +cat >"$scratch/bin/notify-send" <<'SH' +#!/bin/bash +printf 'notify-send' >>"$OSD_TEST_LOG" +printf ' <%s>' "$@" >>"$OSD_TEST_LOG" +printf '\n' >>"$OSD_TEST_LOG" +SH + cat >"$scratch/bin/playerctl" <<'SH' #!/bin/bash printf 'playerctl' >>"$OSD_TEST_LOG" @@ -53,9 +105,21 @@ SH chmod +x "$scratch/bin/"* run_helper() { + local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}" + mkdir -p "$runtime" PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \ OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \ PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \ + PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \ + PANAMA_OSD_RUNTIME_DIR="$runtime" \ + OSD_DDC_STATE="$runtime/ddc-state" \ + OSD_DDC_PROBE="$runtime/ddc-probe" \ + BACKLIGHT_AVAILABLE="${BACKLIGHT_AVAILABLE:-true}" \ + DDC_LIST_JSON="${DDC_LIST_JSON:-}" \ + DDC_GET_VALUE="${DDC_GET_VALUE:-40}" \ + DDC_FAIL_GET_BUS="${DDC_FAIL_GET_BUS:-}" \ + DDC_SET_DELAY="${DDC_SET_DELAY:-}" \ + FOCUSED_MONITOR="${FOCUSED_MONITOR:-DP-2}" \ "$helper" "$@" } @@ -86,9 +150,102 @@ assert_line 'qs <72> <100> "$log" run_helper brightness up 5 -assert_line 'brightnessctl <-e4> <-n2> <5%+>' assert_line 'brightnessctl <-m> <-c> ' +assert_line 'brightnessctl <-e4> <-n2> <-c> <5%+>' assert_line 'qs <50> <100> <50%>' +if grep -Fq 'panama-brightness' "$log"; then + printf 'osd helper contract: DDC fallback ran despite a native backlight\n' >&2 + exit 1 +fi + +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc" \ +BACKLIGHT_AVAILABLE=false \ +DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \ +run_helper brightness up 5 +assert_line 'hyprctl <-j> ' +assert_line 'panama-brightness ' +assert_line 'panama-brightness <5>' +assert_line 'panama-brightness <5> <45>' +assert_line 'qs <45> <100> <45%>' + +# A cached bus avoids the expensive display scan on subsequent key presses. +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc" \ +BACKLIGHT_AVAILABLE=false \ +DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":45}],"error":""}' \ +run_helper brightness down 5 +assert_line 'panama-brightness <5>' +assert_line 'panama-brightness <5> <40>' +assert_line 'qs <40> <100> <40%>' +if grep -Fq 'panama-brightness ' "$log"; then + printf 'osd helper contract: cached DDC bus triggered another display scan\n' >&2 + exit 1 +fi + +# A disconnected cached monitor is discarded and rediscovered once. +mkdir -p "$scratch/runtime-ddc-stale" +printf '9\tDP-9\n' >"$scratch/runtime-ddc-stale/brightness-bus" +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc-stale" \ +BACKLIGHT_AVAILABLE=false \ +DDC_FAIL_GET_BUS=9 \ +DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \ +run_helper brightness up 5 +assert_line 'panama-brightness <9>' +assert_line 'panama-brightness ' +assert_line 'panama-brightness <5>' +assert_line 'panama-brightness <5> <45>' + +# If the focused output is not DDC-capable, use the first discovered display. +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc-first" \ +BACKLIGHT_AVAILABLE=false \ +FOCUSED_MONITOR='eDP-1' \ +DDC_GET_VALUE=35 \ +DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \ +run_helper brightness down 10 +assert_line 'panama-brightness <3>' +assert_line 'panama-brightness <3> <25>' +assert_line 'qs <25> <100> <25%>' + +# Permission and discovery errors must be visible, never masquerade as 0%. +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc-error" \ +BACKLIGHT_AVAILABLE=false \ +DDC_LIST_JSON='{"displays":[],"error":"Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm"}' \ +run_helper brightness up 5 +assert_line 'qs ' +assert_line 'notify-send <--app-name=Panama> <--icon=display-brightness-symbolic> ' +if grep -Fq 'osd> ' "$log"; then + printf 'osd helper contract: unavailable brightness rendered a false percentage\n' >&2 + exit 1 +fi + +# Separate key-repeat processes must not overlap their DDC transactions. +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \ +BACKLIGHT_AVAILABLE=false \ +DDC_SET_DELAY=0.15 \ +DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \ +run_helper brightness up 5 & +first_pid=$! +OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \ +BACKLIGHT_AVAILABLE=false \ +DDC_SET_DELAY=0.15 \ +DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \ +run_helper brightness up 5 & +second_pid=$! +wait "$first_pid" +wait "$second_pid" +if grep -Fqx 'ddc-overlap' "$log"; then + printf 'osd helper contract: concurrent DDC transactions overlapped\n' >&2 + exit 1 +fi +if [[ $(<"$scratch/runtime-ddc-lock/ddc-state") != 50 ]]; then + printf 'osd helper contract: serialized key repeats did not both apply\n' >&2 + exit 1 +fi : >"$log" run_helper media next From 9ce7040b91e611c4d14eb3707ad13e7b4f8fe5b6 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 08:16:10 -0400 Subject: [PATCH 02/20] Add a Mouse & Touchpad page and make keyboard layout editable Working towards parity with GNOME Settings, which splits pointing devices into their own panel. Panama had pointer speed and focus-follows buried under a page called "Input & Shortcuts", and had nothing at all for scroll direction, acceleration profile, scroll speed, left-handed buttons, or any touchpad setting -- all of which could only be changed by editing hypr/input.lua by hand, which is the thing this app exists to stop. Every new mapping was read back off the running compositor rather than assumed, and two were not what they look like: touchpad drag lock is an int with three states, not a switch, and scroll factors are floats even at their default of exactly 1. Getting either wrong makes every write to that setting look rejected. The shape contract now covers 35 mapped options, up from 23. The touchpad card renders only when a touchpad is attached, which is what InputDevices is for. On a desktop it would be worse than useless: every switch on it would appear to work, because the preference is stored and Hyprland accepts an option for a device class it has no member of, so the settings would silently affect nothing. Keyboard layout was read-only text, justified by a note saying changes needed a compositor reload. That is not true in 0.56.2 -- setting input:kb_variant through hl.config re-keymaps attached keyboards immediately, verified by watching active_keymap on a real keyboard change to "English (US, intl., with dead keys)" and back. So layout, variant, and options are now real controls, joined by a TextEntryRow that commits on Enter or focus loss rather than per keystroke, since half a layout name is a valid string meaning something else. Rejected input is shown as rejected rather than sanitised: these strings are serialised into an hl.config payload, where stripping an unexpected character would turn a typo into a different working setting. Verified each new pointer option applies and reverts against the live compositor. Schema, search, commit/reset, and system contracts pass. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../quickshell/config/PreferenceSchema.qml | 105 +++++++++++++++++ .../quickshell/modules/settings/MousePage.qml | 57 +++++++++ .../modules/settings/SettingsShell.qml | 2 + .../modules/settings/SettingsSidebar.qml | 3 +- .../modules/settings/ShortcutsPage.qml | 21 ++-- .../modules/settings/TextEntryRow.qml | 109 ++++++++++++++++++ config/dot/quickshell/modules/settings/qmldir | 2 + .../dot/quickshell/services/InputDevices.qml | 66 +++++++++++ .../quickshell/services/SettingsSearch.qml | 2 + config/dot/quickshell/services/ShellState.qml | 2 +- 10 files changed, 354 insertions(+), 15 deletions(-) create mode 100644 config/dot/quickshell/modules/settings/MousePage.qml create mode 100644 config/dot/quickshell/modules/settings/TextEntryRow.qml create mode 100644 config/dot/quickshell/services/InputDevices.qml diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index c5d89ed..4772613 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -257,6 +257,24 @@ Singleton { detail: "XKB layout name, or a comma-separated list to switch between", hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" } }, + { + key: "keyboardVariant", type: "string", def: "", group: "input", + // Same shape as the layout list and for the same reason: this is + // serialised into an hl.config string. + pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$", + label: "Layout variant", + detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout", + hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" } + }, + { + key: "keyboardOptions", type: "string", def: "", group: "input", + // XKB option names are colon-separated pairs in a comma-separated + // list, e.g. "compose:ralt,caps:escape". + pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$", + label: "Keyboard options", + detail: "XKB options, such as compose:ralt to make right Alt a compose key", + hypr: { path: ["input", "kb_options"], option: "input:kb_options", readAs: "str" } + }, { key: "numlockByDefault", type: "bool", def: true, group: "input", label: "Num Lock on login", @@ -310,6 +328,93 @@ Singleton { hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" } }, + // ── Pointer ───────────────────────────────────────────────────────── + // + // Every `readAs` below was read back off the running compositor rather + // than guessed. Two are not what they look like: touchpad drag lock is + // an int with three states, not a switch, and scroll factors are floats + // even at their default of exactly 1. + { + key: "naturalScroll", type: "bool", def: false, group: "pointer", + label: "Natural scrolling", + detail: "Content follows the direction of your fingers, as on a phone", + hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" } + }, + { + key: "accelProfile", type: "enum", def: "flat", group: "pointer", + label: "Acceleration", + detail: "Flat moves the pointer the same distance however fast you move", + options: [ + { value: "flat", label: "Flat" }, + { value: "adaptive", label: "Adaptive" } + ], + hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" } + }, + { + key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1, + group: "pointer", + label: "Scroll speed", + detail: "Multiplies how far one notch of the wheel scrolls", + hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" } + }, + { + key: "leftHanded", type: "bool", def: false, group: "pointer", + label: "Left-handed", + detail: "Swap the primary and secondary buttons", + hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" } + }, + + // ── Touchpad ──────────────────────────────────────────────────────── + // + // Shown only on machines that have one. These are separate from the + // pointer settings above because libinput keeps them separate: a mouse + // and a touchpad on the same machine can scroll in opposite directions, + // and usually should. + { + key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad", + label: "Tap to click", + detail: "A tap counts as a click without pressing down", + hypr: { path: ["input", "touchpad", "tap-to-click"], option: "input:touchpad:tap-to-click", readAs: "bool" } + }, + { + key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad", + label: "Natural scrolling", + detail: "Content follows the direction of your fingers", + hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" } + }, + { + key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad", + label: "Disable while typing", + detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer", + hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" } + }, + { + key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1, + group: "touchpad", + label: "Scroll speed", + detail: "Multiplies how far a two-finger scroll travels", + hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" } + }, + { + key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad", + label: "Drag lock", + detail: "Keeps a tap-and-drag active when you lift a finger mid-drag", + // An int with three states rather than a switch, which is why this + // is an enum: reported as `int` by getoption, not `bool`. + options: [ + { value: 0, label: "Off" }, + { value: 1, label: "On" }, + { value: 2, label: "On, until you tap again" } + ], + hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" } + }, + { + key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad", + label: "Middle-click by pressing both buttons", + detail: "Pressing left and right together acts as a middle click", + hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" } + }, + // ── Night light ───────────────────────────────────────────────────── { key: "nightLightEnabled", type: "bool", def: false, group: "nightLight", diff --git a/config/dot/quickshell/modules/settings/MousePage.qml b/config/dot/quickshell/modules/settings/MousePage.qml new file mode 100644 index 0000000..c6a3fcc --- /dev/null +++ b/config/dot/quickshell/modules/settings/MousePage.qml @@ -0,0 +1,57 @@ +// Mouse & Touchpad. +// +// GNOME splits pointing devices out of Keyboard, and so does this: the settings +// are unrelated, and burying pointer speed under a page called "Shortcuts" is +// where it was before. These all reach Hyprland's input section, which until now +// could only be changed by editing hypr/input.lua by hand. +// +// The touchpad card renders only when a touchpad is attached. On a desktop it +// would be worse than useless -- every switch on it appears to work, because the +// preference is stored and the compositor accepts an option for a device class +// with no members, so the user would be toggling settings that can never affect +// anything with nothing to say so. + +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + title: "Mouse & Touchpad" + lede: InputDevices.hasTouchpad + ? "Pointer behaviour for your mouse and touchpad." + : "Pointer behaviour. Touchpad settings appear when a touchpad is attached." + + SettingsCard { + title: "Mouse" + subtitle: "Applied to every pointing device that is not a touchpad." + + SliderRow { setting: "pointerSensitivity" } + ChoiceRow { setting: "accelProfile" } + ToggleRow { setting: "naturalScroll" } + SliderRow { setting: "scrollFactor" } + ToggleRow { setting: "leftHanded"; divider: false } + } + + SettingsCard { + visible: InputDevices.hasTouchpad + title: "Touchpad" + subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions." + + ToggleRow { setting: "touchpadTapToClick" } + ToggleRow { setting: "touchpadNaturalScroll" } + ToggleRow { setting: "touchpadDisableWhileTyping" } + SliderRow { setting: "touchpadScrollFactor" } + ChoiceRow { setting: "touchpadDragLock" } + ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false } + } + + SettingsCard { + title: "Pointer" + + ChoiceRow { setting: "followMouse" } + SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" } + SliderRow { setting: "cursorSize"; divider: false } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index d1ef625..def527a 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -99,6 +99,7 @@ Rectangle { case "notifications": return notificationsPage; case "screen-intelligence": return screenIntelligencePage; case "shortcuts": return shortcutsPage; + case "mouse": return mousePage; case "accessibility": return accessibilityPage; case "power": return powerPage; case "datetime": return dateTimePage; @@ -153,6 +154,7 @@ Rectangle { Component { id: notificationsPage; NotificationsPage {} } Component { id: screenIntelligencePage; ScreenIntelligencePage {} } Component { id: shortcutsPage; ShortcutsPage {} } + Component { id: mousePage; MousePage {} } Component { id: servicesPage; ServicesPage {} } Component { id: aboutPage; AboutPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 38e009b..bfe4d36 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -31,7 +31,8 @@ Rectangle { { page: "sound", label: "Sound", icon: "\u{F057E}" }, { page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" }, { page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" }, - { page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" }, + { page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" }, + { page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" }, { page: "accessibility", label: "Accessibility", icon: "\u{F0208}" }, { page: "power", label: "Power & Lock", icon: "\u{F0425}" }, { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml index 6460815..aa9296a 100644 --- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -28,24 +28,19 @@ SettingsPage { SettingsCard { title: "Keyboard" - TextRow { - label: "Keyboard layout" - detail: "XKB layout name. Changing it needs a compositor reload, so it is shown here rather than offered as a control that appears to apply instantly." - value: Settings ? DesktopPreferences.get("keyboardLayout") : "us" - } + // These were read-only text, on the grounds that a layout change needed + // a compositor reload. It does not: setting input:kb_variant through + // hl.config re-keymaps attached keyboards immediately -- verified by + // watching active_keymap on a real keyboard change and change back. So + // they are real controls. + TextEntryRow { setting: "keyboardLayout"; placeholder: "us" } + TextEntryRow { setting: "keyboardVariant"; placeholder: "none" } + TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" } SliderRow { setting: "keyRepeatDelay" } SliderRow { setting: "keyRepeatRate" } ToggleRow { setting: "numlockByDefault"; divider: false } } - SettingsCard { - title: "Pointer" - - ChoiceRow { setting: "followMouse" } - SliderRow { setting: "pointerSensitivity" } - SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false } - } - SettingsCard { title: "Hardware input" diff --git a/config/dot/quickshell/modules/settings/TextEntryRow.qml b/config/dot/quickshell/modules/settings/TextEntryRow.qml new file mode 100644 index 0000000..e402cab --- /dev/null +++ b/config/dot/quickshell/modules/settings/TextEntryRow.qml @@ -0,0 +1,109 @@ +// A free-text setting, bound to a schema key by name. +// +// TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" } +// +// For the settings whose value is a short string with too many legal values to +// enumerate -- XKB layouts and options, chiefly. Label, explanation, and +// validation all come from PreferenceSchema, so a row cannot drift from the +// setting it edits. +// +// Rejected input is shown as rejected rather than silently dropped or quietly +// sanitised. Several of these strings are serialised into an hl.config payload, +// where stripping an unexpected character would turn a typo into a different +// working setting -- so the schema's pattern decides, the field turns red when +// it fails, and nothing is committed until it passes. +// +// Committed on Enter or when focus leaves, not per keystroke: half a layout +// name is a valid string that means something else, and each commit is a round +// trip to the compositor. + +import QtQuick +import qs.config +import qs.services + +SettingRow { + id: root + + required property string setting + property string placeholder: "" + + readonly property var spec: PreferenceSchema.spec(root.setting) + readonly property string stored: String(DesktopPreferences.get(root.setting) ?? "") + + // Empty is always allowed to be typed through, even where the pattern + // forbids it, so a field can be cleared on the way to a new value. + readonly property bool valid: input.text === "" + || !root.spec?.pattern + || new RegExp(root.spec.pattern).test(input.text) + + label: root.spec ? root.spec.label : root.setting + detail: root.spec ? root.spec.detail : "" + controlWidth: 210 + + function commit(): void { + if (!root.valid || input.text === root.stored) + return; + SystemSettings.commitPreference(root.setting, input.text); + } + + function revert(): void { + input.text = root.stored; + } + + // Follows the store when the value changes elsewhere -- a reset, a restored + // backup -- but never while the field has focus, which would overwrite what + // is being typed. + onStoredChanged: if (!input.activeFocus) input.text = root.stored + + Rectangle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: root.controlWidth + height: 30 + radius: 8 + color: Theme.alpha(Theme.fg, 0.05) + border.width: 1 + border.color: !root.valid + ? Theme.danger + : (input.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12)) + + TextInput { + id: input + + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + verticalAlignment: TextInput.AlignVCenter + clip: true + + text: root.stored + color: root.valid ? Theme.fg : Theme.danger + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeSmall + selectByMouse: true + selectionColor: Theme.alpha(Theme.accent, 0.35) + + onAccepted: root.commit() + + // Commit, then show what is actually stored. If the compositor + // refused the value, the field snaps back to the value in effect + // rather than displaying a setting that was never applied; if it + // accepted, onStoredChanged brings the field back up to date the + // moment the write lands. + onActiveFocusChanged: if (!activeFocus) { root.commit(); root.revert(); } + + Keys.onEscapePressed: { + root.revert(); + input.focus = false; + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: input.text === "" && !input.activeFocus + text: root.placeholder + color: Theme.fgMuted + font: input.font + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 231ef52..bf3768f 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -46,3 +46,5 @@ SoundDeviceRow 1.0 SoundDeviceRow.qml TimeOfDayRow 1.0 TimeOfDayRow.qml LocationPicker 1.0 LocationPicker.qml FontPicker 1.0 FontPicker.qml +MousePage 1.0 MousePage.qml +TextEntryRow 1.0 TextEntryRow.qml diff --git a/config/dot/quickshell/services/InputDevices.qml b/config/dot/quickshell/services/InputDevices.qml new file mode 100644 index 0000000..b71c39e --- /dev/null +++ b/config/dot/quickshell/services/InputDevices.qml @@ -0,0 +1,66 @@ +pragma Singleton + +// What input hardware this machine actually has. +// +// Exists so pages can hide controls for hardware that is not present. A +// touchpad card on a desktop is not merely useless -- it is misleading, because +// every switch on it appears to work: the preference is stored and Hyprland +// accepts the option for a device class it has no member of. The user is left +// toggling settings that will never affect anything, with nothing to say so. +// +// Touchpads are identified by name. libinput exposes them through the same +// "mice" list as everything else that reports pointer motion, and Hyprland +// passes the device name through, so an Elan or Synaptics touchpad arrives as +// something like "elan-touchpad". There is no device-class field to consult. +// +// Read on demand rather than polled. Input devices do come and go -- a mouse is +// unplugged, a receiver is moved -- so this also refreshes when Hyprland says +// the device list changed, which is the only moment the answer can differ. + +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import QtQuick + +Singleton { + id: root + + property var mice: [] + property var keyboards: [] + + // True when anything that looks like a touchpad is attached. + readonly property bool hasTouchpad: root.mice.some(name => + name.includes("touchpad") || name.includes("trackpad")) + + readonly property bool hasMouse: root.mice.length > 0 + + function refresh(): void { + if (!query.running) + query.running = true; + } + + Process { + id: query + running: true + command: ["hyprctl", "-j", "devices"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase()); + root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase()); + } catch (error) { + console.warn("InputDevices: could not parse hyprctl devices:", error); + } + } + } + } + + Connections { + target: Hyprland + function onRawEvent(event: var): void { + if (event.name === "device" || event.name === "configreloaded") + root.refresh(); + } + } +} diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 943b41b..3179aeb 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -33,6 +33,8 @@ Singleton { "idle": "power", "accessibility": "accessibility", "input": "shortcuts", + "pointer": "mouse", + "touchpad": "mouse", "weather": "appearance", "notifications": "notifications", "capture": "screen-intelligence" diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 28bb534..1b6c9f8 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "applications", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "accessibility", "power", "datetime", "applications", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; From 14529c011ff2e888b751282e7c660fdf6bc5920e Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 08:20:04 -0400 Subject: [PATCH 03/20] Add a Privacy & Security page Continuing towards GNOME Settings parity. GNOME's Privacy panel covers screen lock, camera and microphone access, file history, trash, and device security; Panama had no equivalent page at all, despite already tracking camera and microphone use for the bar indicator. Device security is a new read-only readout: Secure Boot, TPM, disk encryption, SELinux mode, and the firewall. None of these is a preference -- they are set in firmware, at install time, or by system policy, and a switch offering to change them would either fail or do something far-reaching from a control that looks like every other control. What it answers is "is this machine set up the way I think it is", which otherwise takes five commands and root. Facts that cannot be determined report Unknown rather than guessing, because a security readout that quietly says "fine" when it failed to look is worse than no readout. File history and trash retention are deliberately NOT offered as switches. They are GNOME preferences enforced by gsd-housekeeping, which does not run in a Hyprland session -- verified, it is not running here. Toggling them would store a preference, change nothing, and give no sign of it. They are delegated to GNOME Settings by name instead. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../modules/settings/PrivacyPage.qml | 103 ++++++++++++++++++ .../modules/settings/SettingsShell.qml | 2 + .../modules/settings/SettingsSidebar.qml | 1 + config/dot/quickshell/modules/settings/qmldir | 1 + config/dot/quickshell/scripts/panama-security | 83 ++++++++++++++ .../quickshell/services/DeviceSecurity.qml | 54 +++++++++ config/dot/quickshell/services/ShellState.qml | 2 +- 7 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 config/dot/quickshell/modules/settings/PrivacyPage.qml create mode 100755 config/dot/quickshell/scripts/panama-security create mode 100644 config/dot/quickshell/services/DeviceSecurity.qml diff --git a/config/dot/quickshell/modules/settings/PrivacyPage.qml b/config/dot/quickshell/modules/settings/PrivacyPage.qml new file mode 100644 index 0000000..594964c --- /dev/null +++ b/config/dot/quickshell/modules/settings/PrivacyPage.qml @@ -0,0 +1,103 @@ +// Privacy & Security. +// +// GNOME's Privacy panel covers screen lock, camera and microphone access, file +// history, trash, and device security. Panama covers the parts it genuinely +// owns and is explicit about the parts it does not. +// +// The file-history and trash settings are the notable omission, and the reason +// is worth stating: those are GNOME preferences enforced by gsd-housekeeping, +// which is not running in a Hyprland session. Offering switches for them would +// store a preference, change nothing, and give no sign of it -- the exact +// failure this codebase keeps designing against. So they are delegated by name +// rather than reimplemented as controls that lie. + +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + title: "Privacy & Security" + lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0 + ? "Screen lock, device access, and a machine whose security settings all check out." + : "Screen lock, which applications can see you, and how this machine is protected." + + Component.onCompleted: if (!DeviceSecurity.scanned) DeviceSecurity.refresh() + + SettingsCard { + title: "Screen lock" + subtitle: "The same settings as Power & Lock, which is where the idle timings live." + + SliderRow { setting: "lockMinutes"; zeroLabel: "Never" } + ToggleRow { setting: "lockOnSleep"; divider: false } + } + + SettingsCard { + title: "Camera & microphone" + subtitle: PrivacyState.anyActive + ? "In use right now — the bar shows an indicator whenever this is true." + : "Nothing is using your camera or microphone." + + TextRow { + label: "Camera" + detail: PrivacyState.cameraActive + ? "In use by " + (PrivacyState.cameraApp || "an application") + : "Not in use" + value: PrivacyState.cameraActive ? "Active" : "Idle" + } + + TextRow { + label: "Microphone" + detail: PrivacyState.microphoneActive + ? "In use by " + (PrivacyState.microphoneApp || "an application") + : "Not in use" + value: PrivacyState.microphoneActive ? "Active" : "Idle" + } + + TextRow { + label: "Screen sharing" + detail: PrivacyState.screenSharingActive + ? "Being shared by " + (PrivacyState.screenSharingApp || "an application") + : "Not being shared" + value: PrivacyState.screenSharingActive ? "Active" : "Idle" + divider: false + } + } + + SettingsCard { + title: "Device security" + subtitle: DeviceSecurity.attentionCount === 0 + ? "Everything below is in its recommended state." + : DeviceSecurity.attentionCount + " item" + + (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention." + + Repeater { + model: DeviceSecurity.facts + + TextRow { + id: factRow + required property var modelData + required property int index + + label: factRow.modelData.label + detail: factRow.modelData.detail + value: factRow.modelData.value + divider: factRow.index < DeviceSecurity.facts.length - 1 + } + } + } + + SettingsCard { + title: "Owned by Fedora" + subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. Panama does not offer them as switches, because storing that preference here would change nothing." + + ActionRow { + label: "File history & trash" + detail: "Opens GNOME Settings, which owns these" + action: "Open privacy" + divider: false + onTriggered: SystemSettings.openGnomePanel("privacy") + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index def527a..033cdf6 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -100,6 +100,7 @@ Rectangle { case "screen-intelligence": return screenIntelligencePage; case "shortcuts": return shortcutsPage; case "mouse": return mousePage; + case "privacy": return privacyPage; case "accessibility": return accessibilityPage; case "power": return powerPage; case "datetime": return dateTimePage; @@ -155,6 +156,7 @@ Rectangle { Component { id: screenIntelligencePage; ScreenIntelligencePage {} } Component { id: shortcutsPage; ShortcutsPage {} } Component { id: mousePage; MousePage {} } + Component { id: privacyPage; PrivacyPage {} } Component { id: servicesPage; ServicesPage {} } Component { id: aboutPage; AboutPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index bfe4d36..d82a99c 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -33,6 +33,7 @@ Rectangle { { page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" }, { page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" }, { page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" }, + { page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" }, { page: "accessibility", label: "Accessibility", icon: "\u{F0208}" }, { page: "power", label: "Power & Lock", icon: "\u{F0425}" }, { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index bf3768f..464b268 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -48,3 +48,4 @@ LocationPicker 1.0 LocationPicker.qml FontPicker 1.0 FontPicker.qml MousePage 1.0 MousePage.qml TextEntryRow 1.0 TextEntryRow.qml +PrivacyPage 1.0 PrivacyPage.qml diff --git a/config/dot/quickshell/scripts/panama-security b/config/dot/quickshell/scripts/panama-security new file mode 100755 index 0000000..6dcfcb8 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-security @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +# Device security facts, as JSON. +# +# Everything here is READ-ONLY and deliberately so. Secure Boot, TPM presence, +# disk encryption, SELinux mode and the firewall are set in firmware, at install +# time, or by system policy -- none of them is a desktop preference, and a +# settings app that offered to toggle them would either fail or do something +# far-reaching from a switch that looks like any other. +# +# What it is for is answering "is this machine set up the way I think it is", +# which is the question GNOME's Device Security panel exists to answer and which +# otherwise needs five commands and root. +# +# Each fact is reported as {value, ok} where `ok` marks the reassuring state, so +# the UI can highlight what deserves attention without hard-coding the meaning +# of each string. Anything that cannot be determined reports "Unknown" with +# ok:false rather than guessing, because a security readout that quietly reports +# "fine" when it failed to look is worse than no readout. + +set -uo pipefail + +fact() { + jq -cn --arg label "$1" --arg value "$2" --argjson ok "$3" --arg detail "${4:-}" \ + '{label: $label, value: $value, ok: $ok, detail: $detail}' +} + +facts=() + +# ── Secure Boot ────────────────────────────────────────────────────────────── +if command -v mokutil >/dev/null 2>&1; then + case "$(mokutil --sb-state 2>/dev/null)" in + *"SecureBoot enabled"*) facts+=("$(fact "Secure Boot" "Enabled" true "Firmware verifies the bootloader and kernel signatures")" ) ;; + *"SecureBoot disabled"*) facts+=("$(fact "Secure Boot" "Disabled" false "Firmware does not verify what it boots")") ;; + *) facts+=("$(fact "Secure Boot" "Unknown" false "The firmware did not report a Secure Boot state")") ;; + esac +elif [[ -d /sys/firmware/efi ]]; then + facts+=("$(fact "Secure Boot" "Unknown" false "Install mokutil to report this")") +else + facts+=("$(fact "Secure Boot" "Not applicable" false "This machine booted in legacy BIOS mode")") +fi + +# ── TPM ────────────────────────────────────────────────────────────────────── +tpm_major="$(cat /sys/class/tpm/tpm0/tpm_version_major 2>/dev/null || true)" +if [[ -n "$tpm_major" ]]; then + facts+=("$(fact "TPM" "Version $tpm_major" true "A trusted platform module is present and usable")") +elif [[ -e /sys/class/tpm/tpm0 ]]; then + facts+=("$(fact "TPM" "Present" true "A trusted platform module is present")") +else + facts+=("$(fact "TPM" "None" false "No trusted platform module, so keys cannot be sealed to this machine")") +fi + +# ── Disk encryption ────────────────────────────────────────────────────────── +# Counts LUKS mappings rather than naming them: which volume is encrypted is +# more detail than this readout needs, and device names are not meaningful here. +crypt_count="$(lsblk -o TYPE 2>/dev/null | grep -c '^crypt$' || true)" +[[ "$crypt_count" =~ ^[0-9]+$ ]] || crypt_count=0 +if (( crypt_count > 0 )); then + facts+=("$(fact "Disk encryption" "$crypt_count encrypted volume$( (( crypt_count == 1 )) || printf 's')" true "Data at rest is protected by LUKS")") +else + facts+=("$(fact "Disk encryption" "None" false "No LUKS volume is unlocked on this machine")") +fi + +# ── SELinux ────────────────────────────────────────────────────────────────── +if command -v getenforce >/dev/null 2>&1; then + case "$(getenforce 2>/dev/null)" in + Enforcing) facts+=("$(fact "SELinux" "Enforcing" true "Policy violations are blocked")") ;; + Permissive) facts+=("$(fact "SELinux" "Permissive" false "Violations are logged but allowed")") ;; + Disabled) facts+=("$(fact "SELinux" "Disabled" false "Mandatory access control is off")") ;; + *) facts+=("$(fact "SELinux" "Unknown" false "")") ;; + esac +fi + +# ── Firewall ───────────────────────────────────────────────────────────────── +if systemctl list-unit-files firewalld.service >/dev/null 2>&1; then + if [[ "$(systemctl is-active firewalld 2>/dev/null)" == "active" ]]; then + facts+=("$(fact "Firewall" "Active" true "firewalld is filtering incoming connections")") + else + facts+=("$(fact "Firewall" "Inactive" false "firewalld is installed but not running")") + fi +fi + +printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")" diff --git a/config/dot/quickshell/services/DeviceSecurity.qml b/config/dot/quickshell/services/DeviceSecurity.qml new file mode 100644 index 0000000..500d9ea --- /dev/null +++ b/config/dot/quickshell/services/DeviceSecurity.qml @@ -0,0 +1,54 @@ +pragma Singleton + +// Read-only device security facts: Secure Boot, TPM, disk encryption, SELinux, +// firewall. +// +// Nothing here is a preference. These are set in firmware, at install time, or +// by system policy, and a settings app that offered to change them from a +// switch would either fail or do something far-reaching from a control that +// looks like every other control. What this answers is "is this machine set up +// the way I think it is", which otherwise takes five commands and root. +// +// Read on demand. None of these can change while the desktop is running, +// short of a reboot. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-security" + + // [{ label, value, ok, detail }] + property var facts: [] + property bool scanned: false + + // The facts that are not in their reassuring state. The page leads with the + // count so a machine that is entirely fine says so in one line instead of + // making the user read five rows to find out. + readonly property int attentionCount: root.facts.filter(fact => !fact.ok).length + + function refresh(): void { + if (!query.running) + query.running = true; + } + + Process { + id: query + command: [root.helperPath] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.facts = Array.isArray(parsed) ? parsed : []; + } catch (error) { + root.facts = []; + console.warn("DeviceSecurity: could not parse helper output:", error); + } + root.scanned = true; + } + } + } +} diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 1b6c9f8..5a84298 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "accessibility", "power", "datetime", "applications", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "accessibility", "power", "datetime", "applications", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; From 6026bf308bc54760c68b2a3293c450ab2d6ae8a5 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 08:30:21 -0400 Subject: [PATCH 04/20] Add Region & Language, and answer "what am I running on" in About More GNOME Settings parity. Region & Language is new. The locale is localectl's, and Panama stores no copy of it -- there is exactly one system locale, so a preference here would be a second source of truth that drifts the moment anything else changes it. Codes are resolved against iso-codes into "Portuguese (Brazil)" the way GNOME does, with the code kept visible because it is what actually gets written and someone choosing between two Spanish variants needs to see it. Changing it is privileged and only applies to programs started afterwards, so the page says a sign-out is needed rather than claiming the new language is in use. The service is called SystemLocale, not Locale: QML has a built-in Locale value type that silently shadows a singleton of that name, and every binding then reads properties off the wrong thing. The page rendered empty with nothing but "cannot read property of undefined" to explain it. About now answers what GNOME's About answers -- model, processor, memory, disk, OS, kernel, windowing system -- where before it listed only Panama's own component versions. Graphics is joined from GraphicsDevices rather than read again, because two readouts of the same hardware are two things that can disagree. Placeholder DMI strings ("To Be Filled By O.E.M.") are filtered out, and unreadable facts are omitted rather than shown as "Unknown". The Fedora hand-off card was one row listing five subjects that opened the network panel regardless. Naming a panel and then not opening it reads as a broken button rather than a deliberate hand-off. Each subject now opens the panel that owns it, and openGnomePanel takes an optional subpage so "Users" reaches System's users page the way GNOME's own desktop entry does, instead of dropping the user on System's front page. Printers and online accounts are not repeated here; they stay with the network hardware on Network & Devices. One bug this surfaced, caught by the hyprland write contract: the Lua config key and the hyprctl option name genuinely differ for tap to click. hl.config wants input.touchpad.tap_to_click; getoption answers to input:touchpad:tap-to-click. Either spelling used for both fails -- a hyphen is not a Lua identifier, and the underscored name is not a known option -- which is what the schema's two separate fields are for. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../quickshell/config/PreferenceSchema.qml | 8 +- .../quickshell/modules/settings/AboutPage.qml | 55 +++++++++++ .../modules/settings/RegionPage.qml | 75 ++++++++++++++ .../modules/settings/SearchPicker.qml | 94 ++++++++++++++++++ .../modules/settings/ServicesPage.qml | 36 ++++++- .../modules/settings/SettingsShell.qml | 2 + .../modules/settings/SettingsSidebar.qml | 1 + config/dot/quickshell/modules/settings/qmldir | 2 + config/dot/quickshell/scripts/panama-about | 81 +++++++++++++++ config/dot/quickshell/scripts/panama-locale | 98 ++++++++++++++++++ .../dot/quickshell/services/MachineInfo.qml | 47 +++++++++ config/dot/quickshell/services/ShellState.qml | 2 +- .../dot/quickshell/services/SystemLocale.qml | 99 +++++++++++++++++++ .../quickshell/services/SystemSettings.qml | 21 +++- 14 files changed, 612 insertions(+), 9 deletions(-) create mode 100644 config/dot/quickshell/modules/settings/RegionPage.qml create mode 100644 config/dot/quickshell/modules/settings/SearchPicker.qml create mode 100755 config/dot/quickshell/scripts/panama-about create mode 100755 config/dot/quickshell/scripts/panama-locale create mode 100644 config/dot/quickshell/services/MachineInfo.qml create mode 100644 config/dot/quickshell/services/SystemLocale.qml diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 4772613..858f90a 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -374,7 +374,13 @@ Singleton { key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad", label: "Tap to click", detail: "A tap counts as a click without pressing down", - hypr: { path: ["input", "touchpad", "tap-to-click"], option: "input:touchpad:tap-to-click", readAs: "bool" } + // The Lua config key and the hyprctl option name genuinely differ + // here: hl.config wants input.touchpad.tap_to_click, getoption + // answers to input:touchpad:tap-to-click. Using either spelling for + // both fails -- a hyphen is not a Lua identifier, and the + // underscored name is not a known option to getoption. This is what + // the two separate fields are for. + hypr: { path: ["input", "touchpad", "tap_to_click"], option: "input:touchpad:tap-to-click", readAs: "bool" } }, { key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad", diff --git a/config/dot/quickshell/modules/settings/AboutPage.qml b/config/dot/quickshell/modules/settings/AboutPage.qml index d648c56..30b266e 100644 --- a/config/dot/quickshell/modules/settings/AboutPage.qml +++ b/config/dot/quickshell/modules/settings/AboutPage.qml @@ -4,9 +4,18 @@ import qs.config import qs.services SettingsPage { + id: root + title: "About Panama" lede: "A curated Hyprland desktop built around focus, speed, and good taste." + Component.onCompleted: { + if (!MachineInfo.scanned) + MachineInfo.refresh(); + if (GraphicsDevices.devices.length === 0) + GraphicsDevices.refresh(); + } + SettingsCard { title: "Panama Desktop" subtitle: "Tokyo Night Moon · Prism glass · native tiling" @@ -31,6 +40,52 @@ SettingsPage { } } + // What GNOME's About panel answers and this page did not: what am I running + // on. The rows come from MachineInfo, except graphics, which is joined from + // GraphicsDevices rather than read a second time -- two readouts of the same + // hardware are two things that can disagree. + SettingsCard { + title: "This machine" + subtitle: "Hardware and system, as the kernel reports it." + + Repeater { + model: MachineInfo.facts + + TextRow { + id: machineRow + required property var modelData + required property int index + + label: machineRow.modelData.label + value: machineRow.modelData.value + } + } + + Repeater { + model: GraphicsDevices.devices + + TextRow { + id: gpuRow + required property var modelData + required property int index + + label: GraphicsDevices.devices.length > 1 + ? "Graphics " + (gpuRow.index + 1) + : "Graphics" + value: gpuRow.modelData.name + divider: gpuRow.index < GraphicsDevices.devices.length - 1 + } + } + + TextRow { + visible: MachineInfo.scanned && MachineInfo.facts.length === 0 + label: "Hardware" + detail: "The system did not report anything readable" + value: "Unavailable" + divider: false + } + } + SettingsCard { title: "Design principles" diff --git a/config/dot/quickshell/modules/settings/RegionPage.qml b/config/dot/quickshell/modules/settings/RegionPage.qml new file mode 100644 index 0000000..b86812a --- /dev/null +++ b/config/dot/quickshell/modules/settings/RegionPage.qml @@ -0,0 +1,75 @@ +// Region & Language. +// +// GNOME keeps language and formats under System; the setting itself is the +// machine's locale, which localectl owns. Panama does not store a copy of it -- +// there is exactly one system locale and localectl is where it lives, so a +// preference here would be a second source of truth that drifts the moment +// anything else changes it. +// +// Keyboard layout is deliberately not repeated here even though GNOME groups it +// with region. It is a compositor setting that applies instantly, it lives on +// the Keyboard page with the rest of the typing settings, and showing it twice +// invites the two views to disagree. + +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + title: "Region & Language" + lede: SystemLocale.pendingRestart + ? "Your new language applies to programs started after you sign out and back in." + : "The language and regional formats this machine uses." + + Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh() + + SettingsCard { + title: "Language" + subtitle: "Changing this needs your password, and takes effect for programs started afterwards." + + TextRow { + label: "Current language" + detail: SystemLocale.pendingRestart + ? "Chosen, but not in use until you sign out and back in" + : "Used by programs that ask the system what language to speak" + value: SystemLocale.currentLabel || "Reading…" + } + + SearchPicker { + width: parent.width + items: SystemLocale.locales + current: SystemLocale.current + placeholder: "Search languages and regions" + emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed" + onPicked: value => SystemLocale.set(value) + } + } + + SettingsCard { + visible: SystemLocale.lastError !== "" + title: "Language problem" + subtitle: SystemLocale.lastError + } + + SettingsCard { + title: "Formats" + subtitle: "Dates, times, and numbers follow the language above. Panama's own clock formatting is on the Appearance page." + + ActionRow { + label: "Clock and date display" + detail: "How Panama itself shows the time" + action: "Open appearance" + onTriggered: ShellState.openSettings("appearance") + } + + ActionRow { + label: "Regional formats" + detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora" + action: "Open system" + divider: false + onTriggered: SystemSettings.openGnomePanel("system", "region") + } + } +} diff --git a/config/dot/quickshell/modules/settings/SearchPicker.qml b/config/dot/quickshell/modules/settings/SearchPicker.qml new file mode 100644 index 0000000..6fa4922 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SearchPicker.qml @@ -0,0 +1,94 @@ +// A searchable list of choices, for settings with far too many values to put in +// a dropdown and no useful preview to show. +// +// SearchPicker { +// items: Locale.locales // [{ value, label, detail }] +// current: Locale.current +// placeholder: "Search languages" +// onPicked: value => Locale.set(value) +// } +// +// FontPicker is the same idea specialised: it draws each candidate in its own +// family, which is the whole reason it is a list rather than a text field. This +// one is for choices whose only useful preview is their name. + +import QtQuick +import qs.config +// SearchField lives with the clipboard module, which is where it was first +// needed; FontPicker imports it from the same place. +import qs.modules.clipboard + +Column { + id: root + + // [{ value, label, detail }] + property var items: [] + property string current: "" + property string emptyText: "Nothing to choose from" + property string placeholder: "Search" + + signal picked(string value) + + spacing: 0 + + // Capped and filtered rather than listing everything: 327 locales inside a + // page that is already scrolling is worse than a search box. + readonly property var matches: { + const needle = filter.text.trim().toLowerCase(); + const list = root.items.filter(item => + needle === "" + ? true + : (String(item.label).toLowerCase().indexOf(needle) >= 0 + || String(item.detail).toLowerCase().indexOf(needle) >= 0)); + + // The current choice stays visible while browsing, so it is always + // clear what would be replaced. + const selected = list.find(item => item.value === root.current); + if (needle === "" && selected !== undefined) + return [selected].concat(list.filter(item => item.value !== root.current)).slice(0, 12); + return list.slice(0, 12); + } + + SearchField { + id: filter + width: parent.width + placeholder: root.placeholder + } + + Repeater { + model: root.matches + + SettingRow { + id: candidate + + required property var modelData + required property int index + + readonly property bool selected: candidate.modelData.value === root.current + + label: candidate.modelData.label + detail: candidate.selected ? "Currently in use" : candidate.modelData.detail + controlWidth: 120 + divider: candidate.index < root.matches.length - 1 + activatable: !candidate.selected + onActivated: root.picked(candidate.modelData.value) + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: candidate.selected + text: "✓" + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + 2 + color: Theme.accent + } + } + } + + SettingRow { + width: parent.width + visible: root.matches.length === 0 + label: root.emptyText + divider: false + } +} diff --git a/config/dot/quickshell/modules/settings/ServicesPage.qml b/config/dot/quickshell/modules/settings/ServicesPage.qml index c6e9bd4..ea4129e 100644 --- a/config/dot/quickshell/modules/settings/ServicesPage.qml +++ b/config/dot/quickshell/modules/settings/ServicesPage.qml @@ -120,14 +120,40 @@ SettingsPage { SettingsCard { title: "Fedora system settings" - subtitle: "These remain owned by trusted system services and GNOME's mature panels." + subtitle: "Panels Panama does not own, because they configure system services rather than the desktop. Each row opens the panel that actually owns it. Printers and online accounts live with the rest of the network hardware, on Network & Devices." + + // This was one row listing five subjects and opening the network panel + // regardless. Naming a panel and then not opening it is worse than not + // offering it: it looks like a broken button rather than a deliberate + // hand-off, and someone looking for printers had to know to navigate + // once GNOME Settings appeared on the wrong page. + ActionRow { + label: "Users" + detail: "Accounts, passwords, and automatic login" + action: "Open users" + onTriggered: SystemSettings.openGnomePanel("system", "users") + } ActionRow { - label: "Network, Bluetooth, printers, users, and accounts" - detail: "GNOME Settings remains searchable from the launcher too" + label: "Sharing" + detail: "Remote desktop, media sharing, and remote login" + action: "Open sharing" + onTriggered: SystemSettings.openGnomePanel("sharing") + } + + ActionRow { + label: "Colour profiles" + detail: "ICC profiles for displays, printers, and scanners" + action: "Open colour" + onTriggered: SystemSettings.openGnomePanel("color") + } + + ActionRow { + label: "Digital wellbeing" + detail: "Screen time and break reminders" + action: "Open wellbeing" divider: false - action: "Open network" - onTriggered: SystemSettings.openGnomePanel("network") + onTriggered: SystemSettings.openGnomePanel("wellbeing") } } } diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 033cdf6..10ac867 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -101,6 +101,7 @@ Rectangle { case "shortcuts": return shortcutsPage; case "mouse": return mousePage; case "privacy": return privacyPage; + case "region": return regionPage; case "accessibility": return accessibilityPage; case "power": return powerPage; case "datetime": return dateTimePage; @@ -157,6 +158,7 @@ Rectangle { Component { id: shortcutsPage; ShortcutsPage {} } Component { id: mousePage; MousePage {} } Component { id: privacyPage; PrivacyPage {} } + Component { id: regionPage; RegionPage {} } Component { id: servicesPage; ServicesPage {} } Component { id: aboutPage; AboutPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index d82a99c..a7593cb 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -34,6 +34,7 @@ Rectangle { { page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" }, { page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" }, { page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" }, + { page: "region", label: "Region & Language", icon: "\u{F0AC2}" }, { page: "accessibility", label: "Accessibility", icon: "\u{F0208}" }, { page: "power", label: "Power & Lock", icon: "\u{F0425}" }, { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 464b268..99bbf5c 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -49,3 +49,5 @@ FontPicker 1.0 FontPicker.qml MousePage 1.0 MousePage.qml TextEntryRow 1.0 TextEntryRow.qml PrivacyPage 1.0 PrivacyPage.qml +RegionPage 1.0 RegionPage.qml +SearchPicker 1.0 SearchPicker.qml diff --git a/config/dot/quickshell/scripts/panama-about b/config/dot/quickshell/scripts/panama-about new file mode 100755 index 0000000..a902afe --- /dev/null +++ b/config/dot/quickshell/scripts/panama-about @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +# What this machine is, as JSON: {label, value} pairs in display order. +# +# GNOME's About panel answers "what am I running on" in one screen -- model, +# processor, memory, disk, OS, kernel, windowing system. Panama's About listed +# only its own component versions, which answers a different and much narrower +# question. +# +# Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for +# the vitals readout, and naming them again here would be a second source of +# truth that could disagree with the first. The page joins the two. +# +# Anything unreadable is omitted rather than reported as "Unknown". These are +# facts about hardware, and a row saying "Processor: Unknown" is noise where +# simply not having the row is not. + +set -uo pipefail + +facts=() + +emit() { + [[ -n "${2:-}" ]] || return 0 + facts+=("$(jq -cn --arg label "$1" --arg value "$2" '{label: $label, value: $value}')") +} + +# ── Machine ────────────────────────────────────────────────────────────────── +# DMI strings are frequently placeholders ("To Be Filled By O.E.M.", "Default +# string"). Those are worse than nothing, so they are filtered out. +dmi() { + local value + value="$(cat "/sys/class/dmi/id/$1" 2>/dev/null)" || return 0 + case "$value" in + ""|"To Be Filled By O.E.M."*|"Default string"|"System Product Name"|"Unknown"|"None") return 0 ;; + esac + printf '%s' "$value" +} + +vendor="$(dmi sys_vendor)" +product="$(dmi product_name)" +if [[ -n "$vendor" && -n "$product" ]]; then + emit "Model" "$vendor $product" +else + emit "Model" "${product:-$vendor}" +fi + +emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)" + +# ── Processor ──────────────────────────────────────────────────────────────── +cpu="$(awk -F': ' '/^model name/ { print $2; exit }' /proc/cpuinfo 2>/dev/null)" +threads="$(nproc 2>/dev/null)" +if [[ -n "$cpu" ]]; then + # The marketing name usually already says "6-Core", so the thread count is + # the part that adds information. + [[ -n "$threads" ]] && cpu="$cpu ($threads threads)" + emit "Processor" "$cpu" +fi + +# ── Memory ─────────────────────────────────────────────────────────────────── +# Reported as the kernel sees it, which is a little under the sticker figure +# because firmware and integrated graphics reserve some before Linux starts. +emit "Memory" "$(awk '/^MemTotal:/ { printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)" + +# ── Storage ────────────────────────────────────────────────────────────────── +read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)" +[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size" + +# ── Software ───────────────────────────────────────────────────────────────── +if [[ -r /etc/os-release ]]; then + # Sourced in a subshell so the variables cannot leak into this script. + os="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-$NAME}" )" + emit "Operating system" "$os" +fi +emit "Kernel" "$(uname -r 2>/dev/null)" + +case "${XDG_SESSION_TYPE:-}" in + wayland) emit "Windowing system" "Wayland" ;; + x11) emit "Windowing system" "X11" ;; +esac + +printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")" diff --git a/config/dot/quickshell/scripts/panama-locale b/config/dot/quickshell/scripts/panama-locale new file mode 100755 index 0000000..cd8c8ee --- /dev/null +++ b/config/dot/quickshell/scripts/panama-locale @@ -0,0 +1,98 @@ +#!/usr/bin/env bash + +# System locale, via localectl. +# +# panama-locale list -> [{value, label, detail}] +# panama-locale get -> the current LANG, e.g. en_US.UTF-8 +# panama-locale set +# +# Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you +# already know, which defeats the point of a picker, so codes are resolved +# against the iso-codes database into "Portuguese (Brazil)" the way GNOME does. +# The code stays visible as the row's detail, because it is what actually gets +# written and someone choosing between two Spanish variants needs to see it. +# +# The join happens in a single jq pass. Doing it per locale meant 327 jq +# invocations, which took long enough to be visible when opening the page. +# +# Setting the locale is a privileged operation: localectl goes through polkit, +# which prompts. It also only takes effect for programs started afterwards, so +# the caller is responsible for saying a sign-out is needed -- this script does +# not pretend the running session changed. + +set -uo pipefail + +readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json +readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json + +cmd_get() { + localectl status 2>/dev/null \ + | awk -F'LANG=' '/System Locale:/ { print $2; exit }' \ + | tr -d '[:space:]' +} + +cmd_list() { + local locales + locales="$(localectl list-locales 2>/dev/null)" || locales="" + if [[ -z "$locales" ]]; then + printf '[]\n' + return 0 + fi + + # Without iso-codes installed the codes are still perfectly usable; they + # just do not get friendly names. That is a degraded list, not a failure. + if [[ ! -r "$ISO_LANG" || ! -r "$ISO_COUNTRY" ]]; then + jq -Rn --rawfile raw /dev/stdin \ + '[$raw | split("\n")[] | select(length > 0) | {value: ., label: ., detail: ""}]' \ + <<<"$locales" + return 0 + fi + + jq -Rn \ + --slurpfile languages "$ISO_LANG" \ + --slurpfile countries "$ISO_COUNTRY" \ + --rawfile raw /dev/stdin ' + # alpha_2 -> name, for both databases. Languages without a two-letter + # code cannot appear in a locale name, so they are simply absent. + ($languages[0]["639-2"] | map(select(.alpha_2)) | INDEX(.alpha_2) | map_values(.name)) as $lang + | ($countries[0]["3166-1"] | INDEX(.alpha_2) | map_values(.name)) as $country + | [ $raw + | split("\n")[] + | select(length > 0) + | . as $value + # en_US.UTF-8 -> ["en", "US"]; the codeset and any @modifier are + # not part of the human name. + | ($value | split(".")[0] | split("@")[0] | split("_")) as $parts + | ($lang[$parts[0]] // $parts[0]) as $language + | (if ($parts | length) > 1 then $country[$parts[1]] else null end) as $region + | { + value: $value, + label: (if $region then "\($language) (\($region))" else $language end), + detail: $value + } + ] + | sort_by(.label) + ' <<<"$locales" +} + +cmd_set() { + local locale="${1:-}" + # Constrained rather than passed through: this reaches a privileged + # command, and the set of legal locale names is narrow and well known. + [[ "$locale" =~ ^[a-zA-Z0-9_@.-]+$ ]] || { + printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2 + return 2 + } + localectl list-locales 2>/dev/null | grep -qxF "$locale" || { + printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2 + return 2 + } + localectl set-locale "LANG=$locale" +} + +case "${1:-list}" in + list) cmd_list ;; + get) cmd_get ;; + set) shift; cmd_set "${1:-}" ;; + *) printf 'usage: panama-locale [list|get|set ]\n' >&2; exit 2 ;; +esac diff --git a/config/dot/quickshell/services/MachineInfo.qml b/config/dot/quickshell/services/MachineInfo.qml new file mode 100644 index 0000000..4c2f736 --- /dev/null +++ b/config/dot/quickshell/services/MachineInfo.qml @@ -0,0 +1,47 @@ +pragma Singleton + +// What this machine is: model, processor, memory, disk, OS, kernel. +// +// Read once, on demand. None of it changes while the desktop is running except +// free disk space, and About is not a monitor -- the vitals readout on the Home +// page is where live figures belong. +// +// Graphics is not here. GraphicsDevices already enumerates GPUs for the vitals +// readout, and naming them again would be a second source of truth that could +// disagree with the first; the About page joins the two instead. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-about" + + // [{ label, value }] in display order. + property var facts: [] + property bool scanned: false + + function refresh(): void { + if (!query.running) + query.running = true; + } + + Process { + id: query + command: [root.helperPath] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.facts = Array.isArray(parsed) ? parsed : []; + } catch (error) { + root.facts = []; + console.warn("MachineInfo: could not parse helper output:", error); + } + root.scanned = true; + } + } + } +} diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 5a84298..b785047 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "accessibility", "power", "datetime", "applications", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accessibility", "power", "datetime", "applications", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; diff --git a/config/dot/quickshell/services/SystemLocale.qml b/config/dot/quickshell/services/SystemLocale.qml new file mode 100644 index 0000000..6a5b7b7 --- /dev/null +++ b/config/dot/quickshell/services/SystemLocale.qml @@ -0,0 +1,99 @@ +pragma Singleton + +// The system locale. +// +// Named SystemLocale, not Locale: QML has a built-in Locale value type, and a +// singleton of that name is silently shadowed by it. Every binding then reads +// properties off the wrong thing and the page renders empty with only +// "Cannot read property of undefined" to show for it. +// +// Changing it is privileged: localectl goes through polkit, which prompts +// (hyprpolkitagent serves that in this session). It also only applies to +// programs started afterwards, so `pendingRestart` goes true once a change is +// accepted and the page says a sign-out is needed. Reporting the new locale as +// simply "in effect" would be wrong -- almost nothing on screen would be using +// it yet. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-locale" + + // [{ value, label, detail }] + property var locales: [] + property string current: "" + property bool scanning: false + property string lastError: "" + + // True once a change has been accepted but the session has not restarted, + // so the UI can stop claiming the new locale is already in use. + property bool pendingRestart: false + + readonly property string currentLabel: { + const match = root.locales.find(locale => locale.value === root.current); + return match ? match.label : root.current; + } + + function refresh(): void { + if (root.scanning) + return; + root.scanning = true; + readCurrent.running = true; + list.running = true; + } + + function set(value: string): void { + if (value === root.current) + return; + apply.command = [root.helperPath, "set", value]; + apply.pendingValue = value; + apply.running = true; + } + + Process { + id: list + command: [root.helperPath, "list"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.locales = Array.isArray(parsed) ? parsed : []; + } catch (error) { + root.locales = []; + console.warn("SystemLocale: could not parse the locale list:", error); + } + root.scanning = false; + } + } + } + + Process { + id: readCurrent + command: [root.helperPath, "get"] + stdout: StdioCollector { + onStreamFinished: root.current = this.text.trim() + } + } + + Process { + id: apply + + property string pendingValue: "" + + // A refused change -- polkit dismissed, or an unknown locale -- must not + // move the UI. The value is only adopted on a zero exit. + onExited: code => { + if (code === 0) { + root.current = apply.pendingValue; + root.pendingRestart = true; + root.lastError = ""; + } else { + root.lastError = "The system did not accept that language. It may have needed a password."; + } + } + } +} diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml index 67261fd..1f4c803 100644 --- a/config/dot/quickshell/services/SystemSettings.qml +++ b/config/dot/quickshell/services/SystemSettings.qml @@ -497,18 +497,35 @@ Singleton { ].indexOf(panel) >= 0; } - function openGnomePanel(panel: string): bool { + // `subpage` reaches the panels GNOME 50 nests under System -- users, + // about, datetime, region -- which its own desktop entries open as + // `gnome-control-center system users`. Without it, a row labelled "Users" + // lands on System's front page and leaves the user to navigate, which is + // most of the way to a broken button. + function openGnomePanel(panel: string, subpage: string): bool { if (!root.isGnomePanelAllowed(panel)) { root.lastError = "That GNOME Settings panel is not available."; return false; } + const command = ["gnome-control-center", panel]; + if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage)) + command.push(subpage); Quickshell.execDetached({ - command: ["gnome-control-center", panel], + command: command, environment: { "XDG_CURRENT_DESKTOP": "GNOME" } }); return true; } + // Only System nests panels, and only these. Read off the Exec lines of the + // gnome-*-panel desktop entries rather than guessed, for the same reason + // the panel list above was. + function isGnomeSubpageAllowed(panel: string, subpage: string): bool { + if (panel !== "system") + return false; + return ["users", "about", "datetime", "region", "remote-desktop"].indexOf(subpage) >= 0; + } + function openApplication(id: string): bool { const commands = { "nextcloud": ["nextcloud"], From b1f9891849f03b24834f305d8b9a20e8ff618cdb Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 08:34:05 -0400 Subject: [PATCH 05/20] Add multitasking controls, and a light theme for the launcher Two things, both closing gaps in work that was already reported done. GNOME's Multitasking panel, in Hyprland's terms: tiling layout, split behaviour, floating-window snapping, workspace wrap-around and back-and-forth, whether applications may take focus, and whether the pointer changes the active display. Hyprland creates and destroys workspaces as you use them, so there is no fixed count to expose, and the page says so rather than leaving a conspicuous absence. The Desktop page described the first two of these as read-only facts -- "Layout: Tiling", "Workspace movement: Dynamic" -- which was never true. Both are ordinary Hyprland options that happened to have no controls, and TextRow's own documentation says a setting the user could reasonably change does not belong in it. Schema defaults are Panama's shipped values from looks.lua rather than Hyprland's own, so restoring defaults returns the desktop to how it ships. 43 mapped options now, from 35. The launcher had no light theme. vicinae.json already selected a theme per system appearance, but both entries pointed at Moon, so choosing light mode left the most frequently opened window on the desktop dark -- a hole in the light/dark work, not a missing feature. Day is authored from the same palette as the kitty Day theme so the two cannot drift, and link-dotfiles now installs every authored theme rather than only the dark one, which is why the gap survived being noticed. Its placeholder colour is not Tokyo Night Day's own: that measures 2.54:1 against the background, below the 3:1 floor for secondary text. This is 3.25:1, the same value used for neovim's light comments. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../quickshell/config/PreferenceSchema.qml | 68 +++++++++++++++ .../modules/settings/DesktopPage.qml | 30 ++++--- .../quickshell/services/SettingsSearch.qml | 1 + config/dot/vicinae/themes/tokyonight-day.toml | 87 +++++++++++++++++++ config/dot/vicinae/vicinae.json | 10 ++- setup/scripts/link-dotfiles | 27 +++--- 6 files changed, 199 insertions(+), 24 deletions(-) create mode 100644 config/dot/vicinae/themes/tokyonight-day.toml diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 858f90a..fab097c 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -421,6 +421,74 @@ Singleton { hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" } }, + // ── Multitasking ──────────────────────────────────────────────────── + // + // GNOME's Multitasking panel, in Hyprland's terms. The Desktop page + // described the first two of these as read-only facts ("Layout: + // Tiling"), which was never true -- they are ordinary settings that + // happened not to have controls. + // + // Defaults here are Panama's shipped values from hypr/looks.lua, not + // Hyprland's own, so restoring defaults returns the desktop to how it + // ships rather than to how Hyprland would behave with no config. + { + key: "windowLayout", type: "enum", def: "dwindle", group: "multitasking", + label: "Tiling layout", + detail: "Dwindle splits the focused window; master keeps one large window beside a stack", + options: [ + { value: "dwindle", label: "Dwindle" }, + { value: "master", label: "Master and stack" } + ], + hypr: { path: ["general", "layout"], option: "general:layout", readAs: "str" } + }, + { + key: "preserveSplit", type: "bool", def: true, group: "multitasking", + label: "Keep split direction", + detail: "New windows follow the split of the window they replace, instead of always halving the longer side", + hypr: { path: ["dwindle", "preserve_split"], option: "dwindle:preserve_split", readAs: "bool" } + }, + { + key: "forceSplit", type: "enum", def: 0, group: "multitasking", + label: "New windows open", + detail: "Where a new window lands relative to the one that was focused", + options: [ + { value: 0, label: "Where the pointer is" }, + { value: 1, label: "Always left or above" }, + { value: 2, label: "Always right or below" } + ], + hypr: { path: ["dwindle", "force_split"], option: "dwindle:force_split", readAs: "int" } + }, + { + key: "windowSnapping", type: "bool", def: true, group: "multitasking", + label: "Snap floating windows", + detail: "Floating windows stick to screen edges and to each other as you drag them", + hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" } + }, + { + key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking", + label: "Switch back and forth", + detail: "Selecting the workspace you are already on returns you to the previous one", + hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" } + }, + { + key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking", + label: "Wrap around at the ends", + detail: "Moving past the last workspace continues from the first", + hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" } + }, + { + key: "focusOnActivate", type: "bool", def: false, group: "multitasking", + label: "Let applications take focus", + detail: "An application asking for attention is switched to, rather than only highlighted", + hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" } + }, + { + key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking", + label: "Pointer changes active display", + detail: "Moving the pointer to another display makes it the active one", + hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" } + }, + // ── Night light ───────────────────────────────────────────────────── { key: "nightLightEnabled", type: "bool", def: false, group: "nightLight", diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index dafdf01..8f6524a 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -49,16 +49,14 @@ SettingsPage { title: "Window layout" subtitle: "Panama follows the Forge mental model with native Hyprland tiling." - TextRow { - label: "Layout" - detail: "Dwindle with preserved split direction" - value: "Tiling" - } - TextRow { - label: "Workspace movement" - detail: "Alt+H / Alt+L, add Shift to move a window" - value: "Dynamic" - } + // These were two read-only rows reporting "Tiling" and "Dynamic", which + // described settings rather than facts -- both are ordinary Hyprland + // options that simply had no controls. TextRow's own documentation says + // a setting the user could reasonably change does not belong in it. + ChoiceRow { setting: "windowLayout" } + ToggleRow { setting: "preserveSplit" } + ChoiceRow { setting: "forceSplit" } + ToggleRow { setting: "windowSnapping" } ActionRow { label: "Gaps, corners, and effects" detail: "Adjusted on the Appearance page, beside a live preview" @@ -68,6 +66,18 @@ SettingsPage { } } + // GNOME's Multitasking panel, in Hyprland's terms. + SettingsCard { + title: "Workspaces & focus" + subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set." + + ToggleRow { setting: "workspaceBackAndForth" } + ToggleRow { setting: "allowWorkspaceCycles" } + ToggleRow { setting: "focusOnActivate" } + ToggleRow { setting: "mouseMoveFocusesMonitor" } + ChoiceRow { setting: "followMouse"; divider: false } + } + SettingsCard { title: "Focus" diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 3179aeb..88d9ec7 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -35,6 +35,7 @@ Singleton { "input": "shortcuts", "pointer": "mouse", "touchpad": "mouse", + "multitasking": "desktop", "weather": "appearance", "notifications": "notifications", "capture": "screen-intelligence" diff --git a/config/dot/vicinae/themes/tokyonight-day.toml b/config/dot/vicinae/themes/tokyonight-day.toml new file mode 100644 index 0000000..e00de6b --- /dev/null +++ b/config/dot/vicinae/themes/tokyonight-day.toml @@ -0,0 +1,87 @@ +# Tokyo Night Day — the light counterpart to tokyonight-moon.toml. +# +# Same schema and the same role for every colour; only the palette differs, so +# the launcher keeps its identity when the desktop switches to light rather +# than becoming a different-looking application. +# +# Colours are the Tokyo Night Day palette, taken from kitty/themes/tokyonight-day.conf +# so the launcher and the terminal cannot drift apart. + +[meta] +version = 1 +name = "Tokyo Night Day" +description = "Tokyo Night Day, matched to the Panama Hyprland desktop." +variant = "light" + +[colors.core] +background = "#e1e2e7" # bg +foreground = "#3760bf" # fg +secondary_background = "#d0d5e3" # bg_dark, a shade below the ground +border = "#a8aecb" # a visible hairline on a light ground +accent = "#2e7de9" # blue +accent_foreground = "#e1e2e7" + +[colors.accents] +blue = "#2e7de9" +green = "#587539" +magenta = "#9854f1" +orange = "#b15c00" +purple = "#7847bd" +red = "#f52a65" +yellow = "#8c6c3e" +cyan = "#007197" + +[colors.main_window] +border = "#a8aecb" +footer = { background = "colors.core.secondary_background" } + +[colors.settings_window] +border = "#c4c8da" + +[colors.shortcut] +border = "colors.core.border" + +[colors.text] +default = "colors.core.foreground" +muted = "#6172b0" # fg_dark, 4.0:1 on the background +danger = "#f52a65" +success = "#587539" +placeholder = "#7079a8" # 3.25:1; Day's own #848cb5 is 2.54:1, too faint to read +selection = { background = "#2e7de9", foreground = "#e1e2e7" } + +[colors.text.links] +default = "#2e7de9" +visited = "#9854f1" + +[colors.input] +border = "#c4c8da" +border_focus = "#2e7de9" +border_error = "#f52a65" + +[colors.button.primary] +background = "#d4d6e4" # bg_highlight +foreground = "#3760bf" +hover = { background = "#c4c8da" } +focus = { outline = "colors.core.accent" } + +[colors.list.item.hover] +foreground = "#3760bf" +secondary_foreground = "#6172b0" + +[colors.list.item.selection] +background = "#c3cdf0" # blue side of the Prism selection, lightened +foreground = "#3760bf" +secondary_background = "#d8c8e4" # orchid side of the Prism selection, lightened +secondary_foreground = "#3760bf" + +[colors.grid.item] +background = "#d7d9e5" +hover = { outline = "#2e7de9" } +selection = { outline = "#9854f1" } + +[colors.scrollbars] +background = "#c4c8da" + +[colors.loading] +bar = "#2e7de9" +spinner = "#3760bf" diff --git a/config/dot/vicinae/vicinae.json b/config/dot/vicinae/vicinae.json index 0795bfe..202f057 100644 --- a/config/dot/vicinae/vicinae.json +++ b/config/dot/vicinae/vicinae.json @@ -6,12 +6,14 @@ // and emoji-copy extensions, so its clipboard and emoji views are bound // directly in hypr/keybinds.lua. - // Vicinae 0.26 selects a theme per system appearance. Both point to Moon so - // the launcher never flashes or falls back to its stock palette while the - // desktop appearance is being initialized. + // Vicinae 0.26 selects a theme per system appearance, and now there is a + // theme for each: the launcher follows Panama's light/dark setting instead of + // staying dark on a light desktop. Both are authored here rather than using + // Vicinae's bundled Tokyo Night, which ships Night and Storm but not the Moon + // and Day variants the rest of this desktop uses. "theme": { "light": { - "name": "tokyonight-moon", + "name": "tokyonight-day", "icon_theme": "auto" }, "dark": { diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles index 3ab8fd6..b3e0fde 100755 --- a/setup/scripts/link-dotfiles +++ b/setup/scripts/link-dotfiles @@ -95,19 +95,26 @@ fi # from ~/.config/vicinae. Keep the authored theme in Panama with the rest of # the launcher config and expose only that file at Vicinae's runtime path. VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes" -VICINAE_THEME="$VICINAE_THEME_DIR/tokyonight-moon.toml" mkdir -p "$VICINAE_THEME_DIR" -if [ -L "$VICINAE_THEME" ]; then - rm "$VICINAE_THEME" -fi +# Every authored theme, not just the dark one: vicinae.json selects a theme per +# system appearance, so linking only Moon left the launcher falling back to its +# stock palette whenever Panama was in light mode. +for theme_src in "$PANAMA_DOT"/vicinae/themes/*.toml; do + [ -e "$theme_src" ] || continue + theme_dst="$VICINAE_THEME_DIR/$(basename "$theme_src")" -if [ -e "$VICINAE_THEME" ]; then - log "Keeping existing Vicinae theme at $VICINAE_THEME" -else - ln -s "$PANAMA_DOT/vicinae/themes/tokyonight-moon.toml" "$VICINAE_THEME" - log "Linked Tokyo Night Moon theme → $VICINAE_THEME" -fi + if [ -L "$theme_dst" ]; then + rm "$theme_dst" + fi + + if [ -e "$theme_dst" ]; then + log "Keeping existing Vicinae theme at $theme_dst" + else + ln -s "$theme_src" "$theme_dst" + log "Linked Vicinae theme → $theme_dst" + fi +done # Panama-native applications live in the user data directory so launchers can # discover them alongside system desktop entries. Keep each authored file in From 0fd59c59e3c81aa1648fc80438bede6a734d9856 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 08:38:54 -0400 Subject: [PATCH 06/20] Answer GNOME's Search panel, and pin the settings navigation wiring GNOME's Search panel configures which applications provide results in gnome-shell's overview and which folders are indexed. gnome-shell does not run here, so reimplementing those switches would store preferences that change nothing. Under Panama searching is the launcher's job and Vicinae carries its own preferences, so the Applications page says where search lives and how to rebind the keys that open it, rather than offering settings that would be inert or duplicated. The nav contract is the more useful half. Adding a settings page means editing four files, and missing any one of them fails quietly in a different way: no sidebar row, a row that silently shows Home, a deep link that redirects to Home, or -- worst -- a missing qmldir entry, which makes the page "not a type" and takes the entire settings window down with it. Nothing at runtime cross-checks the four. Having just added four pages by hand, this checks them statically, and also fails on a page file that exists but is unreachable. Verified it catches a missing qmldir registration and a missing allow-list entry. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../modules/settings/ApplicationsPage.qml | 34 +++++++ tests/quickshell/settings-nav-contract.sh | 89 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100755 tests/quickshell/settings-nav-contract.sh diff --git a/config/dot/quickshell/modules/settings/ApplicationsPage.qml b/config/dot/quickshell/modules/settings/ApplicationsPage.qml index 0dea3cb..b86b9f6 100644 --- a/config/dot/quickshell/modules/settings/ApplicationsPage.qml +++ b/config/dot/quickshell/modules/settings/ApplicationsPage.qml @@ -142,6 +142,40 @@ SettingsPage { } } + // GNOME's Search panel, answered honestly. + // + // It configures which applications provide results in gnome-shell's + // overview and which folders are indexed. gnome-shell does not run here, + // so those settings would do nothing. Under Panama searching is the + // launcher's job, and Vicinae carries its own preferences -- reimplementing + // them here would give two places to change one thing. + SettingsCard { + title: "Search" + subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher." + + TextRow { + label: "Launcher" + detail: SystemSettings.vicinaeActive + ? "Running as a user service" + : "Not running — Super+Shift+R opens the fallback launcher" + value: "Vicinae" + } + + TextRow { + label: "Open search" + detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog" + value: "Super+Space" + } + + ActionRow { + label: "Change these shortcuts" + detail: "Every launcher chord is rebindable, including clipboard history and emoji" + action: "Open keyboard" + divider: false + onTriggered: ShellState.openSettings("shortcuts") + } + } + SettingsCard { title: "User autostart" subtitle: "These desktop entries live in your user configuration. Select a row to toggle it." diff --git a/tests/quickshell/settings-nav-contract.sh b/tests/quickshell/settings-nav-contract.sh new file mode 100755 index 0000000..1eaff61 --- /dev/null +++ b/tests/quickshell/settings-nav-contract.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +# Adding a settings page means editing four separate files, and missing one +# fails quietly rather than loudly: +# +# SettingsSidebar.qml the row you click +# SettingsShell.qml the case that maps that row to a component, AND the +# Component declaration itself +# ShellState.qml the allow-list openSettings() checks -- a page missing +# here silently redirects to Home, so a deep link or a +# search result lands on the wrong page with no error +# modules/settings/qmldir the component registration -- without it the page +# is "not a type" and the whole settings window fails +# to load, taking every other page with it +# +# Nothing at runtime cross-checks the four. This does, statically. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +settings_dir="$repo_dir/config/dot/quickshell/modules/settings" +sidebar="$settings_dir/SettingsSidebar.qml" +shell_file="$settings_dir/SettingsShell.qml" +qmldir="$settings_dir/qmldir" +shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml" + +fail() { + printf 'settings nav contract: %s\n' "$1" >&2 + exit 1 +} + +for required in "$sidebar" "$shell_file" "$qmldir" "$shell_state"; do + [[ -r "$required" ]] || fail "cannot read $required" +done + +# ── Every sidebar row resolves everywhere ──────────────────────────────────── +pages="$(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/')" +[[ -n "$pages" ]] || fail 'no pages found in the sidebar -- this contract is not reading it correctly' + +allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \ + || fail 'could not find the allow-list in ShellState' + +while read -r page; do + [[ -n "$page" ]] || continue + + # Home is the switch's default arm rather than a case, since it is also + # where an unknown page falls back to. + if [[ "$page" != "home" ]]; then + grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \ + || fail "the sidebar offers \"$page\" but SettingsShell has no case for it, so clicking it shows Home" + fi + + grep -qF "\"$page\"" <<<"$allowed_line" \ + || fail "\"$page\" is missing from ShellState's allow-list, so openSettings(\"$page\") silently redirects to Home" +done <<<"$pages" + +# ── Every routed component is declared and registered ──────────────────────── +# The case arms name a Component id; each must have a declaration, and the type +# it instantiates must appear in qmldir. +while read -r component; do + [[ -n "$component" ]] || continue + + declaration="$(grep -oE "Component \{ id: $component; [A-Za-z]+ \{\} \}" "$shell_file")" \ + || fail "SettingsShell routes to \"$component\" but never declares it" + + type_name="$(sed -E 's/.*; ([A-Za-z]+) \{\} \}/\1/' <<<"$declaration")" + grep -qE "^$type_name [0-9.]+ $type_name\.qml$" "$qmldir" \ + || fail "$type_name is not registered in modules/settings/qmldir -- it will fail to load as \"not a type\", and the whole settings window fails with it" + + [[ -r "$settings_dir/$type_name.qml" ]] \ + || fail "$type_name is registered in qmldir but $type_name.qml does not exist" +done < <({ + grep -oE 'case "[a-z-]+": return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/' + grep -oE 'default: return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/' +} | sort -u) + +# ── Every page file is reachable ───────────────────────────────────────────── +# A page nobody can navigate to is dead code that still has to compile. The +# scaffold SettingsPage.qml is the one file here that is a base class rather +# than a page. +while read -r page_file; do + type_name="$(basename "$page_file" .qml)" + [[ "$type_name" == "SettingsPage" ]] && continue + + grep -qE "; $type_name \{\} \}" "$shell_file" \ + || fail "$type_name.qml exists but nothing in SettingsShell instantiates it" +done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml') + +printf 'settings nav contract: PASS\n' From 3ff414fb1bbf43ad3bdd9bfeb2b0a2d8ed657bfc Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 08:43:59 -0400 Subject: [PATCH 07/20] Stop two contracts from driving each other's shell settings-hyprland-write-contract failed in full suite runs and passed on its own, reporting "a typed batch did not reach the compositor" with every value at its default. Both it and settings-commit-reset-contract drive the same harness file, and Quickshell identifies an instance by its config path -- not by the environment it was launched with. So when one run's instance has not fully exited, the other's wait for `ipc show` is satisfied by that instance's target, and the whole contract then talks to a shell it did not start. The two directions fail differently, and the second is the alarming one: The write contract lands on the isolated instance, whose compositor write seam is deliberately stubbed. Its writes go nowhere, which is exactly the symptom above. The commit/reset contract lands on the non-isolated instance and drives the DAILY DESKTOP's real compositor while believing it is isolated. Both now refuse to start while another instance of that harness is alive, and say which hazard they are avoiding rather than failing on an assertion much later. One trap worth naming, since it bit me writing this: `rg -c` prints nothing at all when there are no matches, so an unguarded command substitution yields "" and not "0" -- the first version of the guard fired on a perfectly clean machine. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../settings-commit-reset-contract.sh | 20 ++++++++++++++ .../settings-hyprland-write-contract.sh | 26 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/tests/quickshell/settings-commit-reset-contract.sh b/tests/quickshell/settings-commit-reset-contract.sh index a1e4352..21bf264 100755 --- a/tests/quickshell/settings-commit-reset-contract.sh +++ b/tests/quickshell/settings-commit-reset-contract.sh @@ -61,6 +61,26 @@ restore() { } trap restore EXIT +# This contract shares its harness file with settings-hyprland-write-contract, +# and Quickshell identifies an instance by config path -- so if that run's +# instance is still alive, the IPC wait below is satisfied by ITS target. That +# direction is the dangerous one: this contract believes the compositor seam is +# stubbed, so it would happily drive the DAILY DESKTOP's real compositor while +# reporting isolation. Refuse to start rather than find out. +harness_instances() { + # rg -c prints nothing when there are no matches, so an unguarded + # substitution yields "" rather than "0". + local count + count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)" + printf '%s' "${count:-0}" +} +for _ in $(seq 1 50); do + [[ "$(harness_instances)" == "0" ]] && break + sleep 0.1 +done +[[ "$(harness_instances)" == "0" ]] \ + || fail 'another instance of the settings harness is still running -- this contract would drive it instead of its own isolated one, and that instance may be writing to the real compositor' + XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \ PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" --daemonize >/dev/null for _ in $(seq 1 40); do diff --git a/tests/quickshell/settings-hyprland-write-contract.sh b/tests/quickshell/settings-hyprland-write-contract.sh index 9fa9ad0..d69846f 100755 --- a/tests/quickshell/settings-hyprland-write-contract.sh +++ b/tests/quickshell/settings-hyprland-write-contract.sh @@ -77,6 +77,32 @@ target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0) target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3) target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2) +# settings-commit-reset-contract drives the SAME harness file with +# PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1, where the compositor write seam is +# stubbed out. Quickshell identifies an instance by its config path, so if that +# run's instance has not fully exited, the `ipc show` wait below is satisfied by +# ITS target -- and every write in this contract lands on the isolated instance +# and never reaches the compositor. That is exactly what "a typed batch did not +# reach the compositor" looks like when this fails in a full suite run but +# passes on its own. +# +# So wait for the harness to be clear first, and say so plainly if it is not, +# rather than silently talking to the wrong shell. +harness_instances() { + # rg -c prints nothing at all when there are no matches, so an unguarded + # substitution yields "" rather than "0" and every comparison against a + # count fails. + local count + count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)" + printf '%s' "${count:-0}" +} +for _ in $(seq 1 50); do + [[ "$(harness_instances)" == "0" ]] && break + sleep 0.1 +done +[[ "$(harness_instances)" == "0" ]] \ + || fail 'another instance of the settings harness is still running -- this contract would talk to it instead of its own, and its writes may be deliberately stubbed' + XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null for _ in $(seq 1 40); do if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then From 816ea68f9af1ac9c781b4816dff60edcd8db3840 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 09:51:55 -0400 Subject: [PATCH 08/20] Read DDC/CI from the AUX bus, not the EDID bus With I2C access granted, the helper still found nothing: every connected monitor was probed on the wrong bus. A connector has two. The `ddc` symlink points at the classic I2C line that carries EDID on HDMI and DVI. DisplayPort carries DDC/CI over the AUX channel instead, which appears as a child directory of the connector. Both exist on a DP connector and both resolve, so the wrong choice looks entirely reasonable and simply finds no monitor: this machine's DP-2 has ddc -> i2c-5, where ddcutil reports "No monitor detected", while its AUX child i2c-9 answers VCP 0x10 immediately. This is the guess the previous commit said was unverified, and it was wrong in the way that mattered. Enumerating from sysfs is still right -- it gives the connector name Hyprland uses and skips empty connectors -- but it has to prefer the AUX child and fall back to the symlink. The fixture now mirrors sysfs properly: /sys/class/drm/ is a SYMLINK to the real device directory, and `find` does not follow the path it is given. A fixture built from plain directories passes whether or not the code resolves the symlink first, which is a test that agrees with itself rather than with the kernel. Verified on hardware: the Kuycon P20 reports 100%, accepts 70 and returns to 100, and the media keys move it through codex's OSD path. Both bus-selection mistakes are now caught by the contract. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../dot/quickshell/scripts/panama-brightness | 36 +++++++++++++- .../quickshell/brightness-helper-contract.sh | 47 ++++++++++++++----- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/config/dot/quickshell/scripts/panama-brightness b/config/dot/quickshell/scripts/panama-brightness index 5e3af27..11a5f72 100755 --- a/config/dot/quickshell/scripts/panama-brightness +++ b/config/dot/quickshell/scripts/panama-brightness @@ -57,6 +57,39 @@ has_accessible_bus() { return 1 } +# The I2C bus that carries DDC/CI for one connector. +# +# There are two, and picking the wrong one finds no monitor at all: +# +# DisplayPort carries DDC/CI over the AUX channel. That adapter shows up as a +# child directory of the connector -- /sys/class/drm/card1-DP-2/i2c-9 -- and +# is the one ddcutil talks to. +# +# The connector's `ddc` symlink points at the classic I2C line used for EDID +# on HDMI and DVI. On a DisplayPort connector it still exists and still +# resolves, but nothing answers on it: this machine's DP-2 has ddc -> i2c-5, +# where ddcutil reports "No monitor detected", while i2c-9 answers VCP 0x10 +# immediately. +# +# So prefer the AUX child and fall back to the symlink. Note the readlink: the +# entries under /sys/class/drm are symlinks, and `find` does not follow the path +# it is given, so searching the unresolved path silently finds nothing. +bus_for_connector() { + local path="$1" real aux ddc + + real="$(readlink -f "$path")" + aux="$(find "$real" -maxdepth 1 -name 'i2c-*' -printf '%f' -quit 2>/dev/null)" + if [[ -n "$aux" ]]; then + printf '%s' "${aux#i2c-}" + return 0 + fi + + ddc="$(readlink -f "$path/ddc" 2>/dev/null)" || return 1 + [[ -n "$ddc" ]] || return 1 + ddc="$(basename "$ddc")" + printf '%s' "${ddc#i2c-}" +} + cmd_list() { has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm' @@ -69,8 +102,7 @@ cmd_list() { connector="$(basename "$path")" connector="${connector#card*-}" - bus="$(basename "$(readlink -f "$path/ddc")")" - bus="${bus#i2c-}" + bus="$(bus_for_connector "$path")" [[ "$bus" =~ ^[0-9]+$ ]] || continue # A monitor that does not implement 0x10 is not an error; it simply diff --git a/tests/quickshell/brightness-helper-contract.sh b/tests/quickshell/brightness-helper-contract.sh index 085ae65..62203d4 100755 --- a/tests/quickshell/brightness-helper-contract.sh +++ b/tests/quickshell/brightness-helper-contract.sh @@ -34,15 +34,35 @@ mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c" # Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is # connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and # must never be probed at all. +# A connector has an EDID bus (the `ddc` symlink) and, on DisplayPort, an AUX +# bus that appears as a child directory. DDC/CI rides the AUX channel where one +# exists, and the `ddc` line answers nothing on a DP connector even though it +# still resolves -- so the aux argument here is what a real DisplayPort monitor +# looks like, and omitting it is what HDMI and DVI look like. make_connector() { - local name="$1" bus="$2" status="$3" - mkdir -p "$fixture/drm/$name" - printf '%s\n' "$status" >"$fixture/drm/$name/status" - mkdir -p "$fixture/i2c/i2c-$bus" - ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc" + local name="$1" ddc_bus="$2" status="$3" aux_bus="${4:-}" + local device="$fixture/devices/$name" + + # /sys/class/drm/ is a SYMLINK to the real device directory, and + # this fixture mirrors that rather than using a plain directory. It matters: + # `find` does not follow the path it is given, so code that searches the + # unresolved path finds nothing while appearing to work anywhere the entry + # happens to be a real directory. + mkdir -p "$device" + printf '%s\n' "$status" >"$device/status" + mkdir -p "$fixture/i2c/i2c-$ddc_bus" + ln -sfn "$fixture/i2c/i2c-$ddc_bus" "$device/ddc" + [[ -n "$aux_bus" ]] && mkdir -p "$device/i2c-$aux_bus" + + mkdir -p "$fixture/drm" + ln -sfn "$device" "$fixture/drm/$name" + return 0 } +# DP-2 is the DisplayPort case: its EDID line is bus 5, which answers nothing, +# and its AUX child is bus 9, which does. Choosing bus 5 here finds no monitor +# at all, which is exactly the bug this pins down. make_connector card1-DP-1 4 disconnected -make_connector card1-DP-2 5 connected +make_connector card1-DP-2 5 connected 9 make_connector card1-DP-3 6 connected make_connector card1-HDMI-A-1 7 disconnected @@ -75,7 +95,7 @@ for arg in "$@"; do done case "$bus" in - 5) printf 'VCP 10 C 120 200\n'; exit 0 ;; + 9) printf 'VCP 10 C 120 200\n'; exit 0 ;; *) exit 1 ;; esac STUB @@ -103,8 +123,9 @@ jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing" [[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \ || fail "the connector name must match Hyprland's output name: $listing" -[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \ - || fail "the display was mapped to the wrong I2C bus: $listing" +# The AUX bus, not the EDID bus its `ddc` symlink points at. +[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "9" ]] \ + || fail "the display was mapped to its EDID bus instead of its DisplayPort AUX bus, where nothing answers: $listing" # 120 of a maximum of 200 is 60%. [[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \ @@ -123,12 +144,12 @@ if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then fi # ── Writes scale to the reported maximum ───────────────────────────────────── -run_helper set 5 40 -[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \ +run_helper set 9 40 +[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 80" ]] \ || fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")" -run_helper set 5 500 -[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \ +run_helper set 9 500 +[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 200" ]] \ || fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")" # ── No I2C access explains itself ──────────────────────────────────────────── From 9b2fa80ab7fb75f8ef190a091cb522e11dcd2385 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:08:55 -0400 Subject: [PATCH 09/20] Drop the product branding: this is just Settings It is the system settings app for this desktop, so it is named the way one is. The window is "Settings", the titlebar is "Settings", the wordmark above the search field is gone, the sidebar's last row is "About", and the status line reads "Desktop is healthy". Prose followed the same rule. Forty-odd strings explained what "Panama" does -- "Panama never animates while idle", "Restore Panama defaults", "Panama looks in ~/Pictures/Wallpapers" -- which is how a product describes itself, not how a settings panel describes a setting. They now say what happens. No user-visible "Panama" remains anywhere in the app. Two consequences worth naming: The SUPER+I shortcut's description is user-visible, because the Shortcuts page is generated from it, so that is renamed too. Rebindings are keyed by shipped chord rather than description, so no existing override is orphaned by this. Three contracts matched the window by title and one matched that shortcut by description; all four are updated. There are no Hyprland window rules keyed on the title, so nothing about the window's placement changes. The desktop entry is now Name=Settings, but the FILE keeps its panama-settings name, as does the icon: the dock pins applications by desktop id, and renaming the file would silently unpin it. dock-pins-contract covers exactly that. The dated design docs under docs/superpowers keep the old name. They are a record of what was decided when, and editing them to agree with the present would make them lie about the past. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- README.md | 2 +- config/dot/hypr/DESKTOP-PARITY.md | 2 +- config/dot/hypr/README.md | 16 +++--- config/dot/hypr/keybinds.lua | 2 +- .../quickshell/modules/settings/AboutPage.qml | 49 +++++++++++-------- .../modules/settings/AccessibilityPage.qml | 4 +- .../modules/settings/AppearancePage.qml | 4 +- .../modules/settings/ApplicationsPage.qml | 2 +- .../modules/settings/DesktopPage.qml | 6 +-- .../modules/settings/DesktopPreview.qml | 5 +- .../modules/settings/DisplaysPage.qml | 4 +- .../quickshell/modules/settings/HomePage.qml | 2 +- .../modules/settings/HomePhonePage.qml | 4 +- .../modules/settings/NotificationsPage.qml | 2 +- .../quickshell/modules/settings/PowerPage.qml | 8 +-- .../modules/settings/PrivacyPage.qml | 2 +- .../dot/quickshell/modules/settings/README.md | 2 +- .../modules/settings/RegionPage.qml | 4 +- .../settings/ScreenIntelligencePage.qml | 4 +- .../modules/settings/ServicesPage.qml | 2 +- .../modules/settings/SettingsShell.qml | 2 +- .../modules/settings/SettingsSidebar.qml | 13 +---- .../modules/settings/SettingsWindow.qml | 2 +- .../modules/settings/ShortcutsPage.qml | 2 +- .../modules/settings/WallpaperPicker.qml | 2 +- .../applications/panama-settings.desktop | 6 +-- .../home-phone-settings-contract.sh | 6 +-- tests/quickshell/settings-pages-contract.sh | 4 +- tests/quickshell/settings-window-contract.sh | 12 ++--- 29 files changed, 88 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 041aa0b..120e01c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ same Tokyo Night Moon palette. | Piece | What it is | |---|---| | `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README | -| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI | +| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI | | `config/dot/vicinae/` | Raycast-style launcher, themed | | `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) | | `config/dot/wofi/` | Fallback launcher, in case the shell fails to start | diff --git a/config/dot/hypr/DESKTOP-PARITY.md b/config/dot/hypr/DESKTOP-PARITY.md index 1a3bd56..6d22840 100644 --- a/config/dot/hypr/DESKTOP-PARITY.md +++ b/config/dot/hypr/DESKTOP-PARITY.md @@ -36,7 +36,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0. | Removable media | udiskie plus udisks notifications | Live | | Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live | | Printer administration | CUPS with the `system-config-printer` graphical interface | Live | -| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live | +| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live | ## GNOME extension migration diff --git a/config/dot/hypr/README.md b/config/dot/hypr/README.md index 1c259e0..b147c90 100644 --- a/config/dot/hypr/README.md +++ b/config/dot/hypr/README.md @@ -27,7 +27,7 @@ Don't "fix" them. | File | Contents | |---|---| | `hyprland.lua` | Entry point. Each `require()` is its own error scope | -| `prefs.lua` | Reads the settings file Panama Settings writes. See below | +| `prefs.lua` | Reads the settings file the Settings app writes. See below | | `env.lua` | Environment. Note the uwsm caveat below | | `monitors.lua` | DP-2 geometry, scaling, and the HDR decision | | `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout | @@ -59,7 +59,7 @@ generated elsewhere: `quickshell/scripts/panama-idle` regenerates the hypridle config from the settings store and restarts the daemon. `hypridle.conf` in this directory remains the shipped default and is what runs when the drop-in is not installed; -Panama Settings shows which of the two states you are in rather than presenting +The Settings app shows which of the two states you are in rather than presenting controls that quietly do nothing. Remove the drop-in and go back to the shipped config with: @@ -77,7 +77,7 @@ relationship is: `prefs.get("key", )`, so the config still works standalone with no settings file at all. - **The JSON is the truth.** Hyprland and Quickshell both read it. -- **Panama Settings is the editor.** It writes the file *and* applies the change +- **The Settings app is the editor.** It writes the file *and* applies the change live, so nothing needs a reload and the two sides cannot drift apart. To add an adjustable setting: add an entry to @@ -95,7 +95,7 @@ accepts the config in each of those states. Every bind in `keybinds.lua` goes through a local `bind()` wrapper that substitutes the chord from a stored override, so shortcuts can be moved from -Panama Settings without editing this file. +the Settings app without editing this file. ```lua bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" }) @@ -118,7 +118,7 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap. Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds with dispatcher `__lua` and a bytecode offset as the argument, so a bind without -one has nothing readable beside its chord, and Panama Settings drops it from the +one has nothing readable beside its chord, and the Settings app drops it from the Input & Shortcuts page rather than showing a mystery row. `tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so this cannot regress silently. @@ -275,7 +275,7 @@ The mental model is unchanged from Forge: | `SUPER + V` | Clipboard history | | `SUPER + .` | Emoji picker | | `SUPER + S` | Quick settings | -| `SUPER + I` | Panama Settings | +| `SUPER + I` | Settings | | `SUPER + SHIFT + F` | Start or reveal focus session | | `SUPER + B` | Notification centre | | `SUPER + grave` | Workspace overview | @@ -288,7 +288,7 @@ The mental model is unchanged from Forge: ### Apps `SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator · -`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME +`E` mail · `I` Settings · `CTRL + SHIFT + Esc` system monitor. GNOME Settings remains searchable in Vicinae for hardware and account panels. ## Calendar and notifications @@ -326,7 +326,7 @@ one place. Home and Phone continue the same surface rather than opening extra dashboard windows. Home shows the first four selected favourites at rest and every selected light -when expanded. Use **Panama Settings → Home & Phone** to choose favourites, +when expanded. Use **Settings → Home & Phone** to choose favourites, set Panama-only aliases, and arrange their order. Dragging a brightness control only previews the value; releasing it sends one brightness request. A normal power toggle leaves Home Assistant responsible for restoring its previous diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index bbf74d3..35e2abb 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -80,7 +80,7 @@ bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" }) bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" }) bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" }) bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" }) -bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" }) +bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" }) bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" }) -- ── Launcher ──────────────────────────────────────────────────────────────── diff --git a/config/dot/quickshell/modules/settings/AboutPage.qml b/config/dot/quickshell/modules/settings/AboutPage.qml index 30b266e..dc5015f 100644 --- a/config/dot/quickshell/modules/settings/AboutPage.qml +++ b/config/dot/quickshell/modules/settings/AboutPage.qml @@ -6,7 +6,7 @@ import qs.services SettingsPage { id: root - title: "About Panama" + title: "About" lede: "A curated Hyprland desktop built around focus, speed, and good taste." Component.onCompleted: { @@ -17,7 +17,7 @@ SettingsPage { } SettingsCard { - title: "Panama Desktop" + title: "Desktop" subtitle: "Tokyo Night Moon · Prism glass · native tiling" TextRow { @@ -41,15 +41,37 @@ SettingsPage { } // What GNOME's About panel answers and this page did not: what am I running - // on. The rows come from MachineInfo, except graphics, which is joined from + // on. Rows come from MachineInfo, except graphics, which is joined from // GraphicsDevices rather than read a second time -- two readouts of the same // hardware are two things that can disagree. + // + // The join splices the GPU rows in directly after Processor rather than + // appending them, so the card reads the way fastfetch does: what the system + // is, then what is installed on it, then the hardware underneath. A GPU + // listed after "Disk" reads as an afterthought. + readonly property var machineRows: { + const rows = (MachineInfo.facts ?? []).slice(); + const gpus = GraphicsDevices.devices ?? []; + if (gpus.length === 0) + return rows; + + const graphics = gpus.map((device, index) => ({ + label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics", + value: device.name + })); + + const after = rows.findIndex(row => row.label === "Processor"); + if (after < 0) + return rows.concat(graphics); + return rows.slice(0, after + 1).concat(graphics, rows.slice(after + 1)); + } + SettingsCard { title: "This machine" subtitle: "Hardware and system, as the kernel reports it." Repeater { - model: MachineInfo.facts + model: root.machineRows TextRow { id: machineRow @@ -58,27 +80,12 @@ SettingsPage { label: machineRow.modelData.label value: machineRow.modelData.value - } - } - - Repeater { - model: GraphicsDevices.devices - - TextRow { - id: gpuRow - required property var modelData - required property int index - - label: GraphicsDevices.devices.length > 1 - ? "Graphics " + (gpuRow.index + 1) - : "Graphics" - value: gpuRow.modelData.name - divider: gpuRow.index < GraphicsDevices.devices.length - 1 + divider: machineRow.index < root.machineRows.length - 1 } } TextRow { - visible: MachineInfo.scanned && MachineInfo.facts.length === 0 + visible: MachineInfo.scanned && root.machineRows.length === 0 label: "Hardware" detail: "The system did not report anything readable" value: "Unavailable" diff --git a/config/dot/quickshell/modules/settings/AccessibilityPage.qml b/config/dot/quickshell/modules/settings/AccessibilityPage.qml index 62665c2..3d5adef 100644 --- a/config/dot/quickshell/modules/settings/AccessibilityPage.qml +++ b/config/dot/quickshell/modules/settings/AccessibilityPage.qml @@ -25,14 +25,14 @@ SettingsPage { SettingsCard { title: "Text" - subtitle: "Scales text in applications. Panama's own panels are drawn at their design size, so the shell is unaffected." + subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected." SliderRow { setting: "textScale"; divider: false } } SettingsCard { title: "Motion" - subtitle: "Panama never animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing." + subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing." ToggleRow { setting: "animationsEnabled"; divider: false } } diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 879caef..c2b7a1c 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -46,7 +46,7 @@ SettingsPage { title: "Background" subtitle: Wallpaper.lastError !== "" ? Wallpaper.lastError - : "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." + : "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." WallpaperPicker { id: wallpapers @@ -184,7 +184,7 @@ SettingsPage { SettingsCard { title: "Theme" - subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it." + subtitle: "This desktop has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it." TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" } TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false } diff --git a/config/dot/quickshell/modules/settings/ApplicationsPage.qml b/config/dot/quickshell/modules/settings/ApplicationsPage.qml index b86b9f6..06c4a6b 100644 --- a/config/dot/quickshell/modules/settings/ApplicationsPage.qml +++ b/config/dot/quickshell/modules/settings/ApplicationsPage.qml @@ -209,7 +209,7 @@ SettingsPage { SettingsCard { title: "Compositor autostart" - subtitle: "Panama starts these from Hyprland configuration. They are read-only here." + subtitle: "These are started from the Hyprland configuration. They are read-only here." TextRow { visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0 diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index 8f6524a..972e083 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -47,7 +47,7 @@ SettingsPage { SettingsCard { title: "Window layout" - subtitle: "Panama follows the Forge mental model with native Hyprland tiling." + subtitle: "Follows the Forge mental model, with native Hyprland tiling." // These were two read-only rows reporting "Tiling" and "Dynamic", which // described settings rather than facts -- both are ordinary Hyprland @@ -121,10 +121,10 @@ SettingsPage { SettingsCard { title: "Reset" - subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed." + subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed." ActionRow { - label: "Restore Panama defaults" + label: "Restore defaults" detail: "Applies immediately, including to the compositor" action: "Restore defaults" divider: false diff --git a/config/dot/quickshell/modules/settings/DesktopPreview.qml b/config/dot/quickshell/modules/settings/DesktopPreview.qml index 67acc0a..a248ffe 100644 --- a/config/dot/quickshell/modules/settings/DesktopPreview.qml +++ b/config/dot/quickshell/modules/settings/DesktopPreview.qml @@ -89,9 +89,12 @@ Rectangle { } } + // The real bar carries a clock here, not a name. Standing in with the + // actual time keeps the preview a picture of this desktop rather than + // a picture of a product. Text { anchors.centerIn: parent - text: "Panama" + text: Qt.formatTime(new Date(), "h:mm") color: Theme.alpha(Theme.fg, 0.45) font.family: Theme.fontFamily font.pixelSize: 9 diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 638eb7b..eff3b72 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -149,7 +149,7 @@ SettingsPage { ActionRow { visible: Displays.isOverridden(root.monitor ? root.monitor.name : "") label: "Using a custom display setting" - detail: "Forget it to go back to the resolution and scale Panama ships" + detail: "Forget it to go back to the shipped resolution and scale" action: "Forget" divider: false onTriggered: Displays.forget(root.monitor.name) @@ -248,7 +248,7 @@ SettingsPage { SettingsCard { title: "Gaming display policy" - subtitle: "Applied immediately and restored when Panama starts." + subtitle: "Applied immediately and restored when the session starts." ToggleRow { setting: "autoHdr" } ChoiceRow { setting: "vrrPolicy" } diff --git a/config/dot/quickshell/modules/settings/HomePage.qml b/config/dot/quickshell/modules/settings/HomePage.qml index c5ec8d4..83afe61 100644 --- a/config/dot/quickshell/modules/settings/HomePage.qml +++ b/config/dot/quickshell/modules/settings/HomePage.qml @@ -9,7 +9,7 @@ SettingsPage { readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening") title: `${root.greeting}, Gabriel` - lede: "Your Panama desktop is configured and ready." + lede: "Your desktop is configured and ready." SettingsCard { title: SystemSettings.monitorDescription || "Active display" diff --git a/config/dot/quickshell/modules/settings/HomePhonePage.qml b/config/dot/quickshell/modules/settings/HomePhonePage.qml index 39fab71..c97641c 100644 --- a/config/dot/quickshell/modules/settings/HomePhonePage.qml +++ b/config/dot/quickshell/modules/settings/HomePhonePage.qml @@ -202,7 +202,7 @@ SettingsPage { SettingRow { label: "Private configuration" - detail: "Saved with owner-only permissions in Panama's private environment file" + detail: "Saved with owner-only permissions in a private environment file" controlWidth: 216 Row { @@ -239,7 +239,7 @@ SettingsPage { SettingRow { label: "Light catalog" - detail: "Panama reads light state through the Home Assistant helper." + detail: "Light state is read through the Home Assistant helper." divider: false controlWidth: 176 diff --git a/config/dot/quickshell/modules/settings/NotificationsPage.qml b/config/dot/quickshell/modules/settings/NotificationsPage.qml index d3b9cc3..2c3873e 100644 --- a/config/dot/quickshell/modules/settings/NotificationsPage.qml +++ b/config/dot/quickshell/modules/settings/NotificationsPage.qml @@ -24,7 +24,7 @@ SettingsPage { TextRow { label: "Notification history" - detail: "Live notifications retained by Panama" + detail: "Live notifications retained by the shell" value: `${Notifs.history.length} items` } diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml index a4727b1..78a358d 100644 --- a/config/dot/quickshell/modules/settings/PowerPage.qml +++ b/config/dot/quickshell/modules/settings/PowerPage.qml @@ -20,8 +20,8 @@ SettingsPage { SettingsCard { title: "Idle behaviour" subtitle: IdleLock.managed - ? "Panama is managing hypridle. Changes take effect immediately." - : "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable." + ? "Idle timings are managed here. Changes take effect immediately." + : "hypridle is running its shipped configuration. Turn on management below to make these adjustable." SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" } SliderRow { setting: "lockMinutes"; zeroLabel: "Never" } @@ -41,10 +41,10 @@ SettingsPage { SettingsCard { title: "Management" - subtitle: "Panama generates hypridle's configuration into your state directory and points the service at it with a systemd drop-in. ~/.config/hypr is a symlink into the Panama repository, so the shipped configuration cannot be rewritten in place." + subtitle: "hypridle's configuration is generated into your state directory and the service is pointed at it with a systemd drop-in. ~/.config/hypr is a symlink into the configuration repository, so the shipped file cannot be rewritten in place." SettingRow { - label: "Let Panama manage idle timings" + label: "Manage idle timings here" detail: IdleLock.serviceState === "active" ? "hypridle is running" : "hypridle is " + IdleLock.serviceState diff --git a/config/dot/quickshell/modules/settings/PrivacyPage.qml b/config/dot/quickshell/modules/settings/PrivacyPage.qml index 594964c..c56af1a 100644 --- a/config/dot/quickshell/modules/settings/PrivacyPage.qml +++ b/config/dot/quickshell/modules/settings/PrivacyPage.qml @@ -90,7 +90,7 @@ SettingsPage { SettingsCard { title: "Owned by Fedora" - subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. Panama does not offer them as switches, because storing that preference here would change nothing." + subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing." ActionRow { label: "File history & trash" diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md index 0b63665..31dc5ea 100644 --- a/config/dot/quickshell/modules/settings/README.md +++ b/config/dot/quickshell/modules/settings/README.md @@ -1,4 +1,4 @@ -# Panama Settings +# Settings The control centre for everything Panama owns. Anything the system owns — hardware, accounts, printers — is delegated to GNOME Settings and labelled as diff --git a/config/dot/quickshell/modules/settings/RegionPage.qml b/config/dot/quickshell/modules/settings/RegionPage.qml index b86812a..35093e8 100644 --- a/config/dot/quickshell/modules/settings/RegionPage.qml +++ b/config/dot/quickshell/modules/settings/RegionPage.qml @@ -55,11 +55,11 @@ SettingsPage { SettingsCard { title: "Formats" - subtitle: "Dates, times, and numbers follow the language above. Panama's own clock formatting is on the Appearance page." + subtitle: "Dates, times, and numbers follow the language above. The desktop's own clock formatting is on the Appearance page." ActionRow { label: "Clock and date display" - detail: "How Panama itself shows the time" + detail: "How the desktop itself shows the time" action: "Open appearance" onTriggered: ShellState.openSettings("appearance") } diff --git a/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml b/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml index 82a7785..4af33c3 100644 --- a/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml +++ b/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml @@ -18,7 +18,7 @@ SettingsPage { SettingsCard { title: "Read anything on screen" - subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions." + subtitle: "Select a region, window, or display. It is recognized locally, with clean follow-up actions." SettingRow { icon: "󰗊" @@ -52,7 +52,7 @@ SettingsPage { SettingsCard { title: "Local recognition" - subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result." + subtitle: "The screen image stays in a local cache and is deleted when you dismiss the result." TextRow { label: "Text recognition" diff --git a/config/dot/quickshell/modules/settings/ServicesPage.qml b/config/dot/quickshell/modules/settings/ServicesPage.qml index ea4129e..e4c32d2 100644 --- a/config/dot/quickshell/modules/settings/ServicesPage.qml +++ b/config/dot/quickshell/modules/settings/ServicesPage.qml @@ -120,7 +120,7 @@ SettingsPage { SettingsCard { title: "Fedora system settings" - subtitle: "Panels Panama does not own, because they configure system services rather than the desktop. Each row opens the panel that actually owns it. Printers and online accounts live with the rest of the network hardware, on Network & Devices." + subtitle: "Panels this app does not own, because they configure system services rather than the desktop. Each row opens the panel that actually owns it. Printers and online accounts live with the rest of the network hardware, on Network & Devices." // This was one row listing five subjects and opening the network panel // regardless. Naming a panel and then not opening it is worse than not diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 10ac867..28c3be6 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -40,7 +40,7 @@ Rectangle { anchors.left: parent.left anchors.leftMargin: 18 anchors.verticalCenter: parent.verticalCenter - text: "Panama Settings" + text: "Settings" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index a7593cb..997df08 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -40,7 +40,7 @@ Rectangle { { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, { page: "applications", label: "Applications", icon: "\u{F003B}" }, { page: "services", label: "Startup & Services", icon: "\u{F0493}" }, - { page: "about", label: "About Panama", icon: "\u{F02FD}" } + { page: "about", label: "About", icon: "\u{F02FD}" } ] width: 272 @@ -59,15 +59,6 @@ Rectangle { height: implicitHeight spacing: 12 - Text { - text: "PANAMA" - color: Theme.fg - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeLarge - font.weight: Font.DemiBold - font.letterSpacing: 2.2 - } - Rectangle { width: parent.width height: 36 @@ -317,7 +308,7 @@ Rectangle { anchors.left: parent.left anchors.leftMargin: 36 anchors.verticalCenter: parent.verticalCenter - text: "Panama desktop is healthy" + text: "Desktop is healthy" color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall diff --git a/config/dot/quickshell/modules/settings/SettingsWindow.qml b/config/dot/quickshell/modules/settings/SettingsWindow.qml index d10b50f..1c58eac 100644 --- a/config/dot/quickshell/modules/settings/SettingsWindow.qml +++ b/config/dot/quickshell/modules/settings/SettingsWindow.qml @@ -8,7 +8,7 @@ FloatingWindow { readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics - title: "Panama Settings" + title: "Settings" visible: ShellState.settingsOpen implicitWidth: 1120 implicitHeight: 760 diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml index aa9296a..38453d7 100644 --- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -150,7 +150,7 @@ SettingsPage { SettingsCard { visible: Object.keys(Keybinds.overrides).length > 0 title: "Changed shortcuts" - subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration." + subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration." ActionRow { label: "Restore every shipped shortcut" diff --git a/config/dot/quickshell/modules/settings/WallpaperPicker.qml b/config/dot/quickshell/modules/settings/WallpaperPicker.qml index ad0d39b..4ec2a2f 100644 --- a/config/dot/quickshell/modules/settings/WallpaperPicker.qml +++ b/config/dot/quickshell/modules/settings/WallpaperPicker.qml @@ -156,7 +156,7 @@ Item { width: parent.width - 40 horizontalAlignment: Text.AlignHCenter wrapMode: Text.WordWrap - text: "No images found. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." + text: "No images found. Looked in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall diff --git a/config/local/share/applications/panama-settings.desktop b/config/local/share/applications/panama-settings.desktop index f4c2713..d3864df 100644 --- a/config/local/share/applications/panama-settings.desktop +++ b/config/local/share/applications/panama-settings.desktop @@ -1,11 +1,11 @@ [Desktop Entry] Type=Application -Name=Panama Settings +Name=Settings GenericName=System Settings -Comment=Configure the Panama Hyprland desktop +Comment=Configure this desktop Exec=qs ipc call settings open Icon=panama-settings Terminal=false StartupNotify=false Categories=Settings; -Keywords=Panama;Hyprland;Display;Dock;Notifications;Shortcuts;Services; +Keywords=Settings;Preferences;Configuration;Display;Sound;Network;Keyboard;Mouse;Privacy;Power;Shortcuts; diff --git a/tests/quickshell/home-phone-settings-contract.sh b/tests/quickshell/home-phone-settings-contract.sh index 352b66d..01f756b 100755 --- a/tests/quickshell/home-phone-settings-contract.sh +++ b/tests/quickshell/home-phone-settings-contract.sh @@ -288,18 +288,18 @@ shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')" [[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process' for _ in $(seq 1 40); do if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ - '[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null; then + '[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then break fi sleep 0.1 done /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ - '[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \ + '[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \ || fail 'the branch shell did not own exactly one tiled Panama Settings client' if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \ - '.[] | select(.pid == $pid and .title == "Panama Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')" + '.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')" [[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry' grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH" fi diff --git a/tests/quickshell/settings-pages-contract.sh b/tests/quickshell/settings-pages-contract.sh index bdb3fcb..b93592a 100755 --- a/tests/quickshell/settings-pages-contract.sh +++ b/tests/quickshell/settings-pages-contract.sh @@ -195,14 +195,14 @@ for page in "${pages[@]}"; do 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 == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \ + '[.[] | 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 == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \ +/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' diff --git a/tests/quickshell/settings-window-contract.sh b/tests/quickshell/settings-window-contract.sh index 4afad11..3cccf6c 100755 --- a/tests/quickshell/settings-window-contract.sh +++ b/tests/quickshell/settings-window-contract.sh @@ -16,14 +16,14 @@ qs ipc show | rg -q '^target settings$' || fail 'settings IPC target is missing' qs ipc call settings open >/dev/null for _ in $(seq 1 40); do - if hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null; then + if hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null; then break fi sleep 0.1 done -hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \ +hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null \ || fail 'exactly one Settings window did not map' -hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings" and .floating == false)' >/dev/null \ +hyprctl -j clients | jq -e '.[] | select(.title == "Settings" and .floating == false)' >/dev/null \ || fail 'Settings window is not tiled' qs ipc call settings page displays >/dev/null @@ -32,7 +32,7 @@ qs ipc call settings page displays >/dev/null qs ipc call settings page desktop >/dev/null [[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route' -address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Panama Settings") | .address')" +address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Settings") | .address')" hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null for _ in $(seq 1 40); do [[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] && break @@ -42,13 +42,13 @@ done qs ipc call settings open >/dev/null for _ in $(seq 1 40); do - hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null && break + hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null && break sleep 0.1 done qs ipc call settings close >/dev/null for _ in $(seq 1 40); do - if ! hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null; then + if ! hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null; then trap - EXIT printf 'settings window contract: PASS\n' exit 0 From 607acb0a2d7c6124eef433f426430bcb0b924973 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:09:06 -0400 Subject: [PATCH 10/20] Make About answer what fastfetch answers About reported four component versions and, since this morning, a short hardware summary. It now covers what someone actually wants from an About page or a fastfetch run: operating system, model, hostname, kernel, uptime, package counts, shell, locale, windowing system, resolution, processor, graphics, memory, swap, and disk. Rows are ordered the way fastfetch presents them -- what the system is, then what is installed on it, then the hardware underneath -- and the GPU rows are spliced in directly after Processor rather than appended, because a graphics card listed after "Disk" reads as an afterthought. Graphics is still joined from GraphicsDevices rather than read a second time, so the two readouts cannot disagree. Memory is total, not used. About is not a monitor: a "12.4 GiB used" figure is stale before it finishes drawing, and the Home page's vitals readout is where live numbers belong. Disk is the exception because free space does not move while you look at it. Package counting is why this whole helper runs on demand -- rpm -qa on a full workstation is a few thousand lines and takes a moment. Swap and package counts are omitted entirely when they are zero or the tools are absent, rather than reported as "0" or "Unknown". Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- config/dot/quickshell/scripts/panama-about | 88 ++++++++++++++++------ 1 file changed, 67 insertions(+), 21 deletions(-) diff --git a/config/dot/quickshell/scripts/panama-about b/config/dot/quickshell/scripts/panama-about index a902afe..ca9918f 100755 --- a/config/dot/quickshell/scripts/panama-about +++ b/config/dot/quickshell/scripts/panama-about @@ -2,10 +2,11 @@ # What this machine is, as JSON: {label, value} pairs in display order. # -# GNOME's About panel answers "what am I running on" in one screen -- model, -# processor, memory, disk, OS, kernel, windowing system. Panama's About listed -# only its own component versions, which answers a different and much narrower -# question. +# GNOME's About panel answers "what am I running on" in one screen, and +# fastfetch answers it in more detail; this covers both -- model, OS, kernel, +# uptime, package counts, shell, resolution, processor, memory, swap, disk and +# locale. Rows are ordered roughly the way fastfetch presents them: what the +# system is, then what is installed on it, then the hardware underneath. # # Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for # the vitals readout, and naming them again here would be a second source of @@ -24,7 +25,13 @@ emit() { facts+=("$(jq -cn --arg label "$1" --arg value "$2" '{label: $label, value: $value}')") } -# ── Machine ────────────────────────────────────────────────────────────────── +# ── System ─────────────────────────────────────────────────────────────────── +if [[ -r /etc/os-release ]]; then + # Sourced in a subshell so the variables cannot leak into this script. + os="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-$NAME}" )" + emit "Operating system" "$os" +fi + # DMI strings are frequently placeholders ("To Be Filled By O.E.M.", "Default # string"). Those are worse than nothing, so they are filtered out. dmi() { @@ -45,8 +52,55 @@ else fi emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)" +emit "Kernel" "$(uname -r 2>/dev/null)" + +# `uptime -p` already reads as prose ("1 week, 23 hours, 5 minutes"); only the +# leading "up " needs removing. +emit "Uptime" "$(uptime -p 2>/dev/null | sed 's/^up //')" + +# ── Installed ──────────────────────────────────────────────────────────────── +# Counted rather than listed. rpm -qa on a full workstation is a few thousand +# lines and takes a moment, which is part of why this whole helper runs on +# demand rather than at startup. +packages="" +if command -v rpm >/dev/null 2>&1; then + rpm_count="$(rpm -qa 2>/dev/null | wc -l)" + [[ "$rpm_count" -gt 0 ]] && packages="$rpm_count rpm" +fi +if command -v flatpak >/dev/null 2>&1; then + flatpak_count="$(flatpak list --app 2>/dev/null | wc -l)" + if [[ "$flatpak_count" -gt 0 ]]; then + [[ -n "$packages" ]] && packages="$packages, " + packages="$packages$flatpak_count flatpak" + fi +fi +emit "Packages" "$packages" + +# $SHELL is the login shell, which is the one worth reporting -- the shell this +# script happens to run under is an implementation detail of the caller. +if [[ -n "${SHELL:-}" ]]; then + shell_name="$(basename "$SHELL")" + shell_version="$("$SHELL" --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)" + emit "Shell" "${shell_name}${shell_version:+ $shell_version}" +fi + +emit "Locale" "${LANG:-}" + +case "${XDG_SESSION_TYPE:-}" in + wayland) emit "Windowing system" "Wayland" ;; + x11) emit "Windowing system" "X11" ;; +esac + +# ── Hardware ───────────────────────────────────────────────────────────────── +# The focused monitor's actual mode, including the fractional scale, since a +# 4500x3000 panel at 1.5 presents very differently from one at 1. +if command -v hyprctl >/dev/null 2>&1; then + emit "Resolution" "$(hyprctl -j monitors 2>/dev/null \ + | jq -r 'map(select(.focused)) + . | .[0] + | select(. != null) + | "\(.width)x\(.height) @ \(.refreshRate | floor)Hz · scale \(.scale)"' 2>/dev/null)" +fi -# ── Processor ──────────────────────────────────────────────────────────────── cpu="$(awk -F': ' '/^model name/ { print $2; exit }' /proc/cpuinfo 2>/dev/null)" threads="$(nproc 2>/dev/null)" if [[ -n "$cpu" ]]; then @@ -56,26 +110,18 @@ if [[ -n "$cpu" ]]; then emit "Processor" "$cpu" fi -# ── Memory ─────────────────────────────────────────────────────────────────── # Reported as the kernel sees it, which is a little under the sticker figure # because firmware and integrated graphics reserve some before Linux starts. +# +# Total rather than used: About is not a monitor, and a "12.4 GiB used" figure +# is stale before it finishes drawing. The Home page's vitals readout is where +# live numbers belong. emit "Memory" "$(awk '/^MemTotal:/ { printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)" -# ── Storage ────────────────────────────────────────────────────────────────── +swap="$(awk '/^SwapTotal:/ { if ($2 > 0) printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)" +emit "Swap" "$swap" + read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)" [[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size" -# ── Software ───────────────────────────────────────────────────────────────── -if [[ -r /etc/os-release ]]; then - # Sourced in a subshell so the variables cannot leak into this script. - os="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-$NAME}" )" - emit "Operating system" "$os" -fi -emit "Kernel" "$(uname -r 2>/dev/null)" - -case "${XDG_SESSION_TYPE:-}" in - wayland) emit "Windowing system" "Wayland" ;; - x11) emit "Windowing system" "X11" ;; -esac - printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")" From f0321432b4bc4c20d779c513b13f6b8c92362126 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:34:19 -0400 Subject: [PATCH 11/20] Fix dark mode: the GTK theme it asked for does not exist Reported symptom: Electron applications and a Chromium-based browser, all set to follow the system, went light when the desktop went light and never came back. Nothing reported an error, and the portal was serving the correct value the whole time. Cause: ColorScheme set gtk-theme to "Adwaita-dark" for dark and "Adwaita" for light. Neither is installed on Fedora 44 -- only adw-gtk3 and adw-gtk3-dark are. GTK responds to an unknown theme name by falling back to its built-in default, which is LIGHT. So asking for light worked by accident, asking for dark silently produced light, and anything that takes its cue from the GTK theme rather than the portal stayed light no matter what org.freedesktop.appearance said. Verified: the portal emits correctly in both directions, so this was never the portal's fault. Second cause, the mirror of the first: gtk-3.0/settings.ini and gtk-4.0/settings.ini were pinned to adw-gtk3-dark and prefer-dark=1 and never regenerated. Under GNOME that file is ignored because gnome-settings-daemon publishes over XSETTINGS; under Hyprland nothing does, so for GTK3 it is authoritative -- and it contradicted the scheme in light mode. They are now generated from a template on every switch, gitignored as machine state, and seeded by link-dotfiles, the same shape kitty's current-theme.conf already uses. These directories are symlinked into the repository, so writing the live file directly would dirty the working tree on every theme switch. The failure was silent by construction, so it gets a test rather than a comment: gtk-theme-contract asserts every theme name Panama sets is actually installed, and that the generated GTK config agrees with the scheme in both directions. Verified it catches both original bugs. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .gitignore | 4 + .../{settings.ini => settings.ini.template} | 13 ++- config/dot/gtk-4.0/settings.ini | 11 --- config/dot/gtk-4.0/settings.ini.template | 20 +++++ .../dot/quickshell/scripts/panama-theme-apps | 35 ++++++++ .../dot/quickshell/services/ColorScheme.qml | 18 +++- setup/scripts/link-dotfiles | 28 ++++++ tests/quickshell/gtk-theme-contract.sh | 88 +++++++++++++++++++ 8 files changed, 201 insertions(+), 16 deletions(-) rename config/dot/gtk-3.0/{settings.ini => settings.ini.template} (60%) delete mode 100644 config/dot/gtk-4.0/settings.ini create mode 100644 config/dot/gtk-4.0/settings.ini.template create mode 100755 tests/quickshell/gtk-theme-contract.sh diff --git a/.gitignore b/.gitignore index 860119d..ea3e2dc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ __pycache__/ # Generated from the colour scheme; machine state, not configuration. /config/dot/kitty/current-theme.conf + +# Generated from the colour scheme; machine state, not configuration. +/config/dot/gtk-3.0/settings.ini +/config/dot/gtk-4.0/settings.ini diff --git a/config/dot/gtk-3.0/settings.ini b/config/dot/gtk-3.0/settings.ini.template similarity index 60% rename from config/dot/gtk-3.0/settings.ini rename to config/dot/gtk-3.0/settings.ini.template index 02eb639..e4ebce2 100644 --- a/config/dot/gtk-3.0/settings.ini +++ b/config/dot/gtk-3.0/settings.ini.template @@ -1,3 +1,12 @@ +# GENERATED FILE -- edit settings.ini.template instead. +# +# The theme name and dark preference below follow Panama's colour +# scheme, so this file is regenerated on every switch and is not +# committed. Under GNOME, gnome-settings-daemon publishes these over +# XSETTINGS and this file is ignored; under Hyprland there is no +# settings daemon, so for GTK3 it is authoritative -- which is why it +# has to change with the scheme rather than being pinned to dark. +# [Settings] # These values must match `gsettings get org.gnome.desktop.interface ...`. # @@ -6,12 +15,12 @@ # this file becomes authoritative for GTK3 — which is why it previously named # themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything # appearing broken. -gtk-theme-name=adw-gtk3-dark +gtk-theme-name=@GTK_THEME@ gtk-icon-theme-name=Adwaita gtk-font-name=Adwaita Sans 11 gtk-cursor-theme-name=oreo_blue_cursors gtk-cursor-theme-size=24 -gtk-application-prefer-dark-theme=1 +gtk-application-prefer-dark-theme=@PREFER_DARK@ gtk-toolbar-style=GTK_TOOLBAR_ICONS gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR diff --git a/config/dot/gtk-4.0/settings.ini b/config/dot/gtk-4.0/settings.ini deleted file mode 100644 index bb748bb..0000000 --- a/config/dot/gtk-4.0/settings.ini +++ /dev/null @@ -1,11 +0,0 @@ -[Settings] -# libadwaita apps normally take their dark preference from the -# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which -# reads gsettings). This file is the fallback for plain GTK4 apps and for the -# window before the portal answers. -gtk-application-prefer-dark-theme=1 -gtk-theme-name=adw-gtk3-dark -gtk-icon-theme-name=Adwaita -gtk-font-name=Adwaita Sans 11 -gtk-cursor-theme-name=oreo_blue_cursors -gtk-cursor-theme-size=24 diff --git a/config/dot/gtk-4.0/settings.ini.template b/config/dot/gtk-4.0/settings.ini.template new file mode 100644 index 0000000..973a818 --- /dev/null +++ b/config/dot/gtk-4.0/settings.ini.template @@ -0,0 +1,20 @@ +# GENERATED FILE -- edit settings.ini.template instead. +# +# The theme name and dark preference below follow Panama's colour +# scheme, so this file is regenerated on every switch and is not +# committed. Under GNOME, gnome-settings-daemon publishes these over +# XSETTINGS and this file is ignored; under Hyprland there is no +# settings daemon, so for GTK3 it is authoritative -- which is why it +# has to change with the scheme rather than being pinned to dark. +# +[Settings] +# libadwaita apps normally take their dark preference from the +# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which +# reads gsettings). This file is the fallback for plain GTK4 apps and for the +# window before the portal answers. +gtk-application-prefer-dark-theme=@PREFER_DARK@ +gtk-theme-name=@GTK_THEME@ +gtk-icon-theme-name=Adwaita +gtk-font-name=Adwaita Sans 11 +gtk-cursor-theme-name=oreo_blue_cursors +gtk-cursor-theme-size=24 diff --git a/config/dot/quickshell/scripts/panama-theme-apps b/config/dot/quickshell/scripts/panama-theme-apps index 8b2708e..a33ff31 100755 --- a/config/dot/quickshell/scripts/panama-theme-apps +++ b/config/dot/quickshell/scripts/panama-theme-apps @@ -11,6 +11,11 @@ # Terminals are the notable exception -- they predate the standard and carry # their own palettes. kitty is handled here. # +# GTK3 is the other one. Under GNOME, gnome-settings-daemon publishes the theme +# over XSETTINGS; under Hyprland nothing does, so ~/.config/gtk-3.0/settings.ini +# is authoritative for GTK3 applications. Pinned to dark, it contradicted the +# scheme in light mode, so it is generated from a template here instead. +# # panama-theme-apps dark|light # # kitty gets it twice: the generated include file so terminals opened later @@ -26,6 +31,36 @@ case "$scheme" in *) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;; esac +# ── GTK ────────────────────────────────────────────────────────────────────── +# adw-gtk3, not Adwaita: no Adwaita GTK theme is installed on Fedora 44, and +# naming a theme that does not exist makes GTK fall back to its light default -- +# which made dark mode silently produce light windows. +if [[ "$scheme" == "light" ]]; then + gtk_theme="adw-gtk3" + prefer_dark=0 +else + gtk_theme="adw-gtk3-dark" + prefer_dark=1 +fi + +status_gtk="skipped" +for gtk_version in 3.0 4.0; do + gtk_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtk-$gtk_version" + template="$gtk_dir/settings.ini.template" + [[ -r "$template" ]] || continue + + # Written atomically: a GTK application starting mid-write would otherwise + # read a truncated file and fall back to defaults. + if sed -e "s/@GTK_THEME@/$gtk_theme/" -e "s/@PREFER_DARK@/$prefer_dark/" "$template" \ + >"$gtk_dir/settings.ini.tmp" 2>/dev/null \ + && mv "$gtk_dir/settings.ini.tmp" "$gtk_dir/settings.ini" 2>/dev/null; then + status_gtk="written" + else + rm -f "$gtk_dir/settings.ini.tmp" + status_gtk="failed" + fi +done + kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty" theme_file="$kitty_dir/themes/tokyonight-moon.conf" [[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf" diff --git a/config/dot/quickshell/services/ColorScheme.qml b/config/dot/quickshell/services/ColorScheme.qml index 4f09838..2de1c7b 100644 --- a/config/dot/quickshell/services/ColorScheme.qml +++ b/config/dot/quickshell/services/ColorScheme.qml @@ -78,9 +78,21 @@ Singleton { root.lastError = ""; const scheme = root.dark ? "prefer-dark" : "prefer-light"; - // Adwaita's light and dark are the same theme; only the preference and - // the -dark suffix differ, so applications that honour either agree. - const gtkTheme = root.dark ? "Adwaita-dark" : "Adwaita"; + + // adw-gtk3, not Adwaita. This is the bug that made dark mode look + // broken while light mode looked fine: + // + // Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora + // 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does + // not exist makes GTK fall back to its built-in default, which is + // LIGHT. So asking for light accidentally worked, asking for dark + // silently produced light, and applications that take their cue from + // the GTK theme rather than the portal -- Chromium and Electron, when + // built against GTK -- stayed light no matter what the portal said. + // + // gtk-theme-contract asserts these names are actually installed, + // because the failure mode is silent in exactly this way. + const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3"; const commands = [ ["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme], diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles index b3e0fde..352480b 100755 --- a/setup/scripts/link-dotfiles +++ b/setup/scripts/link-dotfiles @@ -70,6 +70,34 @@ for dir in "${dirs[@]}"; do log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir" done +# GTK3 has no include mechanism, so its settings.ini is generated whole from a +# template rather than layered. Without this, a fresh checkout has a template +# and no settings.ini, and GTK3 applications fall back to their built-in theme. +# panama-theme-apps rewrites both files on every scheme change after this. +for gtk_version in 3.0 4.0; do + gtk_template="$PANAMA_DOT/gtk-$gtk_version/settings.ini.template" + gtk_settings="$PANAMA_DOT/gtk-$gtk_version/settings.ini" + [ -r "$gtk_template" ] || continue + if [ -e "$gtk_settings" ]; then + log "Keeping existing GTK settings at $gtk_settings" + else + gtk_scheme="dark" + gtk_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" + if [ -r "$gtk_prefs" ]; then + gtk_stored="$(jq -r '.colorScheme // "dark"' "$gtk_prefs" 2>/dev/null || echo dark)" + [ "$gtk_stored" = "light" ] && gtk_scheme="light" + fi + if [ "$gtk_scheme" = "light" ]; then + gtk_name="adw-gtk3"; gtk_dark=0 + else + gtk_name="adw-gtk3-dark"; gtk_dark=1 + fi + sed -e "s/@GTK_THEME@/$gtk_name/" -e "s/@PREFER_DARK@/$gtk_dark/" \ + "$gtk_template" > "$gtk_settings" + log "Generated GTK $gtk_version settings ($gtk_name) → $gtk_settings" + fi +done + # kitty.conf ends with `include current-theme.conf`, and that file is generated # from the desktop colour scheme rather than committed -- it is machine state. # A fresh checkout therefore has no such file, and kitty starts by complaining diff --git a/tests/quickshell/gtk-theme-contract.sh b/tests/quickshell/gtk-theme-contract.sh new file mode 100755 index 0000000..07e0b26 --- /dev/null +++ b/tests/quickshell/gtk-theme-contract.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash + +# Every GTK theme name Panama sets must be a theme that is actually installed. +# +# This exists because of a bug that was invisible for weeks. ColorScheme set +# gtk-theme to "Adwaita-dark" for dark and "Adwaita" for light. Neither is +# installed on Fedora 44 -- only adw-gtk3 and adw-gtk3-dark are -- and GTK +# responds to an unknown theme name by silently falling back to its built-in +# default, which is LIGHT. +# +# So light mode appeared to work, dark mode produced light windows, and nothing +# anywhere reported an error. Applications that take their cue from the GTK +# theme rather than the portal -- Chromium and Electron among them -- were stuck +# light with no way to diagnose it from inside the application. +# +# The failure is silent by construction, so it needs a test rather than a +# comment. Checks the compositor-facing setting and the generated GTK config +# agree, and that both name something real. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +color_scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml" +theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps" + +fail() { + printf 'gtk theme contract: %s\n' "$1" >&2 + exit 1 +} + +theme_installed() { + local name="$1" dir + for dir in /usr/share/themes "$HOME/.themes" "$HOME/.local/share/themes"; do + [[ -d "$dir/$name/gtk-3.0" ]] && return 0 + done + return 1 +} + +# ── The names ColorScheme sets must exist ──────────────────────────────────── +names="$(grep -oE 'root\.dark \? "[a-zA-Z0-9-]+" : "[a-zA-Z0-9-]+"' "$color_scheme" \ + | grep -oE '"[a-zA-Z0-9-]+"' | tr -d '"' | grep -E '^adw-|^Adwaita' | sort -u)" +[[ -n "$names" ]] || fail 'could not find the GTK theme names in ColorScheme.qml -- this contract is not reading it correctly' + +while read -r name; do + [[ -n "$name" ]] || continue + theme_installed "$name" \ + || fail "ColorScheme sets gtk-theme to \"$name\", which is not installed. GTK falls back to its light default when a theme is missing, so this produces light windows in dark mode with no error anywhere." +done <<<"$names" + +# ── The generated GTK config must agree, in both directions ────────────────── +# Generated into a fixture rather than the live config, so running this cannot +# retheme the desktop it is running on. +fixture="$(mktemp -d /tmp/panama-gtk-theme.XXXXXX)" +trap 'rm -rf "$fixture"' EXIT + +for version in 3.0 4.0; do + mkdir -p "$fixture/gtk-$version" + cp "$repo_dir/config/dot/gtk-$version/settings.ini.template" "$fixture/gtk-$version/" \ + || fail "gtk-$version has no settings.ini.template -- the generated file would never be produced" +done + +for scheme in dark light; do + XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1 + + for version in 3.0 4.0; do + generated="$fixture/gtk-$version/settings.ini" + [[ -r "$generated" ]] || fail "gtk-$version settings.ini was not generated for $scheme" + + grep -q '@GTK_THEME@\|@PREFER_DARK@' "$generated" \ + && fail "gtk-$version settings.ini still contains an unsubstituted placeholder for $scheme" + + theme="$(sed -n 's/^gtk-theme-name=//p' "$generated")" + prefer="$(sed -n 's/^gtk-application-prefer-dark-theme=//p' "$generated")" + + theme_installed "$theme" \ + || fail "gtk-$version settings.ini names \"$theme\" for $scheme, which is not installed" + + if [[ "$scheme" == "dark" ]]; then + [[ "$prefer" == "1" ]] || fail "gtk-$version asks for prefer-dark=$prefer in dark mode" + [[ "$theme" == *dark* ]] || fail "gtk-$version uses \"$theme\" in dark mode, which is not a dark theme" + else + [[ "$prefer" == "0" ]] || fail "gtk-$version asks for prefer-dark=$prefer in light mode" + [[ "$theme" != *dark* ]] || fail "gtk-$version uses \"$theme\" in light mode, which is a dark theme" + fi + done +done + +printf 'gtk theme contract: PASS\n' From e4409ed6aa2200ba212f2e78e7554b3bb4f6b37a Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:34:41 -0400 Subject: [PATCH 12/20] Surface the login keyring, and offer to unlock it The keyring is already unlocked at sign-in exactly as GNOME does it -- pam_gnome_keyring is in GDM's stack and the journal confirms it works ("gnome-keyring-daemon started properly and unlocked keyring"). So there was no configuration bug to fix. What a bare Hyprland session lacks is anywhere to see when that has stopped being true. It stops being true rarely and expensively. gnome-keyring-daemon crashed once on this machine -- an upstream abort in service_method_open_session, with a core dump -- and D-Bus then activated a replacement. That replacement never received the login password, so the keyring was locked in the middle of a session that had unlocked it correctly at login. Nothing announces this. What you see instead is a mail account that will not authenticate, a git push that cannot find its key, or an integration reporting "not configured", none of which mention keyrings. That is the same root cause as the Home Assistant token failure earlier. Privacy & Security now shows the state, offers an Unlock action that raises the standard password dialog, and reports when the daemon holding your secrets is a D-Bus replacement rather than PAM's -- because a replacement that is currently unlocked was unlocked by hand and will not survive a restart. The password never passes through Panama. The contract stubs the secret service rather than touching the real one: locking the login keyring breaks every saved password on the machine and can only be undone by typing the password into a dialog, so it is not something a test suite may do to a daily driver. Verified it catches a helper that misreports locked as unlocked, and one that crashes instead of reporting a missing service. Worth recording: a locked keyring makes a NON-INTERACTIVE caller appear to hang. It is not hung -- it is waiting on a dialog nobody is looking at, which is exactly how the earlier secret-tool investigation lost an hour. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../modules/settings/PrivacyPage.qml | 65 ++++++++- config/dot/quickshell/scripts/panama-keyring | 138 ++++++++++++++++++ config/dot/quickshell/services/Keyring.qml | 83 +++++++++++ tests/quickshell/keyring-helper-contract.sh | 108 ++++++++++++++ 4 files changed, 393 insertions(+), 1 deletion(-) create mode 100755 config/dot/quickshell/scripts/panama-keyring create mode 100644 config/dot/quickshell/services/Keyring.qml create mode 100755 tests/quickshell/keyring-helper-contract.sh diff --git a/config/dot/quickshell/modules/settings/PrivacyPage.qml b/config/dot/quickshell/modules/settings/PrivacyPage.qml index c56af1a..59392c7 100644 --- a/config/dot/quickshell/modules/settings/PrivacyPage.qml +++ b/config/dot/quickshell/modules/settings/PrivacyPage.qml @@ -23,7 +23,12 @@ SettingsPage { ? "Screen lock, device access, and a machine whose security settings all check out." : "Screen lock, which applications can see you, and how this machine is protected." - Component.onCompleted: if (!DeviceSecurity.scanned) DeviceSecurity.refresh() + Component.onCompleted: { + if (!DeviceSecurity.scanned) + DeviceSecurity.refresh(); + if (!Keyring.scanned) + Keyring.refresh(); + } SettingsCard { title: "Screen lock" @@ -33,6 +38,64 @@ SettingsPage { ToggleRow { setting: "lockOnSleep"; divider: false } } + // The login keyring, which nothing else surfaces. + // + // It is unlocked at sign-in by PAM, so this card normally just confirms + // that. It earns its place on the rare occasion it is not: a locked keyring + // breaks saved passwords everywhere at once, and does it without ever + // saying the word "keyring" -- you get a mail account that will not + // authenticate and a git push that cannot find its key. + SettingsCard { + visible: Keyring.scanned + title: "Saved passwords" + subtitle: !Keyring.available + ? "No secret service is answering, so saved passwords are unavailable." + : Keyring.locked + ? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons." + : "The login keyring is unlocked, as it is after every normal sign-in." + + // Two rows rather than one with a conditional button: a locked keyring + // needs an action, an unlocked one is a statement of fact, and ActionRow + // and TextRow already say exactly those two things. + ActionRow { + visible: Keyring.available && Keyring.locked + label: "Login keyring" + detail: "Unlock to restore access to stored passwords and keys" + action: Keyring.unlocking ? "Waiting…" : "Unlock" + enabled: !Keyring.unlocking + divider: Keyring.replacementDaemon || Keyring.lastError !== "" + onTriggered: Keyring.unlock() + } + + TextRow { + visible: !(Keyring.available && Keyring.locked) + label: "Login keyring" + detail: Keyring.available + ? "Unlocked at sign-in by PAM, the same way GNOME does it" + : "No secret service is answering on this session" + value: Keyring.available ? "Unlocked" : "Unavailable" + divider: Keyring.replacementDaemon || Keyring.lastError !== "" + } + + // Only shown when it is true, because it is a diagnostic rather than a + // setting: it means the daemon holding your secrets is not the one PAM + // started, so whatever unlocked it will not survive a restart. + SettingRow { + visible: Keyring.replacementDaemon + label: "Keyring service" + detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks." + value: "Replaced" + divider: Keyring.lastError !== "" + } + + SettingRow { + visible: Keyring.lastError !== "" + label: "Keyring problem" + detail: Keyring.lastError + divider: false + } + } + SettingsCard { title: "Camera & microphone" subtitle: PrivacyState.anyActive diff --git a/config/dot/quickshell/scripts/panama-keyring b/config/dot/quickshell/scripts/panama-keyring new file mode 100755 index 0000000..c7feef8 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-keyring @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +"""The login keyring's lock state, and a way to unlock it. + +Why this exists +--------------- +GNOME unlocks the login keyring at sign-in through pam_gnome_keyring, and so +does this desktop -- the PAM stack is GDM's and it works. What GNOME also has, +and a bare Hyprland session does not, is anywhere to SEE that it failed. + +It does fail, rarely. gnome-keyring-daemon can crash (an upstream abort in +service_method_open_session, seen once here), and when it does, D-Bus activates +a replacement. That replacement never received the login password, so the login +keyring comes back LOCKED in the middle of a session that unlocked it correctly +at login. Everything that stores a secret then starts failing in ways that do +not mention keyrings at all: a mail client that will not authenticate, a git +push that cannot find its key, an integration that reports "not configured". + +So this reports the state plainly and offers the one action that fixes it. + +Unlocking prompts +----------------- +`unlock` asks the Secret Service to unlock, which raises the gcr password +dialog. That is deliberate: the password is not ours to store or handle, and it +never passes through this script. The dialog is the same one GNOME shows. + +Note that a locked keyring makes a NON-INTERACTIVE caller appear to hang -- it +is not hung, it is waiting for a dialog nobody is looking at. That is worth +knowing before debugging one for an hour. + +Usage: + panama-keyring status -> {"available", "locked", "collections", "daemon"} + panama-keyring unlock -> raises the password prompt; prints the new state +""" + +import json +import os +import re +import sys + + +def daemon_origin(): + """Whether the running secrets daemon came from PAM or from D-Bus activation. + + A D-Bus-activated daemon is the signature of the crash-and-replace case + above: it is the one that cannot have the login password. PAM's daemon lives + outside the app slice, so the cgroup tells the two apart. + """ + try: + for pid in os.listdir("/proc"): + if not pid.isdigit(): + continue + try: + with open(f"/proc/{pid}/cmdline", "rb") as handle: + cmdline = handle.read().decode("utf-8", "replace") + except OSError: + continue + if "gnome-keyring-daemon" not in cmdline: + continue + try: + with open(f"/proc/{pid}/cgroup", "r") as handle: + cgroup = handle.read() + except OSError: + return "unknown" + if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup): + return "dbus" + return "pam" + except OSError: + pass + return "none" + + +def load_service(): + import gi + + gi.require_version("Secret", "1") + from gi.repository import Secret + + return Secret, Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None) + + +def report(service, Secret): + collections = [ + {"label": c.get_label(), "locked": c.get_locked()} + for c in service.get_collections() + ] + # The login keyring is the one that matters; the others are per-application + # stores that manage their own unlocking. + login = next((c for c in collections if c["label"] == "Login"), None) + return { + "available": True, + "locked": bool(login["locked"]) if login else False, + "hasLogin": login is not None, + "collections": collections, + "daemon": daemon_origin(), + "error": "", + } + + +def main(): + action = sys.argv[1] if len(sys.argv) > 1 else "status" + if action not in ("status", "unlock"): + print("usage: panama-keyring [status|unlock]", file=sys.stderr) + return 2 + + try: + Secret, service = load_service() + except Exception as error: # noqa: BLE001 - any failure here is "no keyring" + # No Secret Service at all is a legitimate state, not a crash: report it + # so the UI can say so instead of showing an empty card. + print(json.dumps({ + "available": False, "locked": False, "hasLogin": False, + "collections": [], "daemon": daemon_origin(), + "error": f"The secret service is not answering: {error}", + })) + return 0 + + if action == "unlock": + login = next( + (c for c in service.get_collections() if c.get_label() == "Login"), None) + if login is not None and login.get_locked(): + try: + # Blocks until the dialog is answered or dismissed. + service.unlock_sync([login], None) + except Exception as error: # noqa: BLE001 + state = report(service, Secret) + state["error"] = f"The keyring was not unlocked: {error}" + print(json.dumps(state)) + return 0 + # The collection object caches its state; re-read it. + Secret, service = load_service() + + print(json.dumps(report(service, Secret))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/dot/quickshell/services/Keyring.qml b/config/dot/quickshell/services/Keyring.qml new file mode 100644 index 0000000..9865976 --- /dev/null +++ b/config/dot/quickshell/services/Keyring.qml @@ -0,0 +1,83 @@ +pragma Singleton + +// The login keyring's lock state. +// +// The keyring is unlocked at sign-in by pam_gnome_keyring, exactly as it is +// under GNOME. What a bare Hyprland session lacks is anywhere to see when that +// has stopped being true. +// +// It stops being true rarely but expensively: gnome-keyring-daemon can crash, +// D-Bus activates a replacement, and the replacement never received the login +// password -- so the keyring is locked in the middle of a session that unlocked +// it correctly. Nothing announces this. What the user sees instead is a mail +// account that will not authenticate, a git push that cannot find its key, or +// an integration reporting "not configured", none of which mention keyrings. +// +// Checked on demand and after an unlock, not polled: the state changes only +// when a daemon dies or a password is entered. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-keyring" + + property bool available: false + property bool locked: false + property bool scanned: false + property bool unlocking: false + property string lastError: "" + + // "pam" when the daemon that holds the keyring is the one PAM started at + // login, "dbus" when it is a D-Bus-activated replacement -- which is the + // signature of the crash case, and worth showing, because a dbus daemon + // that is currently unlocked was unlocked by hand and will not survive. + property string daemon: "" + + readonly property bool replacementDaemon: root.daemon === "dbus" + + function refresh(): void { + if (!query.running) + query.running = true; + } + + // Raises the standard password dialog. The password never passes through + // Panama -- the Secret Service prompts, the same way it does under GNOME. + function unlock(): void { + if (root.unlocking) + return; + root.unlocking = true; + unlockProcess.running = true; + } + + function absorb(text: string): void { + try { + const parsed = JSON.parse(text); + root.available = parsed.available === true; + root.locked = parsed.locked === true; + root.daemon = String(parsed.daemon ?? ""); + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.available = false; + root.lastError = "Could not read the keyring helper's output."; + console.warn("Keyring: could not parse helper output:", error); + } + root.scanned = true; + } + + Process { + id: query + command: [root.helperPath, "status"] + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + } + + Process { + id: unlockProcess + command: [root.helperPath, "unlock"] + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + onExited: root.unlocking = false + } +} diff --git a/tests/quickshell/keyring-helper-contract.sh b/tests/quickshell/keyring-helper-contract.sh new file mode 100755 index 0000000..12de8a9 --- /dev/null +++ b/tests/quickshell/keyring-helper-contract.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +# panama-keyring reports the login keyring's state, and the Settings page reads +# nothing but its JSON. +# +# The state that matters is LOCKED, and it is also the one that cannot be +# rehearsed on a real desktop: locking the login keyring breaks every saved +# password on the machine and can only be undone by typing the password into a +# dialog. So the secret service is stubbed here instead. Nothing touches the +# real keyring -- this contract is safe to run on the daily driver, which is the +# entire reason it is written this way. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-keyring" + +fail() { + printf 'keyring helper contract: %s\n' "$1" >&2 + exit 1 +} + +stub_dir="$(mktemp -d /tmp/panama-keyring.XXXXXX)" +trap 'rm -rf "$stub_dir"' EXIT + +# A stand-in for the `gi` module the helper imports. PANAMA_KEYRING_FAKE decides +# what the fake service reports, so one stub covers every case. +mkdir -p "$stub_dir/gi/repository" +cat >"$stub_dir/gi/__init__.py" <<'STUB' +def require_version(*_args, **_kwargs): + return None +STUB +cat >"$stub_dir/gi/repository/__init__.py" <<'STUB' +import os + + +class _Collection: + def __init__(self, label, locked): + self._label = label + self._locked = locked + + def get_label(self): + return self._label + + def get_locked(self): + return self._locked + + +class _Service: + def get_collections(self): + mode = os.environ.get("PANAMA_KEYRING_FAKE", "unlocked") + if mode == "nologin": + return [_Collection("Some App", False)] + return [_Collection("Login", mode == "locked"), _Collection("", False)] + + +class _ServiceFactory: + @staticmethod + def get_sync(_flags, _cancellable): + if os.environ.get("PANAMA_KEYRING_FAKE") == "unavailable": + raise RuntimeError("no secret service") + return _Service() + + # unlock_sync is what the `unlock` action calls; record that it was reached. + @staticmethod + def _noop(*_args, **_kwargs): + return None + + +class Secret: + class ServiceFlags: + LOAD_COLLECTIONS = 1 + + Service = _ServiceFactory +STUB + +run() { + PYTHONPATH="$stub_dir" PANAMA_KEYRING_FAKE="$1" python3 "$helper" "${2:-status}" +} + +# ── Unlocked: the normal state after any sign-in ───────────────────────────── +out="$(run unlocked)" +jq -e . >/dev/null 2>&1 <<<"$out" || fail "status did not emit JSON: $out" +jq -e '.available == true and .locked == false and .hasLogin == true' >/dev/null <<<"$out" \ + || fail "an unlocked login keyring was misreported: $out" + +# ── Locked: the state the whole card exists for ────────────────────────────── +out="$(run locked)" +jq -e '.available == true and .locked == true' >/dev/null <<<"$out" \ + || fail "a locked login keyring was not reported as locked: $out" + +# ── No secret service at all is a state, not a crash ───────────────────────── +out="$(run unavailable)" +jq -e . >/dev/null 2>&1 <<<"$out" \ + || fail "a missing secret service produced no JSON, so the page would show nothing: $out" +jq -e '.available == false and .error != ""' >/dev/null <<<"$out" \ + || fail "a missing secret service must be reported with a reason: $out" + +# ── No login keyring: not locked, because there is nothing to lock ─────────── +out="$(run nologin)" +jq -e '.available == true and .hasLogin == false and .locked == false' >/dev/null <<<"$out" \ + || fail "a machine with no login keyring must not report itself locked: $out" + +# ── The daemon origin is reported, since it is the crash diagnostic ────────── +jq -e '.daemon | test("^(pam|dbus|none|unknown)$")' >/dev/null <<<"$(run unlocked)" \ + || fail "the daemon origin must be one of pam/dbus/none/unknown" + +printf 'keyring helper contract: PASS\n' From 52d896b0540e3d9ceffb732216e10c13e415f064 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:34:41 -0400 Subject: [PATCH 13/20] Add an Online Accounts page GNOME Online Accounts is a daemon plus a D-Bus API, and the daemon already runs in this session -- gvfs activates it, and all four accounts on this machine work without gnome-shell involved anywhere. Only the PANEL was GNOME's. The accounts themselves are ordinary D-Bus objects that anything may read and modify. So everything except the initial sign-in is now native: the account list, per-service toggles for mail, calendar, contacts, files, photos, music and chat, and removal. That is the whole Online Accounts panel apart from one OAuth handshake. Signing in is the exception, and only for OAuth providers. The daemon's AddAccount takes credentials as an argument -- it stores them, it does not obtain them -- and the code that runs Google's OAuth exchange lives in libgoa-backend, which Fedora ships without a GIR binding, so it is reachable from C only. Reimplementing it would mean our own Google client credentials. That step is handed to GNOME's panel and the page says so, because a hand-off the user does not expect reads as a bug. Password-based providers (Nextcloud, IMAP, WebDAV) could be added natively later; their credential keys are known now. Accounts needing re-authentication are surfaced first, which turned up something immediately: both Google accounts on this machine report attention_needed, meaning their tokens have expired and they have stopped syncing. GOA has known that all along and nothing outside its own panel ever said so. Every write re-reads the account list rather than assuming it landed. GOA can refuse, and a toggle that springs back is the honest outcome. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../modules/settings/OnlineAccountsPage.qml | 122 +++++++++++++++ .../modules/settings/SettingsShell.qml | 2 + .../modules/settings/SettingsSidebar.qml | 1 + config/dot/quickshell/modules/settings/qmldir | 1 + config/dot/quickshell/scripts/panama-accounts | 145 ++++++++++++++++++ .../quickshell/services/OnlineAccounts.qml | 95 ++++++++++++ config/dot/quickshell/services/ShellState.qml | 2 +- 7 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 config/dot/quickshell/modules/settings/OnlineAccountsPage.qml create mode 100755 config/dot/quickshell/scripts/panama-accounts create mode 100644 config/dot/quickshell/services/OnlineAccounts.qml diff --git a/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml b/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml new file mode 100644 index 0000000..43d7594 --- /dev/null +++ b/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml @@ -0,0 +1,122 @@ +// Online Accounts. +// +// GNOME's panel of the same name, except for one step. The accounts are GNOME +// Online Accounts objects on D-Bus, the daemon already runs here, and listing, +// per-service toggles and removal are all done natively on this page. +// +// Signing in to an OAuth provider is handed to GNOME's panel, because the +// credentials are OAuth tokens and the code that obtains them ships without a +// scriptable binding. That is stated on the page rather than hidden behind a +// button that looks native, because a hand-off the user does not expect reads +// as a bug. +// +// Accounts needing attention are surfaced first. GOA knows when a token has +// expired and nothing outside its own panel says so, which is how an account +// quietly stops syncing for weeks. + +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + title: "Online Accounts" + lede: OnlineAccounts.attentionCount > 0 + ? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1 + ? " account needs you to sign in again." + : " accounts need you to sign in again.") + : "Accounts your mail, calendar, contacts, and files come from." + + Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh() + + SettingsCard { + visible: !OnlineAccounts.available + title: "Accounts unavailable" + subtitle: OnlineAccounts.lastError + } + + SettingsCard { + visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0 + title: "No accounts yet" + subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in." + + ActionRow { + label: "Add an account" + detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos" + action: "Add account" + divider: false + onTriggered: SystemSettings.openGnomePanel("online-accounts") + } + } + + Repeater { + model: OnlineAccounts.accounts + + SettingsCard { + id: accountCard + required property var modelData + + title: accountCard.modelData.identity || accountCard.modelData.providerName + subtitle: accountCard.modelData.needsAttention + ? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing" + : accountCard.modelData.providerName + + // Only when it is true, because it is the one thing on this page + // that needs acting on. + ActionRow { + visible: accountCard.modelData.needsAttention + label: "Sign in again" + detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts" + action: "Sign in" + onTriggered: SystemSettings.openGnomePanel("online-accounts") + } + + Repeater { + model: accountCard.modelData.services + + SettingRow { + id: serviceRow + required property var modelData + required property int index + + label: serviceRow.modelData.label + detail: serviceRow.modelData.enabled + ? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account" + : "Hidden from applications" + controlWidth: 48 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: serviceRow.modelData.enabled + onToggled: value => OnlineAccounts.setService( + accountCard.modelData.path, serviceRow.modelData.key, value) + } + } + } + + ActionRow { + label: "Remove this account" + detail: "Signs out and removes it from every application that was using it" + action: "Remove" + divider: false + onTriggered: OnlineAccounts.remove(accountCard.modelData.path) + } + } + } + + SettingsCard { + visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0 + title: "Add another account" + subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here." + + ActionRow { + label: "Add an account" + detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos" + action: "Add account" + divider: false + onTriggered: SystemSettings.openGnomePanel("online-accounts") + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 28c3be6..f1ae534 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -102,6 +102,7 @@ Rectangle { case "mouse": return mousePage; case "privacy": return privacyPage; case "region": return regionPage; + case "accounts": return onlineAccountsPage; case "accessibility": return accessibilityPage; case "power": return powerPage; case "datetime": return dateTimePage; @@ -159,6 +160,7 @@ Rectangle { Component { id: mousePage; MousePage {} } Component { id: privacyPage; PrivacyPage {} } Component { id: regionPage; RegionPage {} } + Component { id: onlineAccountsPage; OnlineAccountsPage {} } Component { id: servicesPage; ServicesPage {} } Component { id: aboutPage; AboutPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 997df08..3e5b700 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -35,6 +35,7 @@ Rectangle { { page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" }, { page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" }, { page: "region", label: "Region & Language", icon: "\u{F0AC2}" }, + { page: "accounts", label: "Online Accounts", icon: "\u{F0004}" }, { page: "accessibility", label: "Accessibility", icon: "\u{F0208}" }, { page: "power", label: "Power & Lock", icon: "\u{F0425}" }, { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 99bbf5c..d0d1c94 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -51,3 +51,4 @@ TextEntryRow 1.0 TextEntryRow.qml PrivacyPage 1.0 PrivacyPage.qml RegionPage 1.0 RegionPage.qml SearchPicker 1.0 SearchPicker.qml +OnlineAccountsPage 1.0 OnlineAccountsPage.qml diff --git a/config/dot/quickshell/scripts/panama-accounts b/config/dot/quickshell/scripts/panama-accounts new file mode 100755 index 0000000..9a3be6e --- /dev/null +++ b/config/dot/quickshell/scripts/panama-accounts @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 + +"""Online accounts, through GNOME Online Accounts. + +GOA is a daemon plus a D-Bus API, and it is already running in this session -- +gvfs activates it, and the four accounts on this machine work without +gnome-shell involved anywhere. What GNOME owns is only the *panel*; the accounts +themselves are ordinary D-Bus objects that anything may read and modify. + +So everything except the initial sign-in is available to us: listing accounts, +turning individual services on and off, and removing an account. That is the +whole Online Accounts panel apart from one OAuth handshake. + +Adding an account is the part that splits. The daemon's AddAccount takes +credentials as an ARGUMENT -- it stores them, it does not obtain them. For +password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a +password, which a settings app can reasonably collect. For Google it is an OAuth +token, and the code that runs that exchange lives in libgoa-backend, which +Fedora ships without a GIR binding -- reachable from C only. Reimplementing it +would mean our own Google client credentials. So Google sign-in is handed to +GNOME's panel, and only the sign-in. + +Usage: + panama-accounts list + panama-accounts set + panama-accounts remove +""" + +import json +import sys + +# Every service GOA models. The account object carries one interface per service +# it supports, so presence of the interface is what "this account can do mail" +# means -- there is no capability list to read. +SERVICES = [ + ("mail", "Mail", "get_mail", "mail_disabled"), + ("calendar", "Calendar", "get_calendar", "calendar_disabled"), + ("contacts", "Contacts", "get_contacts", "contacts_disabled"), + ("files", "Files", "get_files", "files_disabled"), + ("photos", "Photos", "get_photos", "photos_disabled"), + ("music", "Music", "get_music", "music_disabled"), + ("chat", "Chat", "get_chat", "chat_disabled"), +] + + +def load_client(): + import gi + + gi.require_version("Goa", "1.0") + from gi.repository import Goa + + return Goa.Client.new_sync(None) + + +def describe(obj): + account = obj.get_account() + services = [] + for key, label, getter, disabled_prop in SERVICES: + if getattr(obj, getter)() is None: + continue + services.append({ + "key": key, + "label": label, + "enabled": not getattr(account.props, disabled_prop), + }) + + return { + "path": obj.get_object_path(), + "provider": account.props.provider_type, + "providerName": account.props.provider_name, + # PresentationIdentity is the human one (an email address); Identity is + # the internal handle and is not always readable. + "identity": account.props.presentation_identity or account.props.identity, + # GOA raises this when stored credentials stop working -- an expired + # token, a changed password. It is the one piece of state a user must + # act on, and nothing else surfaces it. + "needsAttention": bool(account.props.attention_needed), + "services": services, + } + + +def find(client, path): + for obj in client.get_accounts(): + if obj.get_object_path() == path: + return obj + return None + + +def main(): + action = sys.argv[1] if len(sys.argv) > 1 else "list" + + try: + client = load_client() + except Exception as error: # noqa: BLE001 - any failure here means "no GOA" + # A machine without GOA is a legitimate state, not a crash. + print(json.dumps({ + "accounts": [], + "error": f"GNOME Online Accounts is not available: {error}", + })) + return 0 + + if action == "list": + print(json.dumps({ + "accounts": [describe(obj) for obj in client.get_accounts()], + "error": "", + })) + return 0 + + if action == "set": + if len(sys.argv) != 5: + print("usage: panama-accounts set ", file=sys.stderr) + return 2 + path, service, value = sys.argv[2], sys.argv[3], sys.argv[4] + obj = find(client, path) + if obj is None: + print(f"panama-accounts: no account at {path}", file=sys.stderr) + return 1 + match = next((s for s in SERVICES if s[0] == service), None) + if match is None: + print(f"panama-accounts: unknown service {service!r}", file=sys.stderr) + return 2 + if getattr(obj, match[2])() is None: + print(f"panama-accounts: this account does not support {service}", file=sys.stderr) + return 1 + # The property is "disabled", so enabling a service clears it. + setattr(obj.get_account().props, match[3], value.lower() not in ("true", "1", "yes")) + return 0 + + if action == "remove": + if len(sys.argv) != 3: + print("usage: panama-accounts remove ", file=sys.stderr) + return 2 + obj = find(client, sys.argv[2]) + if obj is None: + print(f"panama-accounts: no account at {sys.argv[2]}", file=sys.stderr) + return 1 + obj.get_account().call_remove_sync(None) + return 0 + + print("usage: panama-accounts [list|set|remove]", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/dot/quickshell/services/OnlineAccounts.qml b/config/dot/quickshell/services/OnlineAccounts.qml new file mode 100644 index 0000000..cb4757e --- /dev/null +++ b/config/dot/quickshell/services/OnlineAccounts.qml @@ -0,0 +1,95 @@ +pragma Singleton + +// Online accounts, via GNOME Online Accounts. +// +// The daemon already runs in this session -- gvfs activates it, and accounts +// work without gnome-shell anywhere. Only the panel was GNOME's; the accounts +// are D-Bus objects anything may read and modify. So listing, per-service +// toggles, and removal all happen here, natively. +// +// Signing in is the exception, and only for OAuth providers. The daemon's +// AddAccount takes credentials as an argument rather than obtaining them, and +// the code that runs Google's OAuth exchange lives in libgoa-backend, which +// Fedora ships without a GIR binding. So that one step is handed to GNOME's +// panel and the user comes straight back here. +// +// Read on demand and after every change: accounts are added and removed by +// people, not by the system, so there is nothing to poll for. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-accounts" + + // [{ path, provider, providerName, identity, needsAttention, services: [{key,label,enabled}] }] + property var accounts: [] + property bool scanned: false + property bool busy: false + property string lastError: "" + + // Accounts whose stored credentials have stopped working -- an expired + // token, a changed password. GOA knows, and nothing outside its own panel + // ever says so, which is how an account quietly stops syncing for weeks. + readonly property int attentionCount: root.accounts.filter(a => a.needsAttention).length + + readonly property bool available: root.lastError === "" + + function refresh(): void { + if (!list.running) + list.running = true; + } + + // Enabling a service clears GOA's "disabled" flag; the helper owns that + // inversion so the UI can speak in terms of what is on. + function setService(path: string, service: string, enabled: bool): void { + if (root.busy) + return; + root.busy = true; + write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"]; + write.running = true; + } + + function remove(path: string): void { + if (root.busy) + return; + root.busy = true; + write.command = [root.helperPath, "remove", path]; + write.running = true; + } + + Process { + id: list + command: [root.helperPath, "list"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : []; + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.accounts = []; + root.lastError = "Could not read the accounts helper's output."; + console.warn("OnlineAccounts: could not parse helper output:", error); + } + root.scanned = true; + } + } + } + + Process { + id: write + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + // Re-read rather than assuming the write landed: GOA may refuse, and a + // toggle that sprang back is the honest outcome. + onExited: { + root.busy = false; + root.refresh(); + } + } +} diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index b785047..6a102ed 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accessibility", "power", "datetime", "applications", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; From 5562323eb8471972466dd784b068ce5e724a2b74 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:39:42 -0400 Subject: [PATCH 14/20] Stop the live shell clobbering the write contract This contract failed intermittently with "a typed batch did not reach the compositor", reporting Panama's shipped defaults, and passed on retry. The earlier guard against a lingering harness was not the cause -- no instance was alive. Adding the writer's own error to the failure message settled it: the writer reports NO error while the compositor holds defaults. A rejected write leaves an error behind; a write that succeeded and was then overwritten does not. Both this contract and the LIVE shell write to the same compositor, and the running Panama re-applies its own preferences on any store change -- landing exactly the values that were being mistaken for "the write never happened". So the apply is re-issued periodically while waiting, which makes the test survive being overwritten without weakening what it asserts, and the failure message now distinguishes the two cases instead of describing both as a write that never arrived. The wait is also 10 seconds rather than 4. The write path verifies every option by reading it back and retries a refused batch, so a loaded machine legitimately takes longer -- and this runs in a suite alongside other tests driving the same compositor. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../settings-hyprland-write-contract.sh | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/quickshell/settings-hyprland-write-contract.sh b/tests/quickshell/settings-hyprland-write-contract.sh index d69846f..f56e002 100755 --- a/tests/quickshell/settings-hyprland-write-contract.sh +++ b/tests/quickshell/settings-hyprland-write-contract.sh @@ -149,8 +149,23 @@ sleep 0.3 qs_for_harness ipc call settings-system-test applyJson \ '{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null +# 10 seconds, not 4. The write path verifies each option by reading it back off +# the compositor and retries a refused batch, so a busy machine legitimately +# takes longer than a quick apply -- and this contract runs in a suite alongside +# other tests driving the same compositor. Failing at 4 seconds reported a +# product bug ("did not reach the compositor") for what was queueing. +# Re-issued periodically, because this contract and the LIVE shell both write to +# the same compositor. When the running Panama re-applies its own preferences -- +# which it does on any store change -- it overwrites the values this test just +# set, and the read-back below then sees Panama's shipped defaults with the +# writer reporting no error at all. That combination is the signature: a +# rejected write leaves an error, a clobbered one does not. typed=false -for _ in $(seq 1 40); do +for attempt in $(seq 1 100); do + if (( attempt % 30 == 0 )); then + qs_for_harness ipc call settings-system-test applyJson \ + '{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null + fi if [[ "$(read_option decoration:rounding)" == "7" \ && "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \ && "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \ @@ -161,10 +176,14 @@ for _ in $(seq 1 40); do sleep 0.1 done if [[ "$typed" != true ]]; then + # Report what the writer thinks as well as what the compositor holds. Those + # two disagreeing is a rejected write; both showing defaults is a write that + # never happened, and the messages should not look identical. fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \ gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \ blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \ -opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)" +opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float), \ +writer-reported error=\"$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)\"" fi # Verification must recognise those shapes as success, not report them rejected. From 2e8292599a3b602f0f977318ae88de0df3c8e10b Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:46:03 -0400 Subject: [PATCH 15/20] Add power profiles to Power & Lock The same Power Saver / Balanced / Performance choice GNOME's Power panel offers, and the daemon behind it was already running here -- it simply had no control anywhere in Panama. This machine has been sitting on "performance" with nothing to say so. Talks to the net.hadess.PowerProfiles interface rather than to a binary. Fedora 44 implements it with tuned-ppd instead of power-profiles-daemon, and powerprofilesctl is not installed at all, so anything shelling out to that command would have found nothing while the service was right there. Setting a profile needs no privileges: the daemon accepts a property write from the active session user. Not a stored preference. The daemon owns the profile, it survives Panama restarts, and anything else on the system can change it, so a copy in settings.json would drift -- the same reasoning as monitor brightness. PerformanceDegraded is surfaced because it is what makes the setting a lie: a thermally throttled machine reports "performance" while behaving otherwise, and that is worth saying out loud. The contract stubs busctl, because the real daemon is a system service shared with everything else on the machine and a test that flipped the daily driver to power-saver and then died would leave it there. It pins the parsing in particular: busctl renders the Profiles property flat, so profile names and driver names arrive in one stream, and a pattern loose enough to match both reports the driver as an extra profile. On this machine the driver is called "tuned", which reads exactly like a plausible fourth profile -- verified the contract catches that, and catches an unvalidated name reaching the system service. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../quickshell/modules/settings/PowerPage.qml | 34 ++++++ .../quickshell/scripts/panama-power-profile | 85 ++++++++++++++ .../dot/quickshell/services/PowerProfiles.qml | 107 ++++++++++++++++++ tests/quickshell/power-profile-contract.sh | 103 +++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100755 config/dot/quickshell/scripts/panama-power-profile create mode 100644 config/dot/quickshell/services/PowerProfiles.qml create mode 100755 tests/quickshell/power-profile-contract.sh diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml index 78a358d..2591709 100644 --- a/config/dot/quickshell/modules/settings/PowerPage.qml +++ b/config/dot/quickshell/modules/settings/PowerPage.qml @@ -14,9 +14,43 @@ import qs.services SettingsPage { id: root + // Probing the power daemon is a D-Bus round trip, so it happens when the + // page opens rather than at shell startup. + Component.onCompleted: if (!PowerProfiles.scanned) PowerProfiles.refresh() + title: "Power & Lock" lede: "When the screen turns off, when the session locks, and whether it ever sleeps." + // The same profiles GNOME's Power panel offers. Not a stored preference -- + // the daemon owns it, it survives Panama restarts, and anything else on the + // system can change it, so a copy here would drift. + SettingsCard { + visible: PowerProfiles.available || PowerProfiles.lastError !== "" + title: "Power profile" + subtitle: PowerProfiles.degraded !== "" + ? "Performance is limited right now: " + PowerProfiles.degraded + : (PowerProfiles.available + ? "Applies to the whole system and persists across sessions." + : PowerProfiles.lastError) + + Repeater { + model: PowerProfiles.profiles + + SettingRow { + id: profileRow + required property var modelData + required property int index + + label: PowerProfiles.label(profileRow.modelData) + detail: PowerProfiles.detail(profileRow.modelData) + value: profileRow.modelData === PowerProfiles.active ? "Active" : "" + divider: profileRow.index < PowerProfiles.profiles.length - 1 + activatable: profileRow.modelData !== PowerProfiles.active && !PowerProfiles.busy + onActivated: PowerProfiles.set(profileRow.modelData) + } + } + } + SettingsCard { title: "Idle behaviour" subtitle: IdleLock.managed diff --git a/config/dot/quickshell/scripts/panama-power-profile b/config/dot/quickshell/scripts/panama-power-profile new file mode 100755 index 0000000..529ee23 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-power-profile @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +# System power profile, via the PowerProfiles D-Bus API. +# +# GNOME's Power panel offers Balanced / Performance / Power Saver; this is the +# same daemon behind it. On Fedora 44 the implementation is tuned-ppd rather +# than power-profiles-daemon, but it serves the same net.hadess.PowerProfiles +# interface, which is why this talks to the interface rather than to either +# binary -- powerprofilesctl is not even installed here. +# +# Setting a profile needs no privileges: the daemon accepts a property write +# from the active session user. +# +# Usage: +# panama-power-profile list -> {"profiles":[...],"active":"...","degraded":"..."} +# panama-power-profile set +# +# PerformanceDegraded is reported because it is the one thing that makes the +# choice a lie: a thermally throttled laptop reports "performance" while +# behaving otherwise, and GNOME surfaces exactly this. It is an empty string +# when nothing is wrong. + +set -uo pipefail + +readonly BUS_NAME=net.hadess.PowerProfiles +readonly OBJECT=/net/hadess/PowerProfiles + +emit_error() { + printf '{"profiles":[],"active":"","degraded":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')" + exit 0 +} + +command -v busctl >/dev/null 2>&1 || emit_error 'busctl is not available' + +property() { + busctl get-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" "$1" 2>/dev/null +} + +cmd_list() { + # A machine with no power-profiles daemon is a normal state -- plenty of + # desktops have none -- so it is reported rather than treated as a failure. + busctl status "$BUS_NAME" >/dev/null 2>&1 \ + || emit_error 'No power profile service is running. GNOME uses power-profiles-daemon; Fedora ships tuned-ppd.' + + local active degraded profiles + active="$(property ActiveProfile | sed 's/^s //; s/"//g')" + degraded="$(property PerformanceDegraded | sed 's/^s //; s/"//g')" + + # Profiles is an array of dicts, which busctl renders flat: + # v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ... + # so each profile is the string following its own "Profile" marker. Matching + # the marker matters: "Driver" values sit in the same stream, and on this + # machine the driver is called "tuned", which a looser pattern happily + # reports as a fourth profile that does not exist. + profiles="$(property Profiles \ + | grep -oE '"Profile" s "[a-z-]+"' \ + | sed 's/.*s "//; s/"$//' \ + | awk '!seen[$0]++')" + + [[ -n "$profiles" ]] || emit_error 'The power profile service reported no profiles.' + + jq -cn \ + --arg active "$active" \ + --arg degraded "$degraded" \ + --argjson profiles "$(printf '%s\n' "$profiles" | jq -Rn '[inputs | select(length > 0)]')" \ + '{profiles: $profiles, active: $active, degraded: $degraded, error: ""}' +} + +cmd_set() { + local profile="${1:-}" + # Constrained rather than passed through: this reaches a system service. + [[ "$profile" =~ ^[a-z-]+$ ]] || { + printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2 + return 2 + } + busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \ + | head -2 >&2 + return 0 +} + +case "${1:-list}" in + list) cmd_list ;; + set) shift; cmd_set "${1:-}" ;; + *) printf 'usage: panama-power-profile [list|set ]\n' >&2; exit 2 ;; +esac diff --git a/config/dot/quickshell/services/PowerProfiles.qml b/config/dot/quickshell/services/PowerProfiles.qml new file mode 100644 index 0000000..670a06b --- /dev/null +++ b/config/dot/quickshell/services/PowerProfiles.qml @@ -0,0 +1,107 @@ +pragma Singleton + +// The system power profile: power-saver, balanced, or performance. +// +// The same daemon GNOME's Power panel drives. On Fedora 44 the implementation +// is tuned-ppd rather than power-profiles-daemon, but it serves the same +// net.hadess.PowerProfiles interface -- so this talks to the interface, not to +// either binary. powerprofilesctl is not installed here at all. +// +// Not a stored preference. The profile lives in the daemon, survives Panama +// restarts, and can be changed by anything else on the system; keeping a copy +// in settings.json would mean restoring a value the daemon had moved past. +// Same reasoning as monitor brightness. +// +// Read on demand and after each change. The daemon does emit PropertiesChanged, +// but subscribing to it would mean holding a bus connection open for a value +// that changes only when someone chooses it. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-power-profile" + + property var profiles: [] + property string active: "" + property bool scanned: false + property bool busy: false + property string lastError: "" + + // Non-empty when the machine cannot actually deliver the profile it is set + // to -- thermal throttling, or a laptop running on battery. Worth showing, + // because otherwise "performance" is a claim the hardware is not honouring. + property string degraded: "" + + readonly property bool available: root.profiles.length > 0 + + // Presentation lives here rather than in the page so the Control Center and + // Settings cannot disagree about what a profile is called. + function label(profile: string): string { + switch (profile) { + case "power-saver": return "Power Saver"; + case "balanced": return "Balanced"; + case "performance": return "Performance"; + default: return profile; + } + } + + function detail(profile: string): string { + switch (profile) { + case "power-saver": return "Reduces performance to save energy and run quieter"; + case "balanced": return "Standard behaviour, scaling up only when needed"; + case "performance": return "Holds higher clocks, using more power and making more noise"; + default: return ""; + } + } + + function refresh(): void { + if (!query.running) + query.running = true; + } + + function set(profile: string): void { + if (root.busy || profile === root.active) + return; + root.busy = true; + apply.command = [root.helperPath, "set", profile]; + apply.running = true; + } + + Process { + id: query + command: [root.helperPath, "list"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.profiles = Array.isArray(parsed.profiles) ? parsed.profiles : []; + root.active = String(parsed.active ?? ""); + root.degraded = String(parsed.degraded ?? ""); + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.profiles = []; + root.lastError = "Could not read the power profile helper's output."; + console.warn("PowerProfiles: could not parse helper output:", error); + } + root.scanned = true; + } + } + } + + Process { + id: apply + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + // Re-read rather than assuming: the daemon may refuse, or may land on a + // different profile than the one asked for. + onExited: { + root.busy = false; + root.refresh(); + } + } +} diff --git a/tests/quickshell/power-profile-contract.sh b/tests/quickshell/power-profile-contract.sh new file mode 100755 index 0000000..abfd77b --- /dev/null +++ b/tests/quickshell/power-profile-contract.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +# panama-power-profile reads and sets the system power profile. +# +# Runs against a stubbed busctl. The real daemon is a SYSTEM service shared with +# everything else on the machine, and a test that flipped the daily driver into +# power-saver and crashed before restoring would leave it there. +# +# The parsing is the fragile part. busctl renders the Profiles property flat: +# +# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ... +# +# so profile names and driver names sit in the same stream. A pattern loose +# enough to match both reports the driver as an extra profile -- and on this +# machine the driver is literally called "tuned", which reads exactly like a +# plausible fourth profile. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-power-profile" + +fail() { + printf 'power profile contract: %s\n' "$1" >&2 + exit 1 +} + +stub_dir="$(mktemp -d /tmp/panama-power.XXXXXX)" +trap 'rm -rf "$stub_dir"' EXIT + +cat >"$stub_dir/busctl" <<'STUB' +#!/usr/bin/env bash +# Records set-property calls so the test can assert what was written. +case "${1:-}" in + status) + [[ "${PANAMA_POWER_FAKE:-up}" == "down" ]] && exit 1 + exit 0 ;; + get-property) + case "${5:-}" in + ActiveProfile) printf 's "performance"\n' ;; + PerformanceDegraded) printf 's "%s"\n' "${PANAMA_POWER_DEGRADED:-}" ;; + Profiles) + if [[ "${PANAMA_POWER_FAKE:-up}" == "empty" ]]; then + printf 'v aa{sv} 0\n' + else + printf 'v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" s "balanced" "Driver" s "tuned" 2 "Profile" s "performance" "Driver" s "tuned"\n' + fi ;; + esac + exit 0 ;; + set-property) + printf '%s\n' "${!#}" >>"$PANAMA_POWER_SET_LOG" + exit 0 ;; +esac +exit 0 +STUB +chmod +x "$stub_dir/busctl" + +export PANAMA_POWER_SET_LOG="$stub_dir/sets.log" +: >"$PANAMA_POWER_SET_LOG" + +run() { PATH="$stub_dir:$PATH" "$helper" "$@"; } + +# ── Parsing ────────────────────────────────────────────────────────────────── +out="$(run list)" +jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out" + +[[ "$(jq -r '.profiles | length' <<<"$out")" == "3" ]] \ + || fail "expected exactly three profiles; a fourth usually means the Driver value was parsed as one: $out" + +jq -e '.profiles == ["power-saver", "balanced", "performance"]' >/dev/null <<<"$out" \ + || fail "profiles were parsed wrongly or reordered: $out" + +jq -e '.profiles | index("tuned") == null' >/dev/null <<<"$out" \ + || fail 'the driver name "tuned" was reported as a profile' + +[[ "$(jq -r '.active' <<<"$out")" == "performance" ]] \ + || fail "the active profile was not read: $out" + +# ── Degradation is surfaced, since it makes the active profile a lie ───────── +out="$(PANAMA_POWER_DEGRADED="lap-detected" run list)" +[[ "$(jq -r '.degraded' <<<"$out")" == "lap-detected" ]] \ + || fail "a degraded performance state was not reported: $out" + +# ── Setting ────────────────────────────────────────────────────────────────── +run set balanced +[[ "$(tail -1 "$PANAMA_POWER_SET_LOG")" == "balanced" ]] \ + || fail "set did not write the requested profile: $(cat "$PANAMA_POWER_SET_LOG")" + +before="$(wc -l <"$PANAMA_POWER_SET_LOG")" +run set 'evil; rm -rf /' 2>/dev/null +[[ "$(wc -l <"$PANAMA_POWER_SET_LOG")" == "$before" ]] \ + || fail 'a profile name with shell metacharacters reached the system service' + +# ── No daemon, and a daemon with nothing to offer, are both states ─────────── +out="$(PANAMA_POWER_FAKE=down run list)" +jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \ + || fail "a missing power daemon must be reported with a reason: $out" + +out="$(PANAMA_POWER_FAKE=empty run list)" +jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \ + || fail "a daemon offering no profiles must be reported, not shown as an empty card: $out" + +printf 'power profile contract: PASS\n' From 54b8a8280337586f29e8a7d428752f49663a6968 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:49:04 -0400 Subject: [PATCH 16/20] Give Accessibility a real magnifier, and stop implying the rest is available The page had two settings and a hand-off card claiming GNOME's stack provided "screen reader, zoom, and on-screen keyboard". Zoom did not need handing off at all, and the claim about the rest was optimistic. Zoom is native now. Hyprland has a real magnifier -- cursor:zoom_factor follows the pointer -- so it is a slider here rather than a button that opens another application. Inactive windows can also be dimmed as well as faded, which is the other thing that makes a focused window unmistakable. 47 mapped compositor options, from 43. What is NOT here is the more useful half of the change. Sticky keys, slow keys, bounce keys and mouse keys are AccessX, an X11 SERVER feature. XKB under Wayland has no accessx option group at all -- checked against evdev.lst, which lists altwin, caps, compose, ctrl, grp and the rest, and nothing resembling accessx -- and Hyprland implements none of it. The compositor will accept "accessx:enable" as a keyboard option and store it happily; I verified that, and verified nothing acts on it. So the page says plainly that they are unavailable in this session rather than offering switches, and does not point at GNOME's panel for them either: the daemon that would apply those keys is not running here, so that hand-off would be just as empty. Orca is offered instead, since the accessibility bus genuinely does work. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../quickshell/config/PreferenceSchema.qml | 36 ++++++++++++++++ .../modules/settings/AccessibilityPage.qml | 41 ++++++++++++++++--- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index fab097c..c708612 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -489,6 +489,42 @@ Singleton { hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" } }, + // ── Accessibility ─────────────────────────────────────────────────── + // + // Only what Hyprland can actually deliver. GNOME's sticky keys, slow + // keys, bounce keys and mouse keys are AccessX, an X11 server feature: + // XKB under Wayland has no accessx option group at all (verified + // against evdev.lst), and Hyprland does not implement it. The + // compositor will happily STORE "accessx:enable" as a keyboard option + // and nothing will ever act on it, which is exactly the kind of switch + // this app refuses to ship. + { + key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1, + group: "accessibility", + label: "Magnifier", + detail: "Magnifies the screen around the pointer. 1.0 is off", + hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" } + }, + { + key: "magnifierRigid", type: "bool", def: false, group: "accessibility", + label: "Magnifier follows in steps", + detail: "Moves the magnified view in increments rather than gliding with the pointer", + hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" } + }, + { + key: "dimInactive", type: "bool", def: false, group: "accessibility", + label: "Dim inactive windows", + detail: "Darkens every window except the focused one, so the active window is unmistakable", + hypr: { path: ["decoration", "dim_inactive"], option: "decoration:dim_inactive", readAs: "bool" } + }, + { + key: "dimStrength", type: "real", def: 0.5, min: 0.05, max: 0.9, step: 0.05, + group: "accessibility", + label: "Dim amount", + detail: "How much darker unfocused windows are", + hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" } + }, + // ── Night light ───────────────────────────────────────────────────── { key: "nightLightEnabled", type: "bool", def: false, group: "nightLight", diff --git a/config/dot/quickshell/modules/settings/AccessibilityPage.qml b/config/dot/quickshell/modules/settings/AccessibilityPage.qml index 3d5adef..2add626 100644 --- a/config/dot/quickshell/modules/settings/AccessibilityPage.qml +++ b/config/dot/quickshell/modules/settings/AccessibilityPage.qml @@ -37,20 +37,49 @@ SettingsPage { ToggleRow { setting: "animationsEnabled"; divider: false } } + // Zoom, done by the compositor rather than handed to GNOME. Hyprland has a + // real magnifier (cursor:zoom_factor) that follows the pointer, so there is + // no reason to send someone to another application for it. SettingsCard { - title: "Contrast" - subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read." + title: "Magnifier" + subtitle: "Magnifies the screen around the pointer. Set the magnification to 1× to turn it off." - SliderRow { setting: "inactiveOpacity"; divider: false } + SliderRow { setting: "magnifierFactor"; zeroLabel: "Off" } + ToggleRow { setting: "magnifierRigid"; divider: false } } SettingsCard { - title: "System accessibility" - subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack." + title: "Contrast" + subtitle: "Unfocused windows can be faded or darkened to make the focused one obvious, or left alone if that is harder to read." + + SliderRow { setting: "inactiveOpacity" } + ToggleRow { setting: "dimInactive" } + SliderRow { setting: "dimStrength"; divider: false } + } + + // What this session genuinely cannot do, said plainly. + // + // Sticky keys, slow keys, bounce keys and mouse keys are AccessX, which is + // an X11 SERVER feature. XKB under Wayland has no accessx option group at + // all, and Hyprland does not implement one. The compositor will accept + // "accessx:enable" as a keyboard option and store it, and nothing will ever + // act on it -- so there is no switch here, and pointing at GNOME's panel + // would be no better, since the daemon that applies those keys is not + // running either. + SettingsCard { + title: "Keyboard accessibility" + subtitle: "Sticky, slow and bounce keys are an X11 feature with no Wayland equivalent, so they are unavailable in this session. Offering them here would store a preference that nothing acts on." + + ActionRow { + label: "Screen reader" + detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here" + action: "Start Orca" + onTriggered: SystemSettings.openApplication("orca") + } ActionRow { label: "GNOME accessibility settings" - detail: "Opens in GNOME Settings" + detail: "For the parts GNOME's own stack still owns" action: "Open" divider: false onTriggered: SystemSettings.openGnomePanel("universal-access") From 4279da999ac0e30cfb5123cefedc91ae5ff945c0 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:56:01 -0400 Subject: [PATCH 17/20] Share a Wi-Fi network by QR code GNOME's Wi-Fi panel has this and it is the most-used thing in it: the alternative is reading a passphrase out loud. Network & Devices now shows a scannable code for any saved network whose passphrase this user can read. The image contains the network password in machine-readable form, so most of the care here is about that rather than about QR codes. It is written under XDG_RUNTIME_DIR -- 0700, on tmpfs, gone at logout -- rather than /tmp, which is shared; the file is 0600; the passphrase is piped to qrencode on stdin rather than passed as an argument, because argv is world-readable through /proc for as long as the process runs; and it is never printed or included in an error message. Generated on demand, because producing a code for every saved network up front means writing images of passwords nobody asked to see. Enterprise networks are listed as not shareable rather than offered and broken: there is no passphrase to encode, so the code could not work. Two bugs the contract caught while being written. Semicolons in an SSID were not escaped -- the sed replacement had one backslash where its four neighbours have two, so sed dropped it, and an SSID containing a semicolon would have produced a QR code describing a different network. And nmcli's trailing newline landed inside the payload; it decoded here, but a newline in the middle of a WIFI: URI is not something every phone tolerates, and that failure would present as "the QR code just doesn't work on my phone". The contract stubs nmcli and qrencode, because the real ones would write this machine's actual Wi-Fi password into a fixture directory. It asserts the escaping, the absence of a newline, the file and directory modes, that no temporary payload survives, and that the passphrase never reaches argv. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../modules/settings/ConnectivityPage.qml | 70 +++++++++- config/dot/quickshell/scripts/panama-wifi-qr | 121 +++++++++++++++++ config/dot/quickshell/services/WifiShare.qml | 99 ++++++++++++++ tests/quickshell/wifi-qr-contract.sh | 126 ++++++++++++++++++ 4 files changed, 415 insertions(+), 1 deletion(-) create mode 100755 config/dot/quickshell/scripts/panama-wifi-qr create mode 100644 config/dot/quickshell/services/WifiShare.qml create mode 100755 tests/quickshell/wifi-qr-contract.sh diff --git a/config/dot/quickshell/modules/settings/ConnectivityPage.qml b/config/dot/quickshell/modules/settings/ConnectivityPage.qml index 766d379..f387e07 100644 --- a/config/dot/quickshell/modules/settings/ConnectivityPage.qml +++ b/config/dot/quickshell/modules/settings/ConnectivityPage.qml @@ -19,13 +19,18 @@ import qs.services SettingsPage { id: root + title: "Network & Devices" lede: Connectivity.activeNetwork ? "Connected to " + Connectivity.activeNetwork.name : "Wi-Fi, Bluetooth, and the things Fedora owns." // Drive the scanners only while this page is the one being shown. - Component.onCompleted: Connectivity.active = true + Component.onCompleted: { + Connectivity.active = true; + if (!WifiShare.scanned) + WifiShare.refresh(); + } Component.onDestruction: Connectivity.active = false SettingsCard { @@ -68,6 +73,69 @@ SettingsPage { } } + // Sharing a network by QR, the way GNOME's Wi-Fi panel does. The + // alternative is reading a passphrase out loud. + // + // The image holds the password in machine-readable form, so it is generated + // on demand rather than up front, and the helper writes it to tmpfs under + // XDG_RUNTIME_DIR instead of anywhere persistent. + SettingsCard { + visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0 + title: "Share a network" + subtitle: WifiShare.sharing !== "" + ? "Point a phone's camera at the code to join " + WifiShare.sharing + "." + : "Shows a QR code a phone can scan to join, without reading the password out." + + Repeater { + model: WifiShare.shareable + + ActionRow { + id: shareRow + required property var modelData + required property int index + + label: shareRow.modelData.ssid + detail: WifiShare.sharing === shareRow.modelData.name + ? "Showing a code below — anyone who can see the screen can join" + : "Saved network" + action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code" + divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== "" + onTriggered: WifiShare.sharing === shareRow.modelData.name + ? WifiShare.stopSharing() + : WifiShare.share(shareRow.modelData.name) + } + } + + // Drawn at its natural size on a white plate: a QR code inverted or + // tinted to match a dark theme is unreliable to scan, and this one has + // exactly one job. + Item { + width: parent.width + visible: WifiShare.sharing !== "" && WifiShare.imagePath !== "" + implicitHeight: visible ? plate.height + 20 : 0 + + Rectangle { + id: plate + anchors.horizontalCenter: parent.horizontalCenter + y: 10 + width: 208 + height: 208 + radius: 10 + color: "white" + + Image { + anchors.centerIn: parent + width: 184 + height: 184 + smooth: false + fillMode: Image.PreserveAspectFit + cache: false + source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : "" + } + } + } + } + SettingsCard { title: "Bluetooth" visible: Connectivity.adapter !== null diff --git a/config/dot/quickshell/scripts/panama-wifi-qr b/config/dot/quickshell/scripts/panama-wifi-qr new file mode 100755 index 0000000..6532bb5 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-wifi-qr @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +# A QR code for a saved Wi-Fi network, so a guest can join by pointing a camera. +# +# GNOME's Wi-Fi panel has this and it is the single most-used thing in it. +# The payload is the de-facto WIFI: URI that Android and iOS both scan: +# +# WIFI:T:WPA;S:;P:;H:;; +# +# HANDLING THE PASSPHRASE +# +# This image contains the network password in machine-readable form. Anyone who +# can read the file can read the password, so: +# +# * it is written under XDG_RUNTIME_DIR, which is 0700 and on tmpfs, so it +# never reaches disk and disappears at logout -- not /tmp, which is shared; +# * it is created with umask 077; +# * the passphrase is never printed, never passed as an argument (argv is +# world-readable via /proc), and never appears in an error message. +# +# It is piped to qrencode on stdin for that last reason. +# +# Usage: +# panama-wifi-qr list -> {"networks":[{"name","ssid","shareable"}]} +# panama-wifi-qr qr -> {"path":"/run/user/…/….png"} + +set -uo pipefail + +emit_error() { + printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')" + exit 0 +} + +command -v nmcli >/dev/null 2>&1 || emit_error 'NetworkManager is not available' +command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn' + +cmd_list() { + local rows=() name ssid psk + while IFS= read -r name; do + [[ -n "$name" ]] || continue + ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)" + [[ -n "$ssid" ]] || ssid="$name" + + # Only networks whose passphrase this user can actually read are + # shareable. An enterprise network has no passphrase to share at all, + # and a QR code for one would simply not work. + psk="$(nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)" + + rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \ + --argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \ + '{name: $name, ssid: $ssid, shareable: $shareable}')") + done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \ + | awk -F: '$2 == "802-11-wireless" { print $1 }') + + if [[ ${#rows[@]} -eq 0 ]]; then + printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n' + return 0 + fi + printf '{"networks":[%s],"path":"","error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")" +} + +# The WIFI: URI reserves \ ; , : and ", each escaped with a backslash. An SSID +# containing a semicolon would otherwise terminate the field early and produce a +# QR code for a different network entirely. +# +# Trailing newlines are stripped as well. nmcli terminates every value with one, +# and left in place it lands INSIDE the payload -- the code still decodes here, +# but a newline in the middle of a WIFI: URI is not something every phone's +# scanner tolerates, and the failure would look like "the QR code just does not +# work on my phone". +escape_field() { + sed -e 's/\\/\\\\/g' -e 's/;/\\;/g' -e 's/,/\\,/g' -e 's/:/\\:/g' -e 's/"/\\"/g' \ + | tr -d '\n' +} + +cmd_qr() { + local name="${1:-}" + [[ -n "$name" ]] || emit_error 'no network named' + + local ssid hidden psk_file payload_file out_dir out_file + ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)" + [[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"." + + hidden="$(nmcli -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)" + [[ "$hidden" == "yes" ]] && hidden=true || hidden=false + + out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama" + umask 077 + mkdir -p "$out_dir" 2>/dev/null || emit_error 'could not create the runtime directory' + chmod 700 "$out_dir" 2>/dev/null || true + + # Named after the connection, hashed, so repeated shares reuse one file + # instead of accumulating images of the password. + out_file="$out_dir/wifi-$(printf '%s' "$name" | sha256sum | cut -c1-16).png" + + # Built in a file rather than a variable that could be echoed, and piped to + # qrencode on stdin so the passphrase never appears in argv. + payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file' + trap 'rm -f "$payload_file"' RETURN + + { + printf 'WIFI:T:WPA;S:' + printf '%s' "$ssid" | escape_field + printf ';P:' + nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field + printf ';H:%s;;' "$hidden" + } >"$payload_file" + + if ! qrencode -o "$out_file" -s 8 -m 2 -l M <"$payload_file" 2>/dev/null; then + emit_error "Could not generate a QR code for \"$name\"." + fi + chmod 600 "$out_file" 2>/dev/null || true + + jq -cn --arg path "$out_file" '{networks: [], path: $path, error: ""}' +} + +case "${1:-list}" in + list) cmd_list ;; + qr) shift; cmd_qr "${1:-}" ;; + *) printf 'usage: panama-wifi-qr [list|qr ]\n' >&2; exit 2 ;; +esac diff --git a/config/dot/quickshell/services/WifiShare.qml b/config/dot/quickshell/services/WifiShare.qml new file mode 100644 index 0000000..edb7aea --- /dev/null +++ b/config/dot/quickshell/services/WifiShare.qml @@ -0,0 +1,99 @@ +pragma Singleton + +// A QR code for a saved Wi-Fi network, so a guest can join by pointing a phone +// at the screen. GNOME's Wi-Fi panel has this and it is the most-used thing in +// it; reading a passphrase aloud is the alternative. +// +// The generated image contains the network password in machine-readable form, +// so the helper writes it under XDG_RUNTIME_DIR -- 0700, on tmpfs, gone at +// logout -- rather than anywhere persistent. Nothing here ever holds the +// passphrase itself; this service only ever sees a file path. +// +// Generated on demand. Producing a QR for every saved network up front would +// mean writing images of passwords nobody asked to see. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-wifi-qr" + + // [{ name, ssid, shareable }] + property var networks: [] + property bool scanned: false + property string lastError: "" + + // The network whose code is on screen, and where its image is. Empty when + // nothing is being shared. + property string sharing: "" + property string imagePath: "" + + readonly property var shareable: root.networks.filter(n => n.shareable) + + function refresh(): void { + if (!list.running) + list.running = true; + } + + function share(name: string): void { + if (generate.running) + return; + // Cache-bust: the helper reuses one file per network, so a QML Image + // pointed at the same path would keep showing the previous render. + root.imagePath = ""; + root.sharing = name; + generate.command = [root.helperPath, "qr", name]; + generate.running = true; + } + + function stopSharing(): void { + root.sharing = ""; + root.imagePath = ""; + } + + Process { + id: list + command: [root.helperPath, "list"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.networks = Array.isArray(parsed.networks) ? parsed.networks : []; + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.networks = []; + root.lastError = "Could not read the Wi-Fi helper's output."; + console.warn("WifiShare: could not parse helper output:", error); + } + root.scanned = true; + } + } + } + + Process { + id: generate + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + const path = String(parsed.path ?? ""); + const error = String(parsed.error ?? ""); + if (error !== "" || path === "") { + root.lastError = error !== "" ? error : "No QR code was produced."; + root.sharing = ""; + return; + } + root.lastError = ""; + root.imagePath = path; + } catch (error) { + root.lastError = "Could not read the generated QR code's path."; + root.sharing = ""; + console.warn("WifiShare: could not parse helper output:", error); + } + } + } + } +} diff --git a/tests/quickshell/wifi-qr-contract.sh b/tests/quickshell/wifi-qr-contract.sh new file mode 100755 index 0000000..66b866f --- /dev/null +++ b/tests/quickshell/wifi-qr-contract.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash + +# panama-wifi-qr renders a saved network as a QR code a phone can scan. +# +# The QR contains the network PASSWORD in machine-readable form, so most of what +# is worth testing here is about handling that safely rather than about QR +# codes. Both nmcli and qrencode are stubbed: the real ones would read this +# machine's actual passphrases, and a test that writes the daily driver's Wi-Fi +# password into a fixture directory is not one worth having. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-wifi-qr" + +fail() { + printf 'wifi qr contract: %s\n' "$1" >&2 + exit 1 +} + +work="$(mktemp -d /tmp/panama-wifiqr.XXXXXX)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" "$work/run" + +readonly SECRET='hunter2-secret' + +cat >"$work/bin/nmcli" <"$work/bin/qrencode" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$QRENCODE_ARGV_LOG" +out="" +prev="" +for arg in "$@"; do + [[ "$prev" == "-o" ]] && out="$arg" + prev="$arg" +done +cat >"$QRENCODE_STDIN_LOG" +printf 'fake-png' >"$out" +exit 0 +STUB +chmod +x "$work/bin/qrencode" + +export QRENCODE_ARGV_LOG="$work/argv.log" +export QRENCODE_STDIN_LOG="$work/stdin.log" +: >"$QRENCODE_ARGV_LOG" +: >"$QRENCODE_STDIN_LOG" + +run() { PATH="$work/bin:$PATH" XDG_RUNTIME_DIR="$work/run" "$helper" "$@"; } + +# ── Listing distinguishes shareable from not ──────────────────────────────── +out="$(run list)" +jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out" +[[ "$(jq -r '.networks | length' <<<"$out")" == "2" ]] \ + || fail "only wireless connections belong in the list: $out" +jq -e '.networks[] | select(.name == "home net") | .shareable == true' >/dev/null <<<"$out" \ + || fail "a network with a passphrase must be shareable: $out" +jq -e '.networks[] | select(.name == "work-eap") | .shareable == false' >/dev/null <<<"$out" \ + || fail "an enterprise network has no passphrase, so a QR code for it cannot work: $out" + +# ── The payload ───────────────────────────────────────────────────────────── +path="$(run qr 'home net' | jq -r .path)" +[[ -n "$path" && -e "$path" ]] || fail 'no image was produced' + +payload="$(cat "$QRENCODE_STDIN_LOG")" +grep -q "P:$SECRET;" <<<"$payload" \ + || fail 'the passphrase did not reach the payload intact' + +# The SSID is "home;net": unescaped, the semicolon ends the S: field early and +# the code describes a different network. +grep -qF 'S:home\;net;' <<<"$payload" \ + || fail "a reserved character in the SSID was not escaped: $payload" + +[[ "$(wc -l <"$QRENCODE_STDIN_LOG")" == "0" ]] \ + || fail "the payload contains a newline; nmcli's trailing newline must be stripped: $(cat -A "$QRENCODE_STDIN_LOG")" + +grep -q ';;$' <<<"$payload" || fail "the WIFI: URI must be terminated with ;;: $payload" + +# ── The secret must never appear in argv ──────────────────────────────────── +grep -q "$SECRET" "$QRENCODE_ARGV_LOG" \ + && fail 'the passphrase was passed as a command-line argument, where /proc exposes it to every process on the machine' + +# ── The image and its directory must not be readable by others ────────────── +[[ "$(stat -c '%a' "$path")" == "600" ]] \ + || fail "the QR image is mode $(stat -c '%a' "$path"); it contains a password" +[[ "$(stat -c '%a' "$(dirname "$path")")" == "700" ]] \ + || fail "the directory holding QR images is mode $(stat -c '%a' "$(dirname "$path")")" + +# ── No temporary payload files may survive ────────────────────────────────── +leftovers="$(find "$work/run" -name 'payload.*' | wc -l)" +[[ "$leftovers" == "0" ]] \ + || fail "$leftovers temporary payload file(s) containing the passphrase were left behind" + +# ── An unknown network is an error, not an empty image ────────────────────── +out="$(run qr 'no-such-network')" +jq -e '.path == "" and .error != ""' >/dev/null <<<"$out" \ + || fail "an unknown network must be reported: $out" + +printf 'wifi qr contract: PASS\n' From b16834fea651f28586b0051f46da03b62c6f78ae Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:59:34 -0400 Subject: [PATCH 18/20] Version the settings file so it can be upgraded The schema is the single source of truth for what a setting IS. It cannot express what a setting USED to be -- and renaming a key, changing its units, or splitting one setting into two all leave a stored value the new schema does not recognise. Unrecognised keys are deliberately carried through untouched so that rolling back to an older Panama does not discard a newer version's settings, which means the user's choice silently stops taking effect with nothing to explain it. settings.json now carries a schemaVersion, and load() runs every pending migration before anything reads a value. The list is empty: the point is that the first breaking schema change becomes a routine edit rather than an emergency, and Omarchy carries eighty of these. The behaviours that make it safe to run against a real user's file: A file with no schemaVersion predates this and is STAMPED, not migrated -- running the list against it would apply upgrades designed for schemas it never had. A file from a NEWER Panama is left completely alone. Downgrading keys is not something this can do correctly, and unknown keys already survive, so an older build simply ignores what it does not understand. A step that throws stops at the last good version. Skipping past it would lose that conversion forever; failing the whole load would cost the user every setting. The list being empty is exactly why this is tested now: the first time it runs for real will be against somebody's actual settings during an upgrade, which is a poor moment to find out how it behaves. The harness supplies fixture steps including one that throws, and the contract pins all four behaviours above plus the promise that unknown keys survive. One thing the contract earned its place on: stamping a pre-versioning file changes it without running any step, so writing only on "migrated" left the stamp in memory to be redone on every launch. It now writes whenever the version moves, and explicitly does not write a file from the future. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../quickshell/config/DesktopPreferences.qml | 21 ++- config/dot/quickshell/config/Migrations.qml | 121 ++++++++++++++++++ config/dot/quickshell/config/qmldir | 1 + config/dot/quickshell/migrations-harness.qml | 56 ++++++++ tests/quickshell/migrations-contract.sh | 72 +++++++++++ 5 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 config/dot/quickshell/config/Migrations.qml create mode 100644 config/dot/quickshell/migrations-harness.qml create mode 100755 tests/quickshell/migrations-contract.sh diff --git a/config/dot/quickshell/config/DesktopPreferences.qml b/config/dot/quickshell/config/DesktopPreferences.qml index 089c962..528a698 100644 --- a/config/dot/quickshell/config/DesktopPreferences.qml +++ b/config/dot/quickshell/config/DesktopPreferences.qml @@ -98,7 +98,26 @@ Singleton { // back to shipped defaults and let the next write replace it. parsed = {}; } - root.values = (parsed && typeof parsed === "object") ? parsed : {}; + const raw = (parsed && typeof parsed === "object") ? parsed : {}; + + // Upgrade before anything reads a value. A stored key the current + // schema no longer recognises is carried through untouched and silently + // stops taking effect, so the conversion has to happen here rather than + // being noticed later by whoever owns that setting. + const result = Migrations.apply(raw); + root.values = result.values; + + if (result.migrated) + console.info("Settings migrated from version", result.from, "to", result.to + ":", + result.applied.join("; ")); + + // Write whenever the version moved, which includes stamping a file + // written before versioning existed. Left unwritten, the stamp lives + // only in memory and is redone on every launch, and a migration that is + // not idempotent would compound. + if (result.changed) + persistTimer.restart(); + root.revision++; root.loaded = true; } diff --git a/config/dot/quickshell/config/Migrations.qml b/config/dot/quickshell/config/Migrations.qml new file mode 100644 index 0000000..05df23f --- /dev/null +++ b/config/dot/quickshell/config/Migrations.qml @@ -0,0 +1,121 @@ +pragma Singleton + +// Versioned upgrades for the settings file. +// +// The schema is the single source of truth for what a setting IS, but it cannot +// describe what a setting USED to be. Renaming a key, changing its units, or +// splitting one setting into two all leave a stored value that the new schema +// does not recognise -- and an unrecognised key is silently carried through +// untouched, so the user's choice simply stops taking effect with nothing to +// say why. That is the failure this exists to prevent. +// +// HOW IT WORKS +// +// settings.json carries a schemaVersion. On load, every migration with a +// version ABOVE the stored one runs in order, then the file is stamped with +// `current`. A file with no schemaVersion at all is a file written before this +// existed; it is stamped at `baseline` and NOT migrated, because those +// migrations were never written for it. +// +// WRITING ONE +// +// { version: 2, describe: "rename dockDelay to dockHideDelayMs", +// migrate: values => { ... return values; } } +// +// Rules that make this safe to run against a real user's file: +// +// * migrate() receives the whole values object and returns it. Mutating and +// returning the same object is fine. +// * NEVER delete a key you are not replacing. Unknown keys are deliberately +// preserved so that rolling back to an older Panama does not discard a +// newer version's settings, and a migration is the one place that promise +// could quietly be broken. +// * A migration must tolerate its input being absent or the wrong type. It +// runs against files written by every previous version, including ones +// that were hand-edited. +// * Migrations never run twice: the stored version only moves forward. + +import QtQuick + +QtObject { + id: root + + // What a file written today is stamped with. Bump this when adding a + // migration, to the version of the migration you added. + readonly property int current: 1 + + // Files predating versioning are stamped here without being migrated. + readonly property int baseline: 1 + + readonly property string versionKey: "schemaVersion" + + // Ordered by version. Empty is the correct state until the first breaking + // schema change -- this exists so that change is a routine edit rather than + // an emergency. + readonly property var steps: [] + + function storedVersion(values: var): int { + const raw = values ? values[root.versionKey] : undefined; + return (typeof raw === "number" && isFinite(raw)) ? Math.floor(raw) : 0; + } + + // Returns { values, migrated, changed, from, to, applied }. + // + // `applied` names each step that ran, so the caller can log something + // meaningful rather than "settings changed somehow". `changed` is the one + // the caller should write on: stamping a pre-versioning file changes it + // without running any step, and left unwritten the stamp would live only in + // memory and be redone on every launch. + function apply(values: var): var { + return root.applyWith(values, root.steps, root.current, root.baseline); + } + + // The same logic with the step list injected, so the machinery can be + // tested against fixture migrations. The real list is empty until the first + // breaking schema change, and a mechanism that has never run against a + // failing step is not one to find out about during an upgrade. + function applyWith(values: var, steps: var, current: int, baseline: int): var { + const safe = (values && typeof values === "object") ? values : {}; + const from = root.storedVersion(safe); + + // No version: written before versioning existed. Stamp it and stop. + // Running the migration list against it would apply upgrades designed + // for schemas this file never had. + if (from === 0) { + safe[root.versionKey] = baseline; + return { values: safe, migrated: false, changed: true, from: 0, to: baseline, applied: [] }; + } + + // A file from a NEWER Panama. Left completely alone: downgrading its + // keys is not something this can do correctly, and unknown keys are + // already preserved, so the older build simply ignores what it does not + // understand. + if (from > current) + return { values: safe, migrated: false, changed: false, from: from, to: from, applied: [] }; + + const applied = []; + let working = safe; + for (const step of steps) { + if (step.version <= from || step.version > current) + continue; + try { + const result = step.migrate(working); + if (result && typeof result === "object") + working = result; + applied.push(step.version + ": " + step.describe); + } catch (error) { + // One bad migration must not cost the user every setting. Stop + // at the last good version so the next launch retries from + // here rather than skipping the failed step forever. + console.warn("Migrations: step", step.version, "failed:", error); + working[root.versionKey] = step.version - 1; + return { values: working, migrated: applied.length > 0, changed: applied.length > 0, + from: from, to: step.version - 1, applied: applied }; + } + } + + working[root.versionKey] = current; + return { values: working, migrated: applied.length > 0, changed: from !== current, + from: from, to: current, applied: applied }; + } +} diff --git a/config/dot/quickshell/config/qmldir b/config/dot/quickshell/config/qmldir index c792bf9..c138bb4 100644 --- a/config/dot/quickshell/config/qmldir +++ b/config/dot/quickshell/config/qmldir @@ -4,3 +4,4 @@ singleton PreferenceSchema 1.0 PreferenceSchema.qml singleton HomePreferences 1.0 HomePreferences.qml singleton Settings 1.0 Settings.qml singleton Theme 1.0 Theme.qml +singleton Migrations 1.0 Migrations.qml diff --git a/config/dot/quickshell/migrations-harness.qml b/config/dot/quickshell/migrations-harness.qml new file mode 100644 index 0000000..ae65a11 --- /dev/null +++ b/config/dot/quickshell/migrations-harness.qml @@ -0,0 +1,56 @@ +// Exercises Migrations.applyWith against fixture steps and prints a verdict per +// case. Run by tests/quickshell/migrations-contract.sh. +// +// Fixture steps rather than the real list: the real one is empty until the +// first breaking schema change, and a mechanism that has never been run against +// a failing step is not one to discover the behaviour of during an upgrade. + +import Quickshell +import QtQuick +import qs.config + +ShellRoot { + Component.onCompleted: { + const steps = [ + { version: 2, describe: "add b", migrate: v => { v.b = (v.a ?? 0) + 1; return v; } }, + { version: 3, describe: "add c", migrate: v => { v.c = "three"; return v; } }, + { version: 4, describe: "explode", migrate: v => { throw new Error("boom"); } } + ]; + const results = {}; + + // No version at all: a file written before versioning. Stamped, never + // migrated. + let r = Migrations.applyWith({ a: 1 }, steps, 3, 1); + results.unversioned = { v: r.values.schemaVersion, migrated: r.migrated, + changed: r.changed, untouched: r.values.b === undefined }; + + // Older file: every step above its version runs, in order. + r = Migrations.applyWith({ a: 1, schemaVersion: 1 }, steps, 3, 1); + results.upgrade = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c, + count: r.applied.length, migrated: r.migrated }; + + // Already current: nothing runs. + r = Migrations.applyWith({ schemaVersion: 3, keep: "me" }, steps, 3, 1); + results.current = { v: r.values.schemaVersion, migrated: r.migrated, + kept: r.values.keep === "me", b: r.values.b === undefined }; + + // From the future: left completely alone, including its unknown keys. + r = Migrations.applyWith({ schemaVersion: 9, futureKey: "x" }, steps, 3, 1); + results.future = { v: r.values.schemaVersion, migrated: r.migrated, + changed: r.changed, kept: r.values.futureKey === "x" }; + + // A failing step stops at the last good version rather than losing the + // file or skipping past the failure forever. + r = Migrations.applyWith({ schemaVersion: 1, a: 5 }, steps, 4, 1); + results.failure = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c, + kept: r.values.a === 5, count: r.applied.length }; + + // Unknown keys survive a migration: rolling back to an older Panama + // must not discard a newer version's settings. + r = Migrations.applyWith({ schemaVersion: 1, unknownFromFuture: true }, steps, 3, 1); + results.preserved = { kept: r.values.unknownFromFuture === true }; + + console.info("PANAMA-MIGRATIONS " + JSON.stringify(results)); + Qt.callLater(() => Qt.quit()); + } +} diff --git a/tests/quickshell/migrations-contract.sh b/tests/quickshell/migrations-contract.sh new file mode 100755 index 0000000..6c663c7 --- /dev/null +++ b/tests/quickshell/migrations-contract.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +# Versioned upgrades for settings.json. +# +# The schema says what a setting IS; it cannot say what a setting USED to be. +# Rename a key, change its units, or split one setting into two, and the stored +# value stops being recognised -- and unrecognised keys are deliberately carried +# through untouched, so the user's choice silently stops taking effect with +# nothing to explain it. +# +# The list of migrations is empty today, which is exactly why this is tested +# now: the first time it runs for real will be against somebody's actual +# settings during an upgrade, and that is a poor moment to discover how it +# behaves. The harness supplies fixture steps, including one that throws. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/migrations-harness.qml" + +fail() { + printf 'migrations contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -r "$harness" ]] || fail "the harness is missing: $harness" + +out="$(timeout 60 qs -p "$harness" 2>&1 | grep -o 'PANAMA-MIGRATIONS .*' | sed 's/^PANAMA-MIGRATIONS //')" +[[ -n "$out" ]] || fail 'the harness produced no result' +jq -e . >/dev/null 2>&1 <<<"$out" || fail "the harness did not emit JSON: $out" + +check() { + jq -e "$1" >/dev/null <<<"$out" || fail "$2 -- got $(jq -c "$3" <<<"$out")" +} + +# A file written before versioning existed is stamped, NOT migrated. Running +# the list against it would apply upgrades designed for schemas it never had. +check '.unversioned.v == 1 and .unversioned.migrated == false and .unversioned.untouched == true' \ + 'a file with no schemaVersion must be stamped at the baseline without being migrated' '.unversioned' + +# The stamp has to reach disk. Reported as changed-but-not-migrated, it would +# otherwise live only in memory and be redone on every single launch. +check '.unversioned.changed == true' \ + 'stamping a pre-versioning file must be reported as a change so it gets written' '.unversioned' + +# A file from the future must not be rewritten at all. +check '.future.changed == false' \ + 'a file from a newer version must not be written back' '.future' + +# Every step above the stored version runs, in order. +check '.upgrade.v == 3 and .upgrade.b == 2 and .upgrade.c == "three" and .upgrade.count == 2' \ + 'an older file must run each pending step in order and end at the current version' '.upgrade' + +# Already current: nothing runs, nothing is touched. +check '.current.migrated == false and .current.kept == true and .current.b == true' \ + 'a file already at the current version must be left alone' '.current' + +# A file from a NEWER Panama is left completely alone. Downgrading keys is not +# something this can do correctly, and unknown keys are already preserved. +check '.future.v == 9 and .future.migrated == false and .future.kept == true' \ + 'a file from a newer version must not be modified or downgraded' '.future' + +# A failing step stops at the last good version. Skipping past it would lose +# the conversion forever; failing the whole load would cost every setting. +check '.failure.v == 3 and .failure.count == 2 and .failure.kept == true' \ + 'a failing step must stop at the last good version, keeping the steps that succeeded' '.failure' + +# The promise that makes rollback safe. +check '.preserved.kept == true' \ + 'a migration must not discard keys it does not recognise' '.preserved' + +printf 'migrations contract: PASS\n' From 27af4fd4434bb584e4dce0d546eb5cc119405372 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 11:04:00 -0400 Subject: [PATCH 19/20] Carry the colour scheme into btop and tmux Two more applications that keep their own palette and so never followed the desktop. btop was on "Default" and had never been themed at all; tmux had Tokyo Night Moon hardcoded across seventeen lines, which meant a dark status bar sitting under a light terminal in light mode. Both themes are authored rather than borrowed. btop ships a "tokyo-night" theme, but it is the Night variant (#1a1b26) where the rest of this desktop is Moon (#222436), and two Tokyo Nights side by side read as a mistake; it ships no Tokyo Night light theme at all. Colours come from kitty's theme files so a terminal and what runs inside it cannot disagree. tmux follows kitty's shape: the colours move to themes/, tmux.conf sources a generated current-theme.conf, and running servers are re-sourced so an open session changes immediately rather than at next launch. btop is different -- it OWNS btop.conf and rewrites it on exit, so only the color_theme line is edited in place and the file is not symlinked into the repository. btop reads its theme once at startup, so a running instance keeps the old colours; forcing a restart would kill a process the user is watching. The tmux light theme took two passes. Mapping the palette role-for-role put the standard Day accents on a mid-grey panel at 2.74:1 and 2.94:1 -- under the 3:1 floor, on a bar you read at a glance. It now uses Day's darker accent variants on a lighter panel: 4.42:1 and 4.17:1, and 5.01:1 / 4.73:1 for the light text inside the inverted blocks. The helper also now reports every target rather than only kitty. Saying "kitty: applied" while silently skipping three other applications is how a half-applied theme goes unnoticed. Not changed: bat is configured with --theme ansi, which follows the terminal's own palette, so it already tracks kitty with nothing to do. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .gitignore | 1 + config/dot/btop/themes/tokyonight-day.theme | 57 ++++++++++++++++++ config/dot/btop/themes/tokyonight-moon.theme | 56 ++++++++++++++++++ .../dot/quickshell/scripts/panama-theme-apps | 59 ++++++++++++++++++- config/dot/tmux/themes/tokyonight-day.conf | 30 ++++++++++ config/dot/tmux/themes/tokyonight-moon.conf | 25 ++++++++ config/dot/tmux/tmux.conf | 22 ++----- setup/scripts/link-dotfiles | 39 ++++++++++++ 8 files changed, 270 insertions(+), 19 deletions(-) create mode 100644 config/dot/btop/themes/tokyonight-day.theme create mode 100644 config/dot/btop/themes/tokyonight-moon.theme create mode 100644 config/dot/tmux/themes/tokyonight-day.conf create mode 100644 config/dot/tmux/themes/tokyonight-moon.conf diff --git a/.gitignore b/.gitignore index ea3e2dc..a041459 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ __pycache__/ # Generated from the colour scheme; machine state, not configuration. /config/dot/gtk-3.0/settings.ini /config/dot/gtk-4.0/settings.ini +/config/dot/tmux/current-theme.conf diff --git a/config/dot/btop/themes/tokyonight-day.theme b/config/dot/btop/themes/tokyonight-day.theme new file mode 100644 index 0000000..6932b74 --- /dev/null +++ b/config/dot/btop/themes/tokyonight-day.theme @@ -0,0 +1,57 @@ +# Tokyo Night Day for btop, the light counterpart to tokyonight-moon. +# +# btop ships no Tokyo Night light variant at all, and the nearest stock light +# theme (flat-remix-light) is a different palette that happens to have a similar +# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal +# and what runs inside it cannot disagree. +# +# Gradients keep the same low -> middle -> high meaning as the dark theme, using +# Day's darker, more saturated accents: the light versions of these hues are too +# faint to read as a filled meter. + +theme[main_bg]="#e1e2e7" +theme[main_fg]="#3760bf" +theme[title]="#3760bf" +theme[hi_fg]="#2e7de9" +theme[selected_bg]="#c4c8da" +theme[selected_fg]="#2e7de9" +theme[inactive_fg]="#7079a8" +theme[proc_misc]="#587539" + +theme[cpu_box]="#a8aecb" +theme[mem_box]="#a8aecb" +theme[net_box]="#a8aecb" +theme[proc_box]="#a8aecb" +theme[div_line]="#c4c8da" + +theme[temp_start]="#2e7de9" +theme[temp_mid]="#8c6c3e" +theme[temp_end]="#f52a65" + +theme[cpu_start]="#2e7de9" +theme[cpu_mid]="#9854f1" +theme[cpu_end]="#f52a65" + +theme[free_start]="#c4c8da" +theme[free_mid]="#007197" +theme[free_end]="#2e7de9" + +theme[cached_start]="#007197" +theme[cached_mid]="#2e7de9" +theme[cached_end]="#9854f1" + +theme[available_start]="#8c6c3e" +theme[available_mid]="#b15c00" +theme[available_end]="#f52a65" + +theme[used_start]="#587539" +theme[used_mid]="#8c6c3e" +theme[used_end]="#f52a65" + +theme[download_start]="#c4c8da" +theme[download_mid]="#2e7de9" +theme[download_end]="#007197" + +theme[upload_start]="#c4c8da" +theme[upload_mid]="#9854f1" +theme[upload_end]="#7847bd" diff --git a/config/dot/btop/themes/tokyonight-moon.theme b/config/dot/btop/themes/tokyonight-moon.theme new file mode 100644 index 0000000..6e26075 --- /dev/null +++ b/config/dot/btop/themes/tokyonight-moon.theme @@ -0,0 +1,56 @@ +# Tokyo Night Moon for btop, matched to Panama's palette. +# +# btop ships a "tokyo-night" theme, but it is the Night variant (#1a1b26). The +# rest of this desktop is Moon (#222436), and two Tokyo Nights side by side read +# as a mistake rather than as a choice. +# +# The *_start/_mid/_end triples are gradients btop draws meters with: low, +# middle, and high. They run blue -> yellow -> red so a saturated resource is +# obvious at a glance without reading the number. + +theme[main_bg]="#222436" +theme[main_fg]="#c8d3f5" +theme[title]="#c8d3f5" +theme[hi_fg]="#82aaff" +theme[selected_bg]="#3b4261" +theme[selected_fg]="#82aaff" +theme[inactive_fg]="#636da6" +theme[proc_misc]="#a5e8b5" + +theme[cpu_box]="#4d5685" +theme[mem_box]="#4d5685" +theme[net_box]="#4d5685" +theme[proc_box]="#4d5685" +theme[div_line]="#3b4261" + +theme[temp_start]="#82aaff" +theme[temp_mid]="#ffc777" +theme[temp_end]="#ff757f" + +theme[cpu_start]="#82aaff" +theme[cpu_mid]="#c099ff" +theme[cpu_end]="#ff757f" + +theme[free_start]="#3b4261" +theme[free_mid]="#589ed7" +theme[free_end]="#86e1fc" + +theme[cached_start]="#86e1fc" +theme[cached_mid]="#82aaff" +theme[cached_end]="#c099ff" + +theme[available_start]="#ffc777" +theme[available_mid]="#ff966c" +theme[available_end]="#ff757f" + +theme[used_start]="#a5e8b5" +theme[used_mid]="#ffc777" +theme[used_end]="#ff757f" + +theme[download_start]="#3b4261" +theme[download_mid]="#82aaff" +theme[download_end]="#86e1fc" + +theme[upload_start]="#3b4261" +theme[upload_mid]="#c099ff" +theme[upload_end]="#fca7ea" diff --git a/config/dot/quickshell/scripts/panama-theme-apps b/config/dot/quickshell/scripts/panama-theme-apps index a33ff31..23be064 100755 --- a/config/dot/quickshell/scripts/panama-theme-apps +++ b/config/dot/quickshell/scripts/panama-theme-apps @@ -31,6 +31,54 @@ case "$scheme" in *) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;; esac +# ── tmux ───────────────────────────────────────────────────────────────────── +# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is +# machine state rather than configuration. Running servers are re-sourced so an +# open session changes now instead of at next launch -- tmux applies a +# source-file to every attached client immediately. +tmux_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tmux" +tmux_theme="$tmux_dir/themes/tokyonight-moon.conf" +[[ "$scheme" == "light" ]] && tmux_theme="$tmux_dir/themes/tokyonight-day.conf" + +status_tmux="skipped" +if [[ -r "$tmux_theme" ]]; then + if cp "$tmux_theme" "$tmux_dir/current-theme.conf.tmp" 2>/dev/null \ + && mv "$tmux_dir/current-theme.conf.tmp" "$tmux_dir/current-theme.conf" 2>/dev/null; then + status_tmux="written" + # Only if a server is actually running; `tmux source-file` would + # otherwise start one just to theme it. + if command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then + tmux source-file "$tmux_dir/current-theme.conf" 2>/dev/null \ + && status_tmux="applied to running sessions" + fi + else + rm -f "$tmux_dir/current-theme.conf.tmp" + status_tmux="failed" + fi +fi + +# ── btop ───────────────────────────────────────────────────────────────────── +# Only the color_theme line is rewritten, in place. btop OWNS btop.conf -- it +# rewrites the whole file on exit -- so the config is not symlinked into Panama +# and not replaced wholesale here; just this one value is edited, and btop keeps +# it on the next write. +# +# btop reads its theme once at startup, so a running instance keeps the old +# colours until it is restarted. That is acceptable for a monitor you open when +# you want it, and forcing a restart would kill a process the user is watching. +btop_conf="${XDG_CONFIG_HOME:-$HOME/.config}/btop/btop.conf" +btop_theme="tokyonight-moon" +[[ "$scheme" == "light" ]] && btop_theme="tokyonight-day" + +status_btop="skipped" +if [[ -w "$btop_conf" ]]; then + if sed -i "s|^color_theme *=.*|color_theme = \"$btop_theme\"|" "$btop_conf" 2>/dev/null; then + status_btop="written" + else + status_btop="failed" + fi +fi + # ── GTK ────────────────────────────────────────────────────────────────────── # adw-gtk3, not Adwaita: no Adwaita GTK theme is installed on Fedora 44, and # naming a theme that does not exist makes GTK fall back to its light default -- @@ -89,4 +137,13 @@ if [[ -r "$theme_file" ]]; then fi fi -printf '{"scheme":"%s","kitty":"%s"}\n' "$scheme" "$status_kitty" +# Every target reports what actually happened. A helper that says only +# "kitty: applied" while silently skipping three other applications is how a +# half-applied theme goes unnoticed. +jq -cn \ + --arg scheme "$scheme" \ + --arg kitty "$status_kitty" \ + --arg gtk "$status_gtk" \ + --arg btop "$status_btop" \ + --arg tmux "$status_tmux" \ + '{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}' diff --git a/config/dot/tmux/themes/tokyonight-day.conf b/config/dot/tmux/themes/tokyonight-day.conf new file mode 100644 index 0000000..ca7338f --- /dev/null +++ b/config/dot/tmux/themes/tokyonight-day.conf @@ -0,0 +1,30 @@ +# Tokyo Night Day for tmux -- the light counterpart to tokyonight-moon.conf. +# +# Same structure, same roles, Day's palette. Mapped role for role rather than by +# hue: #1b1d2b was text drawn ON an accent block, so it becomes the LIGHT ground +# rather than a dark colour, or the status bar would be dark text on a dark +# accent in light mode. +# +# The accents are Day's DARKER variants, not its standard ones. Measured against +# the panel, the standard #2e7de9 and #9854f1 give 2.74:1 and 2.94:1 -- under +# the 3:1 floor for text. These give 4.42:1 and 4.17:1, and 5.01:1 / 4.73:1 for +# the light text drawn on top of them in the inverted blocks. + +set -g mode-style "fg=#1c5bb8,bg=#d0d5e3" + +set -g message-style "fg=#1c5bb8,bg=#d0d5e3" +set -g message-command-style "fg=#1c5bb8,bg=#d0d5e3" + +set -g pane-border-style "fg=#1c5bb8" +set -g pane-active-border-style "fg=#7847bd" + +set -g status-style "fg=#7847bd,bg=#d0d5e3" +set -g status-bg "#e1e2e7" + +set -g status-left "#[fg=#e1e2e7,bg=#7847bd,bold] #S #[fg=#e1e2e7,bg=#7847bd,nobold,nounderscore,noitalics]" +set -g status-right "#[fg=#e1e2e7,bg=#1c5bb8,nobold,nounderscore,noitalics]#[fg=#7847bd,bg=#d0d5e3] #{prefix_highlight} #[fg=#d0d5e3,bg=#d0d5e3]#[fg=#7847bd,bg=#d0d5e3] %Y/%m/%d %I:%M %p #[fg=#1c5bb8,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#e1e2e7,bg=#7847bd,bold,italics] #h " + +setw -g window-status-format "#[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#1c5bb8,bg=#d0d5e3] #I #W #F #[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]" +setw -g window-status-current-format "#[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#7847bd,bg=#d0d5e3,bold] #I #W #F #[fg=#d0d5e3,bg=#d0d5e3,nobold,nounderscore,noitalics]" + +setw -g window-status-separator "" diff --git a/config/dot/tmux/themes/tokyonight-moon.conf b/config/dot/tmux/themes/tokyonight-moon.conf new file mode 100644 index 0000000..51cf3d2 --- /dev/null +++ b/config/dot/tmux/themes/tokyonight-moon.conf @@ -0,0 +1,25 @@ +# Tokyo Night Moon for tmux -- the palette Panama ships. +# +# Extracted from tmux.conf so the two schemes can be swapped. tmux.conf sources +# current-theme.conf, which panama-theme-apps generates from one of these. +# +# Every colour here was already in tmux.conf; this file is that block verbatim. + +set -g mode-style "fg=#82aaff,bg=#3b4261" + +set -g message-style "fg=#82aaff,bg=#3b4261" +set -g message-command-style "fg=#82aaff,bg=#3b4261" + +set -g pane-border-style "fg=#82aaff" +set -g pane-active-border-style "fg=#b172b0" + +set -g status-style "fg=#b172b0,bg=#3b4261" +set -g status-bg "#222436" + +set -g status-left "#[fg=#1b1d2b,bg=#b172b0,bold] #S #[fg=#1b1d2b,bg=#b172b0,nobold,nounderscore,noitalics]" +set -g status-right "#[fg=#1b1d2b,bg=#82aaff,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261] #{prefix_highlight} #[fg=#3b4261,bg=#3b4261]#[fg=#b172b0,bg=#3b4261] %Y/%m/%d %I:%M %p #[fg=#82aaff,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#1b1d2b,bg=#b172b0,bold,italics] #h " + +setw -g window-status-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#82aaff,bg=#3b4261] #I #W #F #[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]" +setw -g window-status-current-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261,bold] #I #W #F #[fg=#3b4261,bg=#3b4261,nobold,nounderscore,noitalics]" + +setw -g window-status-separator "" diff --git a/config/dot/tmux/tmux.conf b/config/dot/tmux/tmux.conf index 47c686e..636c7ba 100644 --- a/config/dot/tmux/tmux.conf +++ b/config/dot/tmux/tmux.conf @@ -19,24 +19,10 @@ bind C-Space send-prefix # Set status bar #set -g status-bg pink # Tokyo Night Moon color palette -set -g mode-style "fg=#82aaff,bg=#3b4261" - -set -g message-style "fg=#82aaff,bg=#3b4261" -set -g message-command-style "fg=#82aaff,bg=#3b4261" - -set -g pane-border-style "fg=#82aaff" -set -g pane-active-border-style "fg=#b172b0" - -set -g status-style "fg=#b172b0,bg=#3b4261" -set -g status-bg "#222436" - -set -g status-left "#[fg=#1b1d2b,bg=#b172b0,bold] #S #[fg=#1b1d2b,bg=#b172b0,nobold,nounderscore,noitalics]" -set -g status-right "#[fg=#1b1d2b,bg=#82aaff,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261] #{prefix_highlight} #[fg=#3b4261,bg=#3b4261]#[fg=#b172b0,bg=#3b4261] %Y/%m/%d %I:%M %p #[fg=#82aaff,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#1b1d2b,bg=#b172b0,bold,italics] #h " - -setw -g window-status-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#82aaff,bg=#3b4261] #I #W #F #[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]" -setw -g window-status-current-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261,bold] #I #W #F #[fg=#3b4261,bg=#3b4261,nobold,nounderscore,noitalics]" - -setw -g window-status-separator "" +# Colours live in themes/ and are generated into current-theme.conf by +# panama-theme-apps, which regenerates it whenever the desktop colour scheme +# changes. -q so a fresh checkout without the generated file still starts. +source-file -q "~/.config/tmux/current-theme.conf" # Increase scrollback buffer set -g history-limit 50000 diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles index 352480b..ad29e48 100755 --- a/setup/scripts/link-dotfiles +++ b/setup/scripts/link-dotfiles @@ -70,6 +70,45 @@ for dir in "${dirs[@]}"; do log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir" done +# tmux.conf ends with a source-file of current-theme.conf, generated from the +# colour scheme rather than committed. Seed it so a fresh checkout starts themed +# -- the source-file is -q, so a missing file is silent, which would leave tmux +# unstyled with nothing to explain it. +TMUX_THEME="$PANAMA_DOT/tmux/current-theme.conf" +if [ -e "$TMUX_THEME" ]; then + log "Keeping existing tmux theme at $TMUX_THEME" +elif [ -d "$PANAMA_DOT/tmux/themes" ]; then + tmux_scheme="dark" + tmux_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" + if [ -r "$tmux_prefs" ]; then + tmux_stored="$(jq -r '.colorScheme // "dark"' "$tmux_prefs" 2>/dev/null || echo dark)" + [ "$tmux_stored" = "light" ] && tmux_scheme="light" + fi + [ "$tmux_scheme" = "light" ] && tmux_name="tokyonight-day" || tmux_name="tokyonight-moon" + cp "$PANAMA_DOT/tmux/themes/$tmux_name.conf" "$TMUX_THEME" + log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME" +fi + +# btop reads themes from its own config directory, but OWNS btop.conf -- it +# rewrites that file on exit -- so only the theme files are exposed, per file, +# and the config itself is left to btop. panama-theme-apps edits the single +# color_theme line in place. +BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes" +mkdir -p "$BTOP_THEME_DIR" +for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do + [ -e "$btop_theme_src" ] || continue + btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")" + if [ -L "$btop_theme_dst" ]; then + rm "$btop_theme_dst" + fi + if [ -e "$btop_theme_dst" ]; then + log "Keeping existing btop theme at $btop_theme_dst" + else + ln -s "$btop_theme_src" "$btop_theme_dst" + log "Linked btop theme → $btop_theme_dst" + fi +done + # GTK3 has no include mechanism, so its settings.ini is generated whole from a # template rather than layered. Without this, a fresh checkout has a template # and no settings.ini, and GTK3 applications fall back to their built-in theme. From 0c4d132ee1c917b0ecf3b223ba6bc29f847f6352 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 11:13:42 -0400 Subject: [PATCH 20/20] Fix focus-mode labels that said the opposite of what they did Found by codex's GNOME Tweaks audit and verified against the compositor: `hyprctl descriptions` publishes input:follow_mouse as map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]. Panama labelled 0 "Never", 1 "Click to focus", 2 "Sloppy focus". So this desktop, sitting on the shipped value of 1, has been running focus-follows-pointer the whole time while Settings called it "Click to focus" -- and the way to actually GET click-to-focus was to choose "Never". Value 3 was not offered at all. hypr/input.lua carried the same wrong claim in a comment. The shipped VALUE is left alone. Which focus mode this desktop should use is a behaviour decision rather than a correction, and all four are now reachable from Settings. Nothing could have caught this. The compositor accepts 1, reads back 1, and the write contract passes: the value is valid, it just means something other than the label. The only authority on what each number MEANS is the compositor, and it publishes that. So enum-hypr-map-contract now checks every compositor-backed enum against the published map -- that offered values exist, and that published values are offered, since a missing one is a capability nobody can reach. Writing it immediately found two more of the same: variable refresh rate offered Off and fullscreen-games while the compositor publishes four (always-on and fullscreen-only were unreachable, and fullscreen-only is what someone wanting VRR for video rather than games wants), and direct scanout was missing its always-on value. Both now offer everything, with a detail line per option rather than a bare word. Verified the contract catches the original followMouse gap and a value outside the map. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- config/dot/hypr/input.lua | 12 ++- .../quickshell/config/PreferenceSchema.qml | 51 ++++++++-- tests/quickshell/enum-hypr-map-contract.sh | 95 +++++++++++++++++++ 3 files changed, 146 insertions(+), 12 deletions(-) create mode 100755 tests/quickshell/enum-hypr-map-contract.sh diff --git a/config/dot/hypr/input.lua b/config/dot/hypr/input.lua index 2b2b33c..9fcbe58 100644 --- a/config/dot/hypr/input.lua +++ b/config/dot/hypr/input.lua @@ -21,10 +21,18 @@ hl.config({ repeat_delay = prefs.get("keyRepeatDelay", 500), repeat_rate = prefs.get("keyRepeatRate", 33), - -- 1 = click to focus. GNOME's behaviour; NOT sloppy focus. + -- 1 = FOLLOW. The window under the pointer takes focus. This comment + -- previously claimed 1 was "click to focus, GNOME's behaviour", which + -- is the opposite of what Hyprland does -- `hyprctl descriptions` gives + -- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}], + -- so click-to-focus is 0. Changing the shipped value is a behaviour + -- decision rather than a correction, so the value is left alone and + -- only the description is fixed; Settings exposes all four. follow_mouse = prefs.getInt("followMouse", 1), - -- Don't refocus on mouse move alone -- only on click. + -- Softens follow_mouse: with this off, focus changes only when the + -- pointer crosses a window boundary, not on every movement inside one. + -- Still focus-follows-pointer, just less twitchy. mouse_refocus = false, -- Flat pointer response, no acceleration. Matters for gaming. diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index c708612..a60ade8 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -128,10 +128,21 @@ Singleton { { key: "vrrPolicy", type: "enum", def: 3, group: "display", label: "Variable refresh rate", - detail: "Content-aware matches the display to what is on screen", + detail: "Matches the display's refresh rate to what is on screen", + // All four the compositor publishes, rather than the two that were + // here. Always-on VRR is a legitimate choice on a panel that + // handles it well, and it was simply unreachable -- as was + // fullscreen-only, which is what someone wanting VRR for video + // rather than games wants. options: [ - { value: 0, label: "Off" }, - { value: 3, label: "Content-aware" } + { value: 0, label: "Off", + detail: "The display runs at a fixed refresh rate" }, + { value: 1, label: "Always on", + detail: "Best on panels that handle low refresh rates without flicker" }, + { value: 2, label: "Fullscreen only", + detail: "Any fullscreen window, including video" }, + { value: 3, label: "Fullscreen games", + detail: "Only fullscreen games, which is the safest default" } ], hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" } }, @@ -140,8 +151,12 @@ Singleton { label: "Direct scanout", detail: "Lets fullscreen content bypass compositing", options: [ - { value: 0, label: "Off" }, - { value: 2, label: "Automatic" } + { value: 0, label: "Off", + detail: "Everything goes through the compositor" }, + { value: 1, label: "Always on", + detail: "Forced rather than decided per surface; can drop frames on some drivers" }, + { value: 2, label: "Automatic", + detail: "The compositor decides per surface, which is the safe default" } ], hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" } }, @@ -299,12 +314,28 @@ Singleton { }, { key: "followMouse", type: "enum", def: 1, group: "input", - label: "Focus follows pointer", - detail: "Click to focus matches GNOME; sloppy focus follows the pointer", + label: "Pointer focus", + detail: "What moving the pointer does to which window is focused", + // These labels were wrong, and wrong in the worst way: value 1 was + // shown as "Click to focus" while Hyprland's 1 means the opposite. + // The compositor publishes the authoritative mapping itself -- + // `hyprctl descriptions` gives + // map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}] + // -- so a desktop labelled "Click to focus" was in fact following + // the pointer, and the way to actually get click-to-focus was to + // choose "Never". Value 3 was missing entirely. + // + // enum-hypr-map-contract now pins every mapped enum against that + // published map, so this cannot drift again. options: [ - { value: 0, label: "Never" }, - { value: 1, label: "Click to focus" }, - { value: 2, label: "Sloppy focus" } + { value: 0, label: "Click to focus", + detail: "Moving the pointer never changes focus" }, + { value: 1, label: "Focus follows pointer", + detail: "The window under the pointer takes focus as you move" }, + { value: 2, label: "Pointer detached", + detail: "The pointer highlights windows on its own; clicking moves keyboard focus" }, + { value: 3, label: "Pointer fully separate", + detail: "Clicking does not move keyboard focus at all" } ], hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" } }, diff --git a/tests/quickshell/enum-hypr-map-contract.sh b/tests/quickshell/enum-hypr-map-contract.sh new file mode 100755 index 0000000..0973621 --- /dev/null +++ b/tests/quickshell/enum-hypr-map-contract.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +# Every enum backed by a Hyprland option must offer values that option accepts. +# +# This exists because of a bug that shipped: followMouse offered 0/1/2 labelled +# "Never" / "Click to focus" / "Sloppy focus", while Hyprland's actual mapping +# is disabled=0, follow=1, detached=2, separate=3. The desktop was labelled +# "Click to focus" and was in fact following the pointer, the way to GET click +# to focus was to choose "Never", and value 3 did not exist in the UI at all. +# +# Nothing detects that. The compositor accepts 1, reads back 1, and verification +# passes -- the value is valid, it just means something else entirely. The only +# authority on what each number MEANS is the compositor, which publishes it: +# +# hyprctl descriptions -> { "name": "input:follow_mouse", +# "map": [{"separate":3},{"detached":2},...] } +# +# So this checks the schema's enum values against that map, and against the +# min/max range for mapped options that have no named map. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" + +fail() { + printf 'enum hypr map contract: %s\n' "$1" >&2 + exit 1 +} + +command -v hyprctl >/dev/null 2>&1 || { printf 'enum hypr map contract: SKIP (no compositor)\n'; exit 0; } +descriptions="$(hyprctl descriptions 2>/dev/null)" || fail 'could not read hyprctl descriptions' +jq -e 'type == "array" and length > 0' >/dev/null <<<"$descriptions" \ + || fail 'hyprctl descriptions did not return a list' + +# Pull every enum entry that carries a hypr option, as: keyoptionvalues +entries="$(python3 - "$schema" <<'PY' +import re, sys + +text = open(sys.argv[1]).read() +# Each schema entry is a brace-delimited block starting with `key:`. +for block in re.findall(r'\{\s*\n?\s*key:\s*"([^"]+)"(.*?)\n \}', text, re.S): + name, body = block + if 'type: "enum"' not in body: + continue + option = re.search(r'option:\s*"([^"]+)"', body) + if not option: + continue + values = re.findall(r'value:\s*(-?\d+)', body) + if not values: + continue + print(f"{name}\t{option.group(1)}\t{','.join(values)}") +PY +)" + +[[ -n "$entries" ]] || fail 'found no compositor-backed enums in the schema -- this contract is not reading it correctly' + +checked=0 +while IFS=$'\t' read -r key option values; do + [[ -n "$key" ]] || continue + + entry="$(jq -c --arg name "$option" '.[] | select(.name == $name)' <<<"$descriptions")" + [[ -n "$entry" ]] || fail "$key maps to \"$option\", which the compositor does not publish" + + map_values="$(jq -r 'if .map then (.map | map(to_entries[].value) | join(",")) else "" end' <<<"$entry")" + + IFS=',' read -ra wanted <<<"$values" + for value in "${wanted[@]}"; do + if [[ -n "$map_values" ]]; then + grep -qx "$value" <<<"$(tr ',' '\n' <<<"$map_values")" \ + || fail "$key offers $value for $option, which the compositor's map does not contain (it publishes: $map_values). A value outside the map is accepted and read back unchanged, so nothing else notices -- it simply means something other than the label says." + else + min="$(jq -r '.min // empty' <<<"$entry")" + max="$(jq -r '.max // empty' <<<"$entry")" + if [[ -n "$min" && -n "$max" ]]; then + (( value >= min && value <= max )) \ + || fail "$key offers $value for $option, outside the compositor's range $min..$max" + fi + fi + done + + # Every value the compositor names should be offered. A missing one is a + # capability the user simply cannot reach -- value 3 was missing here. + if [[ -n "$map_values" ]]; then + while read -r published; do + [[ -n "$published" ]] || continue + grep -qx "$published" <<<"$(tr ',' '\n' <<<"$values")" \ + || fail "$option publishes value $published but $key does not offer it, so that behaviour is unreachable from Settings" + done <<<"$(tr ',' '\n' <<<"$map_values")" + fi + + checked=$((checked + 1)) +done <<<"$entries" + +printf 'enum hypr map contract: PASS (%d mapped enums)\n' "$checked"