Author SHA1 Message Date
Gabriel Brown 17e23a6cf4 Merge remaining settings work
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:38:46 -04:00
Gabriel Brown d18fe51553 Make the weather location and graphics device choosable
The last two values that could only be changed by editing a file.

Weather was pinned to hardcoded coordinates, so the card could not be
pointed anywhere else. It is a location search now, not latitude and
longitude fields: nobody knows their own coordinates, and a control that
demands them is one nobody uses. Open-Meteo's geocoding endpoint needs
no key, the same reason the forecast already uses them. Only the search
term leaves the machine -- the stored place name is a label -- and
coordinates are rounded to four decimals, far finer than a weather
reading resolves and coarse enough to keep a precise home location out
of the settings file.

The graphics readout was hardcoded to card1. This machine has two amdgpu
cards, discrete and integrated, so that was right only by luck, and the
path is meaningless on any other machine. GPUs are enumerated with a
readable name from lspci, since sysfs exposes only numeric ids, and the
picker appears only when there is more than one to choose between. A
stored path the machine does not have is refused and reported rather
than silently measuring nothing.

Also merges the per-application notification rules UI. Its three commits
were believed integrated but the page half was not actually in the tree:
main had the service side in Notifs.qml and zero references to
setAppRule in NotificationsPage. Ancestry is not content.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:38:46 -04:00
Gabriel Brown 9f9515ffe0 Merge per-application notification rules
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:29:47 -04:00
Gabriel Brown 94353aa0bd Close the gaps the cross-UI audit found
Audited bar, dock, quick settings, date menu and Settings for three
things: a setting reachable in one UI but not another, a setting that
exists but is unreachable anywhere, and UI that states something false.

Night Light was fully exposed in Quick Settings and had no control
anywhere in Settings. It now has a card on Displays, where GNOME also
puts it, with on/off, schedule, times and temperature.

Adding those controls would have shipped the exact defect this audit
exists to find. NightLight declared enabled, temperature and automatic
as bindings on the store, but toggle() assigns to them, and an
assignment destroys a QML binding permanently -- so the service wrote to
the store and never read from it again. The Settings controls would have
written values the service ignored, while Quick Settings kept working.
It now follows the store. Every other service was swept for the same
pattern; this was the only one.

The night light schedule was two hardcoded literals, so the hours could
not be changed. They are schema keys now, with a row that renders 17.5
as "5:30 PM" and honours the 24-hour preference rather than showing a
decimal nobody reads as a time.

keyboardLayout was in the schema and read by input.lua but had no
control anywhere: configurable in principle, unreachable in practice. It
is surfaced on Input & Shortcuts as read-only, with the reason, because
it needs a compositor reload and a control implying instant apply would
be a smaller lie but still a lie.

Caffeine was a Quick Settings toggle mentioned only in a subtitle in
Settings. It has a real control now.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:25:15 -04:00
Gabriel Brown 3a2295ff53 Make credential setup keyboard-safe and verifiable 2026-08-18 06:14:07 -04:00
Gabriel Brown de1b8a7673 Add private Home Assistant credentials settings 2026-08-18 06:14:07 -04:00
Gabriel Brown 39a26adcc2 Make Home light ordering explicit and durable 2026-08-18 06:14:07 -04:00
Gabriel Brown eff0bc44ff fix: resolve persisted notification app labels 2026-08-18 06:14:07 -04:00
Gabriel Brown c038c57278 fix: harden notification application rule integration 2026-08-18 06:14:07 -04:00
Gabriel Brown 420b7aa1a6 feat: add notification application rules 2026-08-18 06:14:07 -04:00
Gabriel Brown f22e405b50 fix: resolve persisted notification app labels 2026-08-18 05:57:10 -04:00
Gabriel Brown 3cfa592db9 Build complete PipeWire sound settings 2026-08-18 05:54:14 -04:00
Gabriel Brown 81096e2d95 Declare the Sound and notification-rule manifest entries
Registers the components and schema key the codex agent needs for the
Sound page and per-application notification rules, so its branches
compile against a manifest that already holds them rather than each
carrying a conflicting edit to the same file.

An absent notification rule is permissive rather than denying: a newly
installed application must be able to notify without an entry being
written for it first.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 05:54:07 -04:00
Gabriel Brown c415c4f176 fix: harden notification application rule integration 2026-08-18 05:51:32 -04:00
Gabriel Brown 86742da63d Merge Wi-Fi and Bluetooth settings
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 05:50:07 -04:00
Gabriel Brown 3c521cf5fa Handle Wi-Fi and Bluetooth in Settings
Network & Devices was 92 lines and two buttons that opened GNOME. It now
scans, joins, and pairs directly through Quickshell.Networking and
Quickshell.Bluetooth -- NetworkManager and BlueZ over DBus, no shelling
out to nmcli or bluetoothctl. That was the founding requirement for this
desktop: never having to drop to a terminal to join a network.

Scanning follows the page being visible. Wi-Fi scanning and especially
Bluetooth discovery hold the radio, and running either for a list nobody
is looking at spends airtime on nothing.

Joining a secured network gets a real password field, not the clipboard
popover's search box with different placeholder text: a Wi-Fi key typed
into a field that echoes it is readable by anyone behind you, and a
search glyph in front of a password prompt is simply wrong.

Two bugs found by looking at the rendered page, both silent:

The device lookups used enum names that do not exist --
NetworkDeviceType.Wifi rather than DeviceType.Wifi -- so both returned
null and the page reported "No Wi-Fi adapter" on a machine whose Wi-Fi
was connected. Nothing was logged; QML resolves an unknown enum member
to undefined and compares happily.

signalStrength is 0.0-1.0, not a percentage, so thresholds written for
0-100 put every network including the connected one in the bottom
bucket. The labels now use the same buckets as the icons in
quicksettings/WifiList.qml so the two cannot disagree.

The contract compares what the service resolves against what nmcli
reports, rather than only checking that nothing crashed.

Also makes the Home Assistant bridge hermetic: resolve_config read the
user's private env file even when a caller supplied an explicit
environment, so adding a real PANAMA_HOME_ASSISTANT_ENTITIES to that
file silently overrode a fixture asserting the legacy fallback. An
explicit environment is now the whole environment; production still
reads the file. Its live contract skips when no token is configured --
an absent credential is not a defect, and a suite expected to be red
stops being read -- while a configured-but-broken bridge still fails.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 05:50:07 -04:00
Gabriel Brown c087998710 feat: add notification application rules 2026-08-18 05:38:39 -04:00
Gabriel Brown b9800bfbab Scope compositor isolation to reset tests 2026-08-18 03:25:39 -04:00
Gabriel Brown 7b8270fdd6 Keep Settings recovery isolated and display-safe 2026-08-18 03:19:45 -04:00
Gabriel Brown 6d3f888784 Protect live state during restore and reset 2026-08-18 03:10:31 -04:00
61 changed files with 4036 additions and 219 deletions
@@ -369,6 +369,23 @@ Singleton {
detail: "Requires your password when the machine wakes" detail: "Requires your password when the machine wakes"
}, },
// ── Night light schedule ────────────────────────────────────────────
// Hours as decimals, so 17.5 is half past five. Wrapping past midnight
// is normal here and is what the shipped values do: on at 17:00, off at
// 10:00 the following morning.
{
key: "nightLightFrom", type: "real", def: 17.0, min: 0, max: 23.5, step: 0.5,
group: "nightLight",
label: "Turns on at",
detail: "Only used when Night Light follows a schedule"
},
{
key: "nightLightTo", type: "real", def: 10.0, min: 0, max: 23.5, step: 0.5,
group: "nightLight",
label: "Turns off at",
detail: "A time earlier than the start simply means the next morning"
},
// ── Accessibility ─────────────────────────────────────────────────── // ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and // Backed by gsettings so GTK applications agree with the shell, and
// pushed to the compositor as well where it has its own notion. // pushed to the compositor as well where it has its own notion.
@@ -402,6 +419,47 @@ Singleton {
detail: "How often Panama updates the current conditions" detail: "How often Panama updates the current conditions"
}, },
// ── Which GPU the vitals readout tracks ─────────────────────────────
// A sysfs path rather than a card number, because the number is neither
// stable across machines nor meaningful. Constrained to the one shape
// that can be read for utilisation; VitalsWidget hides itself when the
// path is unreadable, so a stale value degrades to no readout rather
// than a wrong one.
{
key: "gpuBusyPath", type: "string",
def: "/sys/class/drm/card1/device/gpu_busy_percent",
group: "vitals", internal: true,
pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$",
label: "Graphics device",
detail: "Which GPU the graphics readout in the bar measures"
},
// ── Weather location ────────────────────────────────────────────────
// Coordinates rather than a place name, because that is what Open-Meteo
// takes and it needs no API key. weatherLocation is only the label shown
// in the UI; it is never sent anywhere, so it can say whatever makes the
// reading recognisable.
{
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
group: "weather", internal: true,
label: "Latitude",
detail: "Set by choosing a location"
},
{
key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
group: "weather", internal: true,
label: "Longitude",
detail: "Set by choosing a location"
},
{
key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
internal: true,
// Display only -- never sent to the weather service.
pattern: "^[^\\n]{1,64}$",
label: "Weather location",
detail: "The place the weather reading is for"
},
// ── Vitals refresh ────────────────────────────────────────────────── // ── Vitals refresh ──────────────────────────────────────────────────
{ {
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500, key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
@@ -521,6 +579,21 @@ Singleton {
detail: "Resolution, scale, and rotation per connected display" detail: "Resolution, scale, and rotation per connected display"
}, },
// ── Per-application notification rules ──────────────────────────────
// { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
//
// Absent means "no rule", which is not the same as a rule that allows
// everything: a new application must be able to notify without needing
// an entry written for it first. services/Notifs.qml treats a missing
// entry as permissive and Do Not Disturb remains an override on top,
// rather than being duplicated per application.
{
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
internal: true,
label: "Application notification rules",
detail: "Per-application notification and lock-screen visibility preferences"
},
// ── Internal ──────────────────────────────────────────────────────── // ── Internal ────────────────────────────────────────────────────────
{ {
key: "lastPage", type: "string", def: "home", group: "internal", key: "lastPage", type: "string", def: "home", group: "internal",
+6 -6
View File
@@ -22,12 +22,12 @@ Singleton {
// ── Weather ───────────────────────────────────────────────────────────── // ── Weather ─────────────────────────────────────────────────────────────
// Coordinates taken from the GNOME night-light setting, which had already // Coordinates taken from the GNOME night-light setting, which had already
// resolved the location. Uses Open-Meteo, which needs no API key. // resolved the location. Uses Open-Meteo, which needs no API key.
readonly property real latitude: 27.7375 readonly property real latitude: DesktopPreferences.get("weatherLatitude")
readonly property real longitude: -82.6861 readonly property real longitude: DesktopPreferences.get("weatherLongitude")
// Open-Meteo returns coordinates but no friendly place name. Keep the // Open-Meteo returns coordinates but no friendly place name. Keep the
// label deliberately general rather than exposing precise coordinates in // label deliberately general rather than exposing precise coordinates in
// the UI or guessing at a city from them. // the UI or guessing at a city from them.
readonly property string weatherLocation: "Local weather" readonly property string weatherLocation: DesktopPreferences.get("weatherLocation")
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit") readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes") readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
@@ -41,13 +41,13 @@ Singleton {
// amdgpu exposes utilisation here. Verified present on this machine; the // amdgpu exposes utilisation here. Verified present on this machine; the
// widget hides itself if the path is missing rather than showing zeros. // widget hides itself if the path is missing rather than showing zeros.
readonly property string gpuBusyPath: "/sys/class/drm/card1/device/gpu_busy_percent" readonly property string gpuBusyPath: DesktopPreferences.get("gpuBusyPath")
// ── Night light ───────────────────────────────────────────────────────── // ── Night light ─────────────────────────────────────────────────────────
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00. // Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature") readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
readonly property real nightLightFrom: 17.0 readonly property real nightLightFrom: DesktopPreferences.get("nightLightFrom")
readonly property real nightLightTo: 10.0 readonly property real nightLightTo: DesktopPreferences.get("nightLightTo")
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled") readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
// ── Notifications ─────────────────────────────────────────────────────── // ── Notifications ───────────────────────────────────────────────────────
@@ -0,0 +1,28 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
IpcHandler {
target: "connectivity-test"
function status(): string {
return JSON.stringify({
wifiDevice: Connectivity.wifiDevice ? Connectivity.wifiDevice.name : "",
wiredDevice: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "",
wiredConnected: !!(Connectivity.wiredDevice && Connectivity.wiredDevice.connected),
networks: Connectivity.networks.length,
activeSsid: Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "",
activeStrength: Connectivity.activeNetwork ? Connectivity.activeNetwork.signalStrength : -1,
activeLabel: Connectivity.activeNetwork ? Connectivity.signalLabel(Connectivity.activeNetwork.signalStrength) : "",
adapter: Connectivity.adapter ? true : false,
btDevices: Connectivity.bluetoothDevices.length
});
}
function labelFor(strength: real): string { return Connectivity.signalLabel(strength); }
function setActive(on: bool): void { Connectivity.active = on; }
}
}
@@ -0,0 +1,87 @@
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function finiteNumber(value, fallback) {
var parsed = Number(value);
return isFinite(parsed) ? parsed : fallback;
}
function iconFor(kind, ratio) {
var name = String(kind || "").toLowerCase();
if (name === "volume-muted")
return "audio-volume-muted-symbolic";
if (name === "volume") {
if (ratio <= 0)
return "audio-volume-muted-symbolic";
if (ratio < 0.34)
return "audio-volume-low-symbolic";
if (ratio < 0.67)
return "audio-volume-medium-symbolic";
return "audio-volume-high-symbolic";
}
if (name === "microphone-muted")
return "microphone-sensitivity-muted-symbolic";
if (name === "microphone")
return "audio-input-microphone-symbolic";
if (name === "brightness")
return "display-brightness-symbolic";
if (name === "media-play" || name === "media-playing")
return "media-playback-start-symbolic";
if (name === "media-pause" || name === "media-paused")
return "media-playback-pause-symbolic";
if (name === "media-next")
return "media-skip-forward-symbolic";
if (name === "media-previous")
return "media-skip-backward-symbolic";
if (name === "media-stop")
return "media-playback-stop-symbolic";
return name || "dialog-information-symbolic";
}
function normalizedDuration(value) {
var parsed = finiteNumber(value, 1400);
return Math.max(0, Math.round(parsed));
}
function progressState(kind, rawValue, rawMaximum, rawLabel, rawDuration) {
var maximum = Math.max(1, finiteNumber(rawMaximum, 100));
var value = clamp(finiteNumber(rawValue, 0), 0, maximum);
var ratio = value / maximum;
var label = String(rawLabel || "");
if (!label)
label = Math.round(ratio * 100) + "%";
return {
kind: String(kind || ""),
value: value,
maximum: maximum,
ratio: ratio,
label: label,
icon: iconFor(kind, ratio),
duration: normalizedDuration(rawDuration),
progress: true
};
}
function messageState(kind, rawLabel, rawDuration) {
return {
kind: String(kind || ""),
value: 0,
maximum: 100,
ratio: 0,
label: String(rawLabel || ""),
icon: iconFor(kind, 0),
duration: normalizedDuration(rawDuration),
progress: false
};
}
if (typeof module !== "undefined") {
module.exports = {
clamp: clamp,
iconFor: iconFor,
progressState: progressState,
messageState: messageState
};
}
@@ -6,6 +6,7 @@ import QtQuick
import Quickshell import Quickshell
import Quickshell.Services.Pipewire import Quickshell.Services.Pipewire
import qs.config import qs.config
import qs.services
Item { Item {
id: root id: root
@@ -16,23 +17,12 @@ Item {
implicitHeight: list.implicitHeight implicitHeight: list.implicitHeight
readonly property var nodes: { readonly property var nodes: root.output ? AudioDevices.outputs : AudioDevices.inputs
return Pipewire.nodes.values.filter(n => {
if (n.isStream)
return false;
// Sources have to be filtered on the type flags: !isSink also
// matches video nodes (webcams show up here otherwise).
return root.output ? n.isSink : (n.type & PwNodeType.AudioSource) === PwNodeType.AudioSource;
});
}
readonly property var current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource readonly property var current: AudioDevices.current(root.output)
function select(node): void { function select(node): void {
if (root.output) AudioDevices.select(root.output, node)
Pipewire.preferredDefaultAudioSink = node;
else
Pipewire.preferredDefaultAudioSource = node;
} }
ScrollColumn { ScrollColumn {
@@ -52,7 +42,7 @@ Item {
implicitHeight: 38 implicitHeight: 38
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic" icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
iconFallback: "audio-card-symbolic" iconFallback: "audio-card-symbolic"
label: nodeRow.modelData.description || nodeRow.modelData.nickname || nodeRow.modelData.name label: AudioDevices.label(nodeRow.modelData)
selected: nodeRow.modelData === root.current selected: nodeRow.modelData === root.current
onClicked: root.select(nodeRow.modelData) onClicked: root.select(nodeRow.modelData)
} }
@@ -103,7 +103,24 @@ SettingsPage {
ToggleRow { setting: "showCpu" } ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" } ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu"; divider: false } ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
// Only worth asking when there is a choice to make.
ChoiceGrid {
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
width: parent.width
label: "Graphics device"
detail: GraphicsDevices.selectionMissing
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
: "Which GPU the graphics readout measures."
options: GraphicsDevices.devices.map(device => ({
value: device.path,
label: GraphicsDevices.shortName(device.name)
}))
current: GraphicsDevices.selectedPath
divider: false
onPicked: value => GraphicsDevices.select(value)
}
} }
SettingsCard { SettingsCard {
@@ -0,0 +1,72 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.config
import qs.widgets
SettingRow {
id: root
property var node: null
label: "Balance"
detail: "Adjust the left and right channels"
controlWidth: 270
visible: root.available
divider: false
PwObjectTracker {
objects: root.node ? [root.node] : []
}
function channelIndex(channel): int {
if (!root.node?.audio)
return -1;
const channels = root.node.audio.channels;
for (let index = 0; index < channels.length; index++) {
if (channels[index] === channel)
return index;
}
return -1;
}
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
readonly property bool available: root.node?.audio
&& root.leftIndex >= 0 && root.rightIndex >= 0
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
readonly property real position: {
if (!root.available)
return 0.5;
const left = root.node.audio.volumes[root.leftIndex];
const right = root.node.audio.volumes[root.rightIndex];
const level = Math.max(left, right);
if (level <= 0.001)
return 0.5;
return right >= left ? 0.5 + (1 - left / level) * 0.5
: 0.5 - (1 - right / level) * 0.5;
}
function setBalance(value: real): void {
if (!root.available)
return;
const next = Array.from(root.node.audio.volumes);
const level = Math.max(next[root.leftIndex], next[root.rightIndex], 0.001);
if (value < 0.5) {
next[root.leftIndex] = level;
next[root.rightIndex] = level * value * 2;
} else {
next[root.leftIndex] = level * (1 - value) * 2;
next[root.rightIndex] = level;
}
root.node.audio.volumes = next;
}
ValueSlider {
anchors.fill: parent
value: root.position
icon: "audio-speakers-symbolic"
onMoved: value => root.setBalance(value)
}
}
@@ -0,0 +1,95 @@
// Bluetooth, at page size.
//
// Paired devices first, because reconnecting to something you already own is
// what you are here for nine times out of ten; discovered devices follow.
// Battery is shown where BlueZ reports it, which is the one thing people
// routinely open a terminal for.
import QtQuick
import Quickshell
import Quickshell.Bluetooth
import qs.config
import qs.services
Column {
id: root
spacing: 0
function primaryAction(device: var): void {
if (device.connected) {
device.disconnect();
return;
}
if (device.paired) {
device.connect();
return;
}
device.pair();
}
function stateLabel(device: var): string {
if (device.pairing)
return "Pairing…";
if (device.connected)
return device.batteryAvailable
? `Connected · ${Math.round(device.battery * 100)}% battery`
: "Connected";
if (device.paired)
return "Paired";
return device.address || "Not paired";
}
Repeater {
model: Connectivity.bluetoothDevices
SettingRow {
id: entry
required property var modelData
required property int index
width: parent.width
label: entry.modelData.name || entry.modelData.address || "Unknown device"
detail: root.stateLabel(entry.modelData)
divider: entry.index < Connectivity.bluetoothDevices.length - 1
controlWidth: 200
activatable: !entry.modelData.pairing
onActivated: root.primaryAction(entry.modelData)
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
enabled: !entry.modelData.pairing
text: entry.modelData.connected
? "Disconnect"
: (entry.modelData.paired ? "Connect" : "Pair")
onClicked: root.primaryAction(entry.modelData)
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.paired
text: "Forget"
onClicked: entry.modelData.forget()
}
}
}
}
SettingRow {
width: parent.width
visible: Connectivity.bluetoothDevices.length === 0
label: !Connectivity.adapter
? "No Bluetooth adapter"
: (Connectivity.adapter.enabled ? "Looking for devices…" : "Bluetooth is off")
detail: Connectivity.adapter && !Connectivity.adapter.enabled
? "Turn it on above to discover devices"
: "Put the device into pairing mode to make it appear"
divider: false
}
}
@@ -1,92 +1,124 @@
// Network & Devices.
//
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was
// the founding requirement for this desktop: never having to drop to a terminal
// to join a network.
//
// Scanning follows this page being on screen. Wi-Fi scanning and especially
// Bluetooth discovery hold the radio, and doing either for a list nobody is
// looking at is battery and airtime spent on nothing.
import QtQuick import QtQuick
import Quickshell
import Quickshell.Networking import Quickshell.Networking
import Quickshell.Bluetooth
import qs.config import qs.config
import qs.services import qs.services
import qs.modules.quicksettings
SettingsPage { SettingsPage {
id: root id: root
title: "Network & Devices" title: "Network & Devices"
lede: "Connect graphically—no terminal workflow required." lede: Connectivity.activeNetwork
? "Connected to " + Connectivity.activeNetwork.name
: "Wi-Fi, Bluetooth, and the things Fedora owns."
readonly property var wifiDevice: { // Drive the scanners only while this page is the one being shown.
for (const device of Networking.devices.values) { Component.onCompleted: Connectivity.active = true
if (device.type === DeviceType.Wifi) Component.onDestruction: Connectivity.active = false
return device;
}
return null;
}
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
SettingsCard { SettingsCard {
title: "Wi‑Fi" title: "Wired"
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off" visible: Connectivity.wiredDevice !== null
TextRow {
label: "Ethernet"
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
divider: false
}
}
SettingsCard {
title: "Wi-Fi"
// A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a
// contradiction; with no radio the card simply does not belong.
visible: Connectivity.wifiDevice !== null
subtitle: "Networks are re-scanned while this page is open."
SettingRow { SettingRow {
label: "Wi‑Fi" label: "Wi-Fi"
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found" detail: Connectivity.wifiEnabled ? "On" : "Off"
controlWidth: 48 controlWidth: 48
divider: Connectivity.wifiEnabled
SettingsToggle { SettingsToggle {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: Networking.wifiEnabled checked: Connectivity.wifiEnabled
enabled: Networking.wifiHardwareEnabled enabled: Connectivity.wifiAvailable
onToggled: value => Networking.wifiEnabled = value onToggled: value => Networking.wifiEnabled = value
} }
} }
WifiList { WifiPanel {
width: parent.width width: parent.width
device: root.wifiDevice visible: Connectivity.wifiEnabled
active: true
maxHeight: 240
}
ActionRow {
label: "Advanced network settings"
detail: "VPN, wired profiles, DNS, and connection details"
divider: false
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("network")
} }
} }
SettingsCard { SettingsCard {
title: "Bluetooth" title: "Bluetooth"
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off" visible: Connectivity.adapter !== null
subtitle: "Discovery runs while this page is open."
SettingRow { SettingRow {
label: "Bluetooth" label: "Bluetooth"
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found" detail: Connectivity.adapter
? (Connectivity.adapter.enabled ? "On" : "Off")
: "Unavailable"
controlWidth: 48 controlWidth: 48
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
SettingsToggle { SettingsToggle {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: root.bluetoothAdapter?.enabled ?? false checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
enabled: root.bluetoothAdapter !== null enabled: Connectivity.adapter !== null
onToggled: value => { onToggled: value => {
if (root.bluetoothAdapter) if (Connectivity.adapter)
root.bluetoothAdapter.enabled = value; Connectivity.adapter.enabled = value;
} }
} }
} }
BluetoothList { BluetoothPanel {
width: parent.width width: parent.width
active: true visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
maxHeight: 220
} }
}
SettingsCard {
title: "Owned by Fedora"
subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable."
ActionRow { ActionRow {
label: "Advanced Bluetooth settings" label: "Network connections"
detail: "Device details and system-level options" detail: "VPN, proxies, and per-connection settings"
action: "Open"
onTriggered: SystemSettings.openGnomePanel("network")
}
ActionRow {
label: "Printers"
action: "Open"
onTriggered: SystemSettings.openGnomePanel("printers")
}
ActionRow {
label: "Online accounts"
action: "Open"
divider: false divider: false
action: "Open panel" onTriggered: SystemSettings.openGnomePanel("online-accounts")
onTriggered: SystemSettings.openGnomePanel("bluetooth")
} }
} }
} }
@@ -187,6 +187,19 @@ SettingsPage {
} }
} }
SettingsCard {
title: "Night Light"
subtitle: NightLight.active
? "On now, warming the display to reduce blue light."
: "Warms the display in the evening to reduce blue light."
ToggleRow { setting: "nightLightEnabled" }
ToggleRow { setting: "nightLightAutomatic" }
TimeOfDayRow { setting: "nightLightFrom" }
TimeOfDayRow { setting: "nightLightTo" }
SliderRow { setting: "nightLightTemperature"; divider: false }
}
SettingsCard { SettingsCard {
title: "Gaming display policy" title: "Gaming display policy"
subtitle: "Applied immediately and restored when Panama starts." subtitle: "Applied immediately and restored when Panama starts."
@@ -9,93 +9,61 @@ Rectangle {
required property string sourceName required property string sourceName
required property int index required property int index
required property bool featured required property bool featured
required property bool canMoveEarlier
required property bool canMoveLater
signal aliasCommitted(string id, string alias) signal aliasCommitted(string id, string alias)
signal removeRequested(string id) signal removeRequested(string id)
signal moveRequested(string id, int targetIndex) signal moveRequested(string id, int targetIndex)
readonly property bool dragging: dragHandler.active
implicitHeight: 108 implicitHeight: 108
radius: Theme.cardRadius radius: Theme.cardRadius
color: root.dragging color: Theme.alpha(Theme.bgDark, 0.7)
? Theme.mix(Theme.bgDark, Theme.accent, 0.09) border.width: 1
: Theme.alpha(Theme.bgDark, 0.7) border.color: Theme.alpha(Theme.fg, 0.07)
border.width: root.dragging ? 2 : 1
border.color: root.dragging
? Theme.alpha(Theme.accent, 0.82)
: Theme.alpha(Theme.fg, 0.07)
z: root.dragging ? 10 : 0
transform: Translate {
x: root.dragging ? dragHandler.translation.x : 0
y: root.dragging ? dragHandler.translation.y : 0
}
PrismEdge { PrismEdge {
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
inset: root.radius inset: root.radius
opacity: root.dragging ? 0.82 : 0.2 opacity: 0.2
} }
Rectangle { Column {
id: dragHandle id: reorderControls
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 11 anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: 30 width: 30
height: 42 spacing: 4
radius: 9
activeFocusOnTab: true
color: root.dragging || activeFocus
? Theme.alpha(Theme.accent, 0.14)
: (handleMouse.containsMouse ? Theme.alpha(Theme.fg, 0.09) : Theme.alpha(Theme.fg, 0.045))
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.06)
Text { SettingsButton {
anchors.centerIn: parent width: 30
text: "⠿" height: 29
color: root.dragging ? Theme.accent : Theme.fgDim text: "↑"
font.family: Theme.fontFamily enabled: root.canMoveEarlier
font.pixelSize: 16 activeFocusOnTab: enabled
onClicked: root.moveRequested(root.favorite.id, root.index - 1)
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
} }
MouseArea { SettingsButton {
id: handleMouse width: 30
anchors.fill: parent height: 29
hoverEnabled: true text: "↓"
acceptedButtons: Qt.NoButton enabled: root.canMoveLater
cursorShape: Qt.SizeAllCursor activeFocusOnTab: enabled
} onClicked: root.moveRequested(root.favorite.id, root.index + 1)
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
DragHandler { Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
id: dragHandler
target: null
onActiveChanged: {
if (!active)
root.commitDrag();
}
}
Keys.onPressed: event => {
if (event.key === Qt.Key_Left || event.key === Qt.Key_Up) {
root.moveRequested(root.favorite.id, Math.max(0, root.index - 1));
event.accepted = true;
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Down) {
const grid = root.GridView.view;
const lastIndex = grid ? grid.count - 1 : root.index;
root.moveRequested(root.favorite.id, Math.min(lastIndex, root.index + 1));
event.accepted = true;
}
} }
} }
Rectangle { Rectangle {
id: aliasFrame id: aliasFrame
anchors.left: dragHandle.right anchors.left: reorderControls.right
anchors.leftMargin: 10 anchors.leftMargin: 10
anchors.right: removeButton.left anchors.right: removeButton.left
anchors.rightMargin: 12 anchors.rightMargin: 12
@@ -188,16 +156,4 @@ Rectangle {
Keys.onReturnPressed: root.removeRequested(root.favorite.id) Keys.onReturnPressed: root.removeRequested(root.favorite.id)
Keys.onSpacePressed: root.removeRequested(root.favorite.id) Keys.onSpacePressed: root.removeRequested(root.favorite.id)
} }
function commitDrag(): void {
const grid = root.GridView.view;
if (!grid || grid.count <= 0)
return;
const centerX = root.x + dragHandler.translation.x + root.width / 2;
const centerY = root.y + dragHandler.translation.y + root.height / 2;
const modelCount = grid.count;
const column = Math.max(0, Math.min(1, Math.floor(centerX / grid.cellWidth)));
const row = Math.max(0, Math.floor(centerY / grid.cellHeight));
root.moveRequested(root.favorite.id, Math.min(modelCount - 1, row * 2 + column));
}
} }
@@ -103,6 +103,16 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Weather" title: "Weather"
subtitle: "Local conditions in the date menu" subtitle: "Local conditions in the date menu"
TextRow {
label: "Location"
detail: "Only the search term is sent; the name below is a label kept on this machine"
value: Settings.weatherLocation
}
LocationPicker {
width: parent.width
}
ChoiceRow { setting: "temperatureUnit" } ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false } SliderRow { setting: "weatherRefreshMinutes"; divider: false }
} }
@@ -43,10 +43,200 @@ SettingsPage {
return "Home Assistant is unavailable"; return "Home Assistant is unavailable";
} }
function saveHomeAssistantConfig(): void {
HomeAssistantConfig.save(
homeUrlInput.text,
homeEntitiesInput.text,
homeTokenInput.text
);
}
Connections {
target: HomeAssistantConfig
function onConfigurationSaved(): void {
homeTokenInput.clear();
homeUrlInput.text = HomeAssistantConfig.url;
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
}
}
SettingsCard { SettingsCard {
title: "Home Assistant" title: "Home Assistant"
subtitle: root.homeStatus() subtitle: root.homeStatus()
SettingRow {
label: "Connection"
detail: HomeAssistantConfig.tokenConfigured
? "A long-lived access token is stored privately"
: "Paste a long-lived access token to connect"
value: HomeAssistantConfig.configured ? "Configured" : "Not configured"
}
SettingRow {
label: "Server URL"
detail: "The local or remote address of Home Assistant"
controlWidth: 330
Rectangle {
anchors.fill: parent
radius: Theme.pillRadius
color: Theme.alpha(Theme.fg, 0.07)
border.width: homeUrlInput.activeFocus ? 2 : 1
border.color: homeUrlInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : "transparent"
TextInput {
id: homeUrlInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
activeFocusOnTab: true
text: HomeAssistantConfig.url
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
verticalAlignment: TextInput.AlignVCenter
clip: true
Text {
anchors.fill: parent
visible: homeUrlInput.text === ""
text: "https://homeassistant.local:8123"
color: Theme.fgMuted
font: homeUrlInput.font
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
SettingRow {
label: "Access token"
detail: HomeAssistantConfig.tokenConfigured
? "Stored · leave blank to keep it"
: "Create one in your Home Assistant profile"
controlWidth: 330
PasswordField {
id: homeTokenInput
anchors.fill: parent
placeholder: HomeAssistantConfig.tokenConfigured
? "Stored token" : "Long-lived access token"
onAccepted: root.saveHomeAssistantConfig()
}
}
Column {
width: parent.width
spacing: 7
topPadding: 10
bottomPadding: 12
Text {
width: parent.width
text: "Light entities"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Text {
width: parent.width
text: "Comma-separated entity IDs. These define the discoverable light catalog."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Rectangle {
width: parent.width
height: 72
radius: 10
color: Theme.alpha(Theme.fg, 0.055)
border.width: homeEntitiesInput.activeFocus ? 2 : 1
border.color: homeEntitiesInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
TextEdit {
id: homeEntitiesInput
anchors.fill: parent
anchors.margins: 10
activeFocusOnTab: true
text: HomeAssistantConfig.entities.join(", ")
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
wrapMode: TextEdit.Wrap
clip: true
Text {
anchors.fill: parent
visible: homeEntitiesInput.text === ""
text: "light.living_room, light.kitchen"
color: Theme.fgMuted
font: homeEntitiesInput.font
wrapMode: Text.WordWrap
}
}
}
}
Text {
width: parent.width
visible: HomeAssistantConfig.lastError !== ""
text: HomeAssistantConfig.lastError
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
bottomPadding: 9
}
SettingRow {
label: "Private configuration"
detail: "Saved with owner-only permissions in Panama's private environment file"
controlWidth: 216
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
id: clearTokenButton
text: "Clear token"
enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistantConfig.clearToken()
Keys.onReturnPressed: if (enabled) HomeAssistantConfig.clearToken()
Keys.onSpacePressed: if (enabled) HomeAssistantConfig.clearToken()
}
SettingsButton {
id: saveHomeConfigButton
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
tone: "accent"
enabled: !HomeAssistantConfig.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 0
border.color: activeFocus ? Theme.fg : "transparent"
onClicked: root.saveHomeAssistantConfig()
Keys.onReturnPressed: if (enabled) root.saveHomeAssistantConfig()
Keys.onSpacePressed: if (enabled) root.saveHomeAssistantConfig()
}
}
}
SettingRow { SettingRow {
label: "Light catalog" label: "Light catalog"
detail: "Panama reads light state through the Home Assistant helper." detail: "Panama reads light state through the Home Assistant helper."
@@ -122,6 +312,8 @@ SettingsPage {
}) })
sourceName: modelData.sourceName sourceName: modelData.sourceName
featured: index < 4 featured: index < 4
canMoveEarlier: index > 0
canMoveLater: index < favoritesGrid.count - 1
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias) onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex) onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
onRemoveRequested: id => HomePreferences.remove(id) onRemoveRequested: id => HomePreferences.remove(id)
@@ -0,0 +1,53 @@
// Choosing where the weather reading is for.
//
// A search box rather than latitude and longitude fields: nobody knows their
// own coordinates, and a control that demands them is one nobody ever uses. The
// coordinates are what actually get stored -- the name is only a label.
import QtQuick
import qs.config
import qs.services
import qs.modules.clipboard
Column {
id: root
spacing: 0
SearchField {
id: query
width: parent.width
placeholder: "Search for a town or city"
onTextChanged: Geocoding.search(query.text)
}
Repeater {
model: Geocoding.results
SettingRow {
id: place
required property var modelData
required property int index
label: place.modelData.name
detail: [place.modelData.admin, place.modelData.country].filter(part => !!part).join(", ")
value: place.modelData.latitude.toFixed(2) + ", " + place.modelData.longitude.toFixed(2)
controlWidth: 150
divider: place.index < Geocoding.results.length - 1
activatable: true
onActivated: {
if (Geocoding.choose(place.modelData))
query.text = "";
}
}
}
SettingRow {
width: parent.width
visible: Geocoding.searching || Geocoding.lastError !== ""
label: Geocoding.searching ? "Searching…" : "No result"
detail: Geocoding.searching ? "" : Geocoding.lastError
divider: false
}
}
@@ -50,10 +50,89 @@ SettingsPage {
SliderRow { setting: "maxVisibleToasts"; divider: false } SliderRow { setting: "maxVisibleToasts"; divider: false }
} }
SettingsCard {
title: "Application rules"
subtitle: "Apps appear here after they send a notification."
TextRow {
visible: Notifs.applications.length === 0
label: "No applications remembered yet"
detail: "Application controls will appear after the first notification arrives."
divider: false
}
Repeater {
model: Notifs.applications
Column {
required property var modelData
readonly property var app: modelData
width: parent.width
SettingRow {
label: app.name
detail: app.id
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).enabled
onToggled: value => Notifs.setAppRule(app.id, { enabled: value })
}
}
SettingRow {
label: "Show on lock screen"
detail: "Allow this app's notifications on the lock screen"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).showOnLockScreen
onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value })
}
}
SettingRow {
label: "Show content on lock screen"
detail: "Show message details when this app is visible there"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.appRule(app.id).showContentOnLockScreen
onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value })
}
}
}
}
}
SettingsCard { SettingsCard {
title: "Focus sessions" title: "Focus sessions"
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace." subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
SettingRow {
label: "Keep the screen awake"
detail: Caffeine.enabled
? "The display will not blank or lock while this is on"
: "Idle timings on Power & Lock apply normally"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Caffeine.enabled
onToggled: value => Caffeine.enabled = value
}
}
SettingRow { SettingRow {
id: durationRow id: durationRow
@@ -0,0 +1,106 @@
// A password entry with a reveal toggle.
//
// Deliberately not the clipboard popover's SearchField with different text: a
// Wi-Fi key typed into a field that echoes it is readable by anyone behind you,
// and a search glyph in front of a password prompt is simply wrong. Masked by
// default, revealable while held, because the reason people want to see it is
// to check a character they just typed.
import QtQuick
import qs.config
Rectangle {
id: root
property alias text: input.text
property string placeholder: "Password"
property bool revealed: false
signal accepted
implicitHeight: 32
radius: Theme.pillRadius
color: Theme.alpha(Theme.fg, 0.07)
// Not left at 0 so the focus ring has something to animate. See the note in
// modules/clipboard/SearchField.qml.
border.width: 1
border.color: input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent"
Behavior on border.color {
ColorAnimation { duration: Theme.durFast }
}
function grab(): void {
input.forceActiveFocus();
}
function clear(): void {
input.text = "";
root.revealed = false;
}
TextInput {
id: input
anchors.left: parent.left
anchors.leftMargin: 13
anchors.right: revealButton.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
echoMode: root.revealed ? TextInput.Normal : TextInput.Password
passwordCharacter: "•"
clip: true
onAccepted: root.accepted()
Text {
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
visible: input.text === ""
text: root.placeholder
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
}
Rectangle {
id: revealButton
anchors.right: parent.right
anchors.rightMargin: 5
anchors.verticalCenter: parent.verticalCenter
width: 26
height: 24
radius: 7
color: revealHover.hovered ? Theme.alpha(Theme.fg, 0.12) : "transparent"
border.width: 0
visible: input.text !== ""
Text {
anchors.centerIn: parent
// Nerd Font eye / eye-slash. fontMono is used for icon glyphs only.
text: root.revealed ? "\u{F070}" : "\u{F06E}"
font.family: Theme.fontMono
font.pixelSize: 12
color: root.revealed ? Theme.accent : Theme.fgMuted
}
HoverHandler {
id: revealHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.revealed = !root.revealed
}
}
}
@@ -28,6 +28,11 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Keyboard" 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"
}
SliderRow { setting: "keyRepeatDelay" } SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" } SliderRow { setting: "keyRepeatRate" }
ToggleRow { setting: "numlockByDefault"; divider: false } ToggleRow { setting: "numlockByDefault"; divider: false }
@@ -0,0 +1,40 @@
import QtQuick
import Quickshell.Services.Pipewire
import qs.config
import qs.services
Column {
id: root
property bool output: true
readonly property var nodes: AudioDevices.nodes(root.output)
readonly property var current: AudioDevices.current(root.output)
width: parent ? parent.width : 620
spacing: 8
Repeater {
model: root.nodes
SoundDeviceRow {
required property var modelData
width: root.width
node: modelData
output: root.output
selected: modelData === root.current
}
}
Text {
width: parent.width
visible: root.nodes.length === 0
text: Pipewire.ready ? "No audio devices found" : "Discovering audio devices…"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
topPadding: 18
bottomPadding: 18
}
}
@@ -0,0 +1,168 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.config
import qs.widgets
import qs.services
import qs.modules.quicksettings
Rectangle {
id: root
required property var node
property bool output: true
property bool selected: false
implicitHeight: root.output || !root.selected ? 88 : 105
radius: Theme.cardRadius
color: root.selected ? Theme.alpha(Theme.accent, 0.09) : Theme.alpha(Theme.fg, 0.025)
border.width: 1
border.color: root.selected ? Theme.alpha(Theme.accent, 0.34) : Theme.alpha(Theme.fg, 0.07)
PwObjectTracker {
objects: root.node ? [root.node] : []
}
PwNodePeakMonitor {
id: inputPeak
node: root.node
enabled: !root.output && root.selected
}
readonly property real volume: root.node?.audio?.volume ?? 0
readonly property bool muted: root.node?.audio?.muted ?? false
function iconName(): string {
if (!root.output)
return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic";
if (root.muted || root.volume <= 0.001)
return "audio-volume-muted-symbolic";
if (root.volume < 0.34)
return "audio-volume-low-symbolic";
if (root.volume < 0.67)
return "audio-volume-medium-symbolic";
return "audio-volume-high-symbolic";
}
Row {
id: heading
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 12
anchors.rightMargin: 10
anchors.topMargin: 8
height: 28
spacing: 10
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
size: 20
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
iconFallback: "audio-card-symbolic"
tint: root.selected ? Theme.accent : Theme.fg
}
Column {
width: Math.max(0, parent.width - 20 - useButton.width - parent.spacing * 2)
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text {
width: parent.width
text: AudioDevices.label(root.node)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: root.selected ? Font.DemiBold : Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: root.node.nickname && root.node.nickname !== AudioDevices.label(root.node)
text: root.node.nickname ?? ""
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
SettingsButton {
id: useButton
anchors.verticalCenter: parent.verticalCenter
width: root.selected ? 78 : 64
text: root.selected ? "Default" : "Use"
enabled: !root.selected
onClicked: AudioDevices.select(root.output, root.node)
}
}
IconButton {
id: muteButton
anchors.left: parent.left
anchors.leftMargin: 8
anchors.top: heading.bottom
anchors.topMargin: 7
size: 30
iconSize: 17
icon: root.iconName()
iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic"
onClicked: {
if (root.node?.audio)
root.node.audio.muted = !root.node.audio.muted;
}
}
ValueSlider {
id: volumeSlider
anchors.left: muteButton.right
anchors.leftMargin: 7
anchors.right: volumeText.left
anchors.rightMargin: 10
anchors.verticalCenter: muteButton.verticalCenter
value: root.muted ? 0 : root.volume
onMoved: value => {
if (!root.node?.audio)
return;
root.node.audio.muted = false;
root.node.audio.volume = value;
}
}
Text {
id: volumeText
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: muteButton.verticalCenter
width: 38
text: Math.round(root.volume * 100) + "%"
color: Theme.fgDim
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
}
Rectangle {
anchors.left: volumeSlider.left
anchors.right: volumeText.right
anchors.top: muteButton.bottom
anchors.topMargin: 5
height: 5
radius: height / 2
visible: !root.output && root.selected
color: Theme.alpha(Theme.fg, 0.10)
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
radius: parent.radius
color: inputPeak.peak > 0.88 ? Theme.danger : Theme.accentSecondary
}
}
}
@@ -1,8 +1,6 @@
import QtQuick import QtQuick
import Quickshell.Services.Pipewire
import qs.config import qs.config
import qs.services import qs.services
import qs.modules.quicksettings
SettingsPage { SettingsPage {
title: "Sound" title: "Sound"
@@ -10,43 +8,58 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Output" title: "Output"
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device" subtitle: AudioDevices.current(true)?.description ?? "No output device"
AudioSlider { SoundDeviceList {
width: parent.width
node: Pipewire.defaultAudioSink
output: true
}
Rectangle {
width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.06)
}
AudioDeviceList {
width: parent.width width: parent.width
output: true output: true
maxHeight: 190 }
AudioBalance {
width: parent.width
node: AudioDevices.current(true)
} }
} }
SettingsCard { SettingsCard {
title: "Input" title: "Input"
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device" subtitle: AudioDevices.current(false)?.description ?? "No input device"
AudioSlider { SoundDeviceList {
width: parent.width
node: Pipewire.defaultAudioSource
output: false
}
Rectangle {
width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.06)
}
AudioDeviceList {
width: parent.width width: parent.width
output: false output: false
maxHeight: 160 }
}
SettingsCard {
title: "Sound feedback"
subtitle: "Use the same event preferences as GTK and GNOME applications."
SettingRow {
label: "Event sounds"
detail: "Play alerts and interface event sounds"
controlWidth: 42
SettingsToggle {
anchors.fill: parent
checked: SoundFeedback.eventSounds
enabled: !SoundFeedback.busy
onToggled: checked => SoundFeedback.setEventSounds(checked)
}
}
SettingRow {
label: "Input feedback"
detail: "Play sounds for supported typing and input events"
controlWidth: 42
divider: false
SettingsToggle {
anchors.fill: parent
checked: SoundFeedback.inputFeedback
enabled: !SoundFeedback.busy
onToggled: checked => SoundFeedback.setInputFeedback(checked)
}
} }
} }
@@ -0,0 +1,31 @@
// A time of day, stored as a decimal hour.
//
// SliderRow would render 17.5 as "17.50", which is not how anyone reads a
// clock. This is the same control with the readout formatted as a time, so the
// night light schedule says "5:30 PM" rather than a number you have to convert.
//
// Honours the 24-hour clock preference, because a user who has asked for 18:30
// everywhere else should not be shown 6:30 PM here.
import QtQuick
import qs.config
import qs.services
SliderRow {
id: root
// SliderRow renders `unit` after the number; a time needs the whole readout
// replaced, so the formatting is done here instead.
function display(value: real): string {
const hour = Math.floor(value);
const minute = Math.round((value - hour) * 60);
const padded = String(minute).padStart(2, "0");
if (Settings.use24Hour)
return `${String(hour).padStart(2, "0")}:${padded}`;
const suffix = hour < 12 ? "AM" : "PM";
const twelve = hour % 12 === 0 ? 12 : hour % 12;
return `${twelve}:${padded} ${suffix}`;
}
}
@@ -0,0 +1,177 @@
// Wi-Fi, at page size.
//
// The quick settings version is a popover: a compact list you glance at. This
// is the one you sit in front of when a network is not behaving, so each row
// carries what you would otherwise open a terminal to find out — signal,
// security, and whether it is a network this machine already knows.
//
// Joining a secured network reveals an inline password field rather than
// failing silently, which is the one interaction the popover already got right
// and is worth keeping identical.
import QtQuick
import Quickshell
import Quickshell.Networking
import qs.config
import qs.services
import qs.widgets
Column {
id: root
spacing: 0
// SSID whose password field is open, and the last failure.
property string passwordFor: ""
property string failedSsid: ""
property string failedText: ""
function activate(network: var): void {
root.failedSsid = "";
if (network.connected)
return;
if (network.known || !Connectivity.isSecured(network)) {
root.passwordFor = "";
network.connect();
return;
}
root.passwordFor = root.passwordFor === network.name ? "" : network.name;
}
Repeater {
model: Connectivity.networks
Column {
id: entry
required property var modelData
required property int index
width: parent.width
SettingRow {
width: parent.width
label: entry.modelData.name || "Hidden network"
detail: {
const bits = [];
if (entry.modelData.connected)
bits.push("Connected");
else if (entry.modelData.known)
bits.push("Saved");
bits.push(Connectivity.signalLabel(entry.modelData.signalStrength));
bits.push(Connectivity.securityLabel(entry.modelData));
return bits.join(" · ");
}
divider: entry.index < Connectivity.networks.length - 1 || root.passwordFor === entry.modelData.name
controlWidth: 190
activatable: !entry.modelData.connected
onActivated: root.activate(entry.modelData)
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Text {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.connected
text: "Connected"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.connected
text: "Disconnect"
onClicked: entry.modelData.disconnect()
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: !entry.modelData.connected
text: entry.modelData.known ? "Connect" : "Join"
onClicked: root.activate(entry.modelData)
}
}
}
// The password field for this network, when it is the one being
// joined. Inline rather than a dialog: a dialog over a tiled window
// is a worse place to type than the row you just clicked.
Item {
width: parent.width
height: root.passwordFor === entry.modelData.name ? 54 : 0
visible: height > 0
clip: true
onVisibleChanged: {
if (visible)
password.grab();
else
password.clear();
}
PasswordField {
id: password
anchors.left: parent.left
anchors.right: joinButton.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
placeholder: "Password for " + (entry.modelData.name || "network")
onAccepted: joinButton.join()
}
SettingsButton {
id: joinButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Join"
function join(): void {
entry.modelData.connect(password.text);
root.passwordFor = "";
password.text = "";
}
onClicked: joinButton.join()
}
}
Text {
width: parent.width
visible: root.failedSsid === entry.modelData.name
leftPadding: 2
bottomPadding: 8
text: root.failedText
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Connections {
target: entry.modelData
function onConnectionFailed(reason): void {
root.failedSsid = entry.modelData.name;
root.failedText = Connectivity.connectionFailureText(reason);
root.passwordFor = entry.modelData.name;
}
}
}
}
SettingRow {
width: parent.width
visible: Connectivity.networks.length === 0
label: !Connectivity.wifiDevice
? "No Wi-Fi adapter"
: (Connectivity.wifiEnabled ? "Looking for networks…" : "Wi-Fi is off")
detail: Connectivity.wifiDevice && !Connectivity.wifiEnabled
? "Turn it on above to see what is nearby"
: ""
divider: false
}
}
@@ -37,3 +37,11 @@ DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml ShortcutCapture 1.0 ShortcutCapture.qml
ChoiceGrid 1.0 ChoiceGrid.qml ChoiceGrid 1.0 ChoiceGrid.qml
DisplayModePicker 1.0 DisplayModePicker.qml DisplayModePicker 1.0 DisplayModePicker.qml
WifiPanel 1.0 WifiPanel.qml
BluetoothPanel 1.0 BluetoothPanel.qml
PasswordField 1.0 PasswordField.qml
AudioBalance 1.0 AudioBalance.qml
SoundDeviceList 1.0 SoundDeviceList.qml
SoundDeviceRow 1.0 SoundDeviceRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Enumerates GPUs that can report utilisation, with a readable name for each.
#
# Panama's vitals readout needs one specific sysfs file, and the card numbering
# is neither stable across machines nor meaningful to a person: this box has
# card1 and card2, both amdgpu, one discrete and one integrated. Picking a
# number blindly shows whichever the kernel happened to enumerate first.
#
# Names come from lspci where available, because the sysfs device directory
# exposes only numeric vendor/device ids.
set -euo pipefail
first=true
printf '['
for busy in /sys/class/drm/card*/device/gpu_busy_percent; do
[[ -r "$busy" ]] || continue
device_dir="$(dirname "$busy")"
card="$(basename "$(dirname "$device_dir")")"
# The device directory is a symlink into the PCI tree; its target's basename
# is the PCI address lspci wants.
pci="$(basename "$(readlink -f "$device_dir")" 2>/dev/null || true)"
name=""
if [[ -n "$pci" ]] && command -v lspci >/dev/null 2>&1; then
# Strip the leading domain: lspci -s wants 00:02.0, sysfs gives 0000:00:02.0
short="${pci#*:}"
name="$(lspci -s "$short" 2>/dev/null | sed -E 's/^[^ ]+ [^:]+: //' | head -1)"
fi
if [[ -z "$name" ]]; then
driver="$(sed -n 's/^DRIVER=//p' "$device_dir/uevent" 2>/dev/null | head -1)"
name="${driver:-Graphics} ($card)"
fi
reading="$(cat "$busy" 2>/dev/null || printf '')"
[[ "$reading" =~ ^[0-9]+$ ]] || reading=-1
[[ "$first" == true ]] || printf ','
first=false
printf '{"card":"%s","path":"%s","name":%s,"busy":%s}' \
"$card" "$busy" "$(printf '%s' "$name" | jq -Rs .)" "$reading"
done
printf ']\n'
@@ -192,8 +192,17 @@ def resolve_config(
env: Mapping[str, str] | None = None, env: Mapping[str, str] | None = None,
legacy: Callable[[], Config] = load_legacy_config, legacy: Callable[[], Config] = load_legacy_config,
) -> Config: ) -> Config:
private_env = read_panama_env() # An explicitly supplied environment is the WHOLE environment. Reading the
private_env.update(dict(os.environ if env is None else env)) # user's private env file underneath it makes callers -- tests especially --
# depend on whatever happens to be in that file: adding a real
# PANAMA_HOME_ASSISTANT_ENTITIES to it silently overrode a fixture that was
# asserting the legacy fallback. Production passes env=None and still gets
# the file.
if env is None:
private_env = read_panama_env()
private_env.update(dict(os.environ))
else:
private_env = dict(env)
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip() url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip() token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Read and atomically update Panama's private Home Assistant settings.
Secret values are accepted only as a single JSON object on stdin and are never
returned. The command line therefore remains safe to inspect with ps(1).
"""
from __future__ import annotations
import fcntl
import json
import os
import pathlib
import re
import shlex
import sys
import tempfile
import urllib.parse
from collections.abc import Mapping, Sequence
from typing import Any
KEY_URL = "PANAMA_HOME_ASSISTANT_URL"
KEY_TOKEN = "PANAMA_HOME_ASSISTANT_TOKEN"
KEY_ENTITIES = "PANAMA_HOME_ASSISTANT_ENTITIES"
TARGET_KEYS = (KEY_URL, KEY_TOKEN, KEY_ENTITIES)
ASSIGNMENT = re.compile(
r"^(?P<prefix>\s*(?:export\s+)?)(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=(?P<value>.*)$"
)
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
DEFAULT_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
class ConfigError(RuntimeError):
"""An error code safe to display without including submitted values."""
def env_path() -> pathlib.Path:
override = os.environ.get("PANAMA_HOME_ASSISTANT_ENV_FILE", "")
return pathlib.Path(override) if override else DEFAULT_ENV
def parse_assignment(raw: str) -> str | None:
try:
parsed = shlex.split(raw, comments=True, posix=True)
except ValueError:
return None
return parsed[0] if len(parsed) == 1 else None
def read_values(path: pathlib.Path) -> dict[str, str]:
try:
lines = path.read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
return {}
except OSError as error:
raise ConfigError("read-failed") from error
values: dict[str, str] = {}
for line in lines:
match = ASSIGNMENT.match(line)
if not match or match.group("key") not in TARGET_KEYS:
continue
value = parse_assignment(match.group("value"))
if value is not None:
values[match.group("key")] = value
return values
def normalize_url(value: Any) -> str:
if not isinstance(value, str):
raise ConfigError("invalid-url")
normalized = value.strip().rstrip("/")
if not normalized:
return ""
parsed = urllib.parse.urlsplit(normalized)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username
or parsed.password
):
raise ConfigError("invalid-url")
return normalized
def normalize_token(value: Any) -> str:
if not isinstance(value, str) or "\x00" in value or "\n" in value or "\r" in value:
raise ConfigError("invalid-token")
return value.strip()
def normalize_entities(value: Any) -> tuple[str, ...]:
if isinstance(value, str):
candidates: Sequence[Any] = re.split(r"[,\n]", value)
elif isinstance(value, list):
candidates = value
else:
raise ConfigError("invalid-entities")
entities: list[str] = []
for candidate in candidates:
if not isinstance(candidate, str):
raise ConfigError("invalid-entities")
entity_id = candidate.strip()
if not entity_id:
continue
if not ENTITY_ID.fullmatch(entity_id):
raise ConfigError("invalid-entities")
if entity_id not in entities:
entities.append(entity_id)
return tuple(entities)
def public_state(values: Mapping[str, str]) -> dict[str, object]:
try:
url = normalize_url(values.get(KEY_URL, ""))
entities = list(normalize_entities(values.get(KEY_ENTITIES, "")))
error = ""
except ConfigError as config_error:
url = ""
entities = []
error = str(config_error)
token_configured = bool(values.get(KEY_TOKEN, "").strip())
return {
"ok": error == "",
"configured": bool(url and token_configured),
"tokenConfigured": token_configured,
"url": url,
"entities": entities,
"error": error,
}
def render_updated(original: str, updates: Mapping[str, str]) -> str:
lines = original.splitlines(keepends=True)
rendered: list[str] = []
replaced: set[str] = set()
for line in lines:
content = line.rstrip("\r\n")
ending = line[len(content) :]
match = ASSIGNMENT.match(content)
key = match.group("key") if match else ""
if key not in updates:
rendered.append(line)
continue
if key in replaced:
continue
rendered.append(f"export {key}={shlex.quote(updates[key])}{ending or os.linesep}")
replaced.add(key)
if rendered and not rendered[-1].endswith(("\n", "\r")):
rendered[-1] += os.linesep
for key in TARGET_KEYS:
if key in updates and key not in replaced:
rendered.append(f"export {key}={shlex.quote(updates[key])}{os.linesep}")
return "".join(rendered)
def atomic_write(path: pathlib.Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=".env.", dir=path.parent)
temporary = pathlib.Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
os.chmod(path, 0o600)
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
try:
temporary.unlink()
except FileNotFoundError:
pass
def read_payload() -> dict[str, Any]:
line = sys.stdin.buffer.readline(1_048_577)
if not line or len(line) > 1_048_576:
raise ConfigError("invalid-payload")
try:
payload = json.loads(line)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ConfigError("invalid-payload") from error
if not isinstance(payload, dict):
raise ConfigError("invalid-payload")
return payload
def write_payload(path: pathlib.Path, payload: Mapping[str, Any]) -> dict[str, object]:
allowed = {"url", "token", "entities"}
if not set(payload).issubset(allowed) or not payload:
raise ConfigError("invalid-payload")
updates: dict[str, str] = {}
if "url" in payload:
updates[KEY_URL] = normalize_url(payload["url"])
if "token" in payload:
updates[KEY_TOKEN] = normalize_token(payload["token"])
if "entities" in payload:
updates[KEY_ENTITIES] = ",".join(normalize_entities(payload["entities"]))
lock_path = path.with_name("." + path.name + ".lock")
path.parent.mkdir(parents=True, exist_ok=True)
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
try:
os.fchmod(lock_fd, 0o600)
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
original = path.read_text(encoding="utf-8")
except FileNotFoundError:
original = ""
except OSError as error:
raise ConfigError("read-failed") from error
atomic_write(path, render_updated(original, updates))
finally:
os.close(lock_fd)
result = public_state(read_values(path))
result["ok"] = True
result["error"] = ""
return result
def compact_json(value: Mapping[str, object]) -> str:
return json.dumps(value, separators=(",", ":"))
def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else "status"
if len(sys.argv) > 2 or command not in {"status", "write"}:
raise ConfigError("usage")
path = env_path()
result = public_state(read_values(path)) if command == "status" else write_payload(path, read_payload())
print(compact_json(result))
if __name__ == "__main__":
try:
main()
except ConfigError as error:
print(compact_json({"ok": False, "error": str(error)}))
raise SystemExit(1) from error
except OSError:
print(compact_json({"ok": False, "error": "write-failed"}))
raise SystemExit(1)
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
set -u
show_progress() {
qs ipc call osd progress "$1" "$2" 100 "$3" >/dev/null 2>&1 || true
}
show_message() {
qs ipc call osd message "$1" "$2" >/dev/null 2>&1 || true
}
volume_state() {
local target="$1" output level percent muted=false
output="$(wpctl get-volume "$target" 2>/dev/null)" || return 1
if [[ $output =~ Volume:[[:space:]]*([0-9]+([.][0-9]+)?) ]]; then
level="${BASH_REMATCH[1]}"
else
return 1
fi
[[ $output == *"[MUTED]"* ]] && muted=true
percent="$(awk -v value="$level" 'BEGIN { printf "%d", value * 100 + 0.5 }')"
printf '%s %s\n' "$percent" "$muted"
}
show_volume() {
local target="$1" kind="$2" state percent muted label
state="$(volume_state "$target")" || return 0
read -r percent muted <<<"$state"
if [[ $muted == true ]]; then
show_progress "${kind}-muted" "$percent" "Muted"
else
label="${percent}%"
show_progress "$kind" "$percent" "$label"
fi
}
adjust_volume() {
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@"
case "$action" in
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
down) wpctl set-volume "$target" "${step}%-" || return ;;
toggle) wpctl set-mute "$target" toggle || return ;;
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
esac
show_volume "$target" volume
}
adjust_microphone() {
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SOURCE@"
case "$action" in
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
down) wpctl set-volume "$target" "${step}%-" || return ;;
toggle) wpctl set-mute "$target" toggle || return ;;
*) printf 'Usage: panama-osd microphone up|down|toggle [step]\n' >&2; return 2 ;;
esac
show_volume "$target" microphone
}
adjust_brightness() {
local action="${1:-}" step="${2:-5}" output percent
case "$action" in
up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;;
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
*) 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}%"
}
media_action() {
local action="${1:-}" kind label fallback
case "$action" in
play-pause)
playerctl play-pause || return
if [[ $(playerctl status 2>/dev/null) == "Playing" ]]; then
kind="media-play"
fallback="Playing"
else
kind="media-pause"
fallback="Paused"
fi
;;
next) playerctl next || return; kind="media-next"; fallback="Next track" ;;
previous) playerctl previous || return; kind="media-previous"; fallback="Previous track" ;;
stop) playerctl stop || return; kind="media-stop"; fallback="Stopped" ;;
*) printf 'Usage: panama-osd media play-pause|next|previous|stop\n' >&2; return 2 ;;
esac
label="$(playerctl metadata --format '{{ title }} — {{ artist }}' 2>/dev/null)"
[[ -n $label ]] || label="$fallback"
show_message "$kind" "$label"
}
case "${1:-}" in
volume) shift; adjust_volume "$@" ;;
microphone) shift; adjust_microphone "$@" ;;
brightness) shift; adjust_brightness "$@" ;;
media) shift; media_action "$@" ;;
*)
printf 'Usage: panama-osd volume|microphone|brightness|media ACTION [step]\n' >&2
exit 2
;;
esac
@@ -572,6 +572,28 @@ def command_restore(arguments: list[str]) -> None:
home_present = False home_present = False
home_data = None home_data = None
# Display geometry is never restored from a snapshot. Applying it requires
# the visible confirmation/recovery flow in Displays.qml; a settings-file
# restore followed by `hyprctl reload` must not bypass that safety boundary.
# Preserve the currently confirmed generation when it is readable, and
# otherwise remove the snapshot's geometry so startup uses shipped policy.
try:
current_desktop = read_json(SETTINGS, "The current settings file") \
if is_present(SETTINGS) else {}
except BackupError:
current_desktop = {}
if isinstance(current_desktop, dict) and "displays" in current_desktop:
# Even a Home-only snapshot must retain the confirmed monitor layout.
# In that case the restored desktop file contains only the protected
# geometry; every ordinary desktop preference remains absent/default.
desktop_present = True
desktop_data = dict(desktop_data) if desktop_data is not None else {}
desktop_data["displays"] = current_desktop["displays"]
elif desktop_present and desktop_data is not None:
desktop_data = dict(desktop_data)
desktop_data.pop("displays", None)
# Restoring remains undoable, but a corrupt current file must not prevent a # Restoring remains undoable, but a corrupt current file must not prevent a
# known-good snapshot from recovering the desktop. # known-good snapshot from recovering the desktop.
save_snapshot(require_any=False, validate=False) save_snapshot(require_any=False, validate=False)
@@ -0,0 +1,43 @@
pragma Singleton
// Shared PipeWire device discovery and default selection. Quick Settings and
// Panama Settings intentionally use this same boundary so they cannot disagree
// about what counts as an input or which node should become the default.
import Quickshell
import Quickshell.Services.Pipewire
import QtQuick
Singleton {
id: root
readonly property var outputs: Pipewire.nodes.values.filter(node =>
!node.isStream && node.isSink)
readonly property var inputs: Pipewire.nodes.values.filter(node =>
!node.isStream
&& (node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource)
function nodes(output: bool): var {
return output ? root.outputs : root.inputs;
}
function current(output: bool): var {
return output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource;
}
function select(output: bool, node: var): void {
if (!node)
return;
if (output)
Pipewire.preferredDefaultAudioSink = node;
else
Pipewire.preferredDefaultAudioSource = node;
}
function label(node: var): string {
if (!node)
return "Unknown device";
return node.description || node.nickname || node.name || "Unknown device";
}
}
@@ -0,0 +1,144 @@
pragma Singleton
// Network and Bluetooth state for the settings page.
//
// The hard parts -- scanning, joining, pairing -- already work in the quick
// settings panel through Quickshell.Networking and Quickshell.Bluetooth, which
// speak to NetworkManager and BlueZ over DBus. Nothing here shells out to nmcli
// or bluetoothctl, and nothing should: the founding requirement for this
// desktop was never having to drop to a terminal to join a network.
//
// This exists so the page does not have to reach into those modules for the
// same derived values the panel already computes, and so scanning is driven by
// whether the page is actually on screen. Scanning while nobody is looking is
// radio time and battery spent on a list that is not being read.
import Quickshell
import Quickshell.Networking
import Quickshell.Bluetooth
import QtQuick
Singleton {
id: root
// Set by the page while it is visible; drives both scanners.
property bool active: false
readonly property var wifiDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wifi)
return device;
}
return null;
}
readonly property var wiredDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wired)
return device;
}
return null;
}
readonly property var adapter: Bluetooth.defaultAdapter
readonly property bool wifiEnabled: Networking.wifiEnabled
readonly property bool wifiAvailable: Networking.wifiHardwareEnabled
// Current network, then saved, then by signal -- the order GNOME uses,
// which is the order you actually look for things in.
readonly property var networks: {
if (!root.wifiDevice || !root.wifiDevice.networks)
return [];
const list = root.wifiDevice.networks.values.slice();
list.sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
if (a.known !== b.known)
return a.known ? -1 : 1;
return b.signalStrength - a.signalStrength;
});
return list;
}
readonly property var savedNetworks: root.networks.filter(network => network.known)
readonly property var bluetoothDevices: {
if (!Bluetooth.devices)
return [];
const list = Bluetooth.devices.values.slice();
list.sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
if (a.paired !== b.paired)
return a.paired ? -1 : 1;
return String(a.name || "").localeCompare(String(b.name || ""));
});
return list;
}
readonly property var activeNetwork: root.networks.find(network => network.connected) ?? null
function isSecured(network: var): bool {
return network.security !== WifiSecurityType.Open
&& network.security !== WifiSecurityType.Owe
&& network.security !== WifiSecurityType.Unknown;
}
function securityLabel(network: var): string {
if (!root.isSecured(network))
return "Open";
switch (network.security) {
case WifiSecurityType.Wep: return "WEP";
case WifiSecurityType.Wpa: return "WPA";
case WifiSecurityType.Wpa2: return "WPA2";
case WifiSecurityType.Wpa3: return "WPA3";
case WifiSecurityType.Enterprise: return "Enterprise";
}
return "Secured";
}
// Four bars is what people read signal as, so bucket rather than showing a
// percentage that changes every scan and means nothing to anyone.
//
// signalStrength is 0.0-1.0, NOT a percentage. Treating it as 0-100 puts
// every network including the connected one in the bottom bucket, which is
// exactly as useless as showing nothing. Thresholds match the icon buckets
// in modules/quicksettings/WifiList.qml so the two never disagree.
function signalLabel(strength: real): string {
if (strength >= 0.8) return "Excellent";
if (strength >= 0.55) return "Good";
if (strength >= 0.3) return "Fair";
if (strength > 0.05) return "Weak";
return "No signal";
}
function connectionFailureText(reason: var): string {
switch (reason) {
case ConnectionFailReason.WifiAuthTimeout:
case ConnectionFailReason.Authentication:
return "Wrong password";
case ConnectionFailReason.WifiNetworkLost:
return "Network out of range";
}
return "Could not connect";
}
// Scanning follows visibility. NetworkManager keeps scanning as long as it
// is asked to, and Bluetooth discovery is worse -- it holds the radio.
function syncScanners(): void {
if (root.wifiDevice)
root.wifiDevice.scannerEnabled = root.active && root.wifiEnabled;
if (root.adapter && root.adapter.enabled) {
const shouldDiscover = root.active;
if (root.adapter.discovering !== shouldDiscover)
root.adapter.discovering = shouldDiscover;
}
}
onActiveChanged: root.syncScanners()
onWifiDeviceChanged: root.syncScanners()
onWifiEnabledChanged: root.syncScanners()
onAdapterChanged: root.syncScanners()
}
@@ -41,6 +41,7 @@ Singleton {
property bool revertVerificationActive: false property bool revertVerificationActive: false
property int operationGeneration: 0 property int operationGeneration: 0
property int revertGeneration: -1 property int revertGeneration: -1
property bool externalChangeBlocked: false
property int secondsLeft: 0 property int secondsLeft: 0
readonly property bool awaitingConfirmation: root.pendingOutput !== "" readonly property bool awaitingConfirmation: root.pendingOutput !== ""
@@ -271,6 +272,10 @@ Singleton {
// Applies immediately and starts the countdown. Nothing is stored yet: the // Applies immediately and starts the countdown. Nothing is stored yet: the
// settings file is only written by confirm(). // settings file is only written by confirm().
function apply(output: string, mode: string, scale: real, transform: int): bool { function apply(output: string, mode: string, scale: real, transform: int): bool {
if (root.externalChangeBlocked) {
root.lastError = "Wait for Settings to finish restoring before changing a display.";
return false;
}
if (root.busy) { if (root.busy) {
root.lastError = "Wait for the current display operation to finish."; root.lastError = "Wait for the current display operation to finish.";
return false; return false;
@@ -0,0 +1,129 @@
pragma Singleton
// Turning a place name into coordinates.
//
// The weather card needs latitude and longitude, but nobody knows their own
// coordinates, and a settings page that demands them is a settings page nobody
// changes. Open-Meteo publishes a geocoding endpoint that needs no API key and
// no account, which is the same reason the forecast itself uses them.
//
// Fetched with curl rather than XMLHttpRequest for the same reason as
// services/Weather.qml: curl is guaranteed present, and a search that fails
// must leave the page usable rather than producing an error popup.
//
// Only the query is sent. The stored location label never leaves the machine.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// [{ name, admin, country, latitude, longitude, label }]
property var results: []
property bool searching: false
property string lastError: ""
property string lastQuery: ""
readonly property string endpoint: "https://geocoding-api.open-meteo.com/v1/search"
Process {
id: fetch
stdout: StdioCollector {
onStreamFinished: root.parse(this.text)
}
onExited: (exitCode, exitStatus) => {
root.searching = false;
if (exitCode !== 0)
root.lastError = "Could not reach the location service.";
}
}
// Debounced: typing "Denver" should not fire six searches.
Timer {
id: debounce
interval: 350
onTriggered: root.run()
}
property string pending: ""
function search(query: string): void {
const trimmed = String(query).trim();
root.pending = trimmed;
if (trimmed.length < 2) {
root.results = [];
root.lastError = "";
debounce.stop();
return;
}
debounce.restart();
}
function run(): void {
if (fetch.running || root.pending.length < 2)
return;
root.searching = true;
root.lastError = "";
root.lastQuery = root.pending;
// --get with --data-urlencode makes curl do the escaping, so a place
// name with spaces or an ampersand cannot alter the request.
fetch.exec(["curl", "-s", "--max-time", "10", "--get",
"--data-urlencode", `name=${root.pending}`,
"--data-urlencode", "count=8",
"--data-urlencode", "format=json",
root.endpoint]);
}
function parse(text: string): void {
try {
const parsed = JSON.parse(text);
const out = [];
for (const item of (parsed.results ?? [])) {
if (typeof item.latitude !== "number" || typeof item.longitude !== "number")
continue;
const admin = item.admin1 ?? "";
const country = item.country ?? "";
out.push({
name: item.name ?? "",
admin: admin,
country: country,
latitude: item.latitude,
longitude: item.longitude,
// What the user will see stored as their location label.
label: [item.name, admin, country].filter(part => !!part).join(", ")
});
}
root.results = out;
root.lastError = out.length === 0 ? "No places match that name." : "";
} catch (error) {
root.results = [];
root.lastError = "The location service returned something unreadable.";
}
}
// Stores a chosen place. Coordinates are rounded to four decimals -- roughly
// ten metres, far finer than a weather reading resolves, and it keeps a
// precise home location out of the settings file.
function choose(place: var): bool {
const latitude = Math.round(place.latitude * 10000) / 10000;
const longitude = Math.round(place.longitude * 10000) / 10000;
const label = String(place.label).slice(0, 64);
const ok = DesktopPreferences.set("weatherLatitude", latitude)
&& DesktopPreferences.set("weatherLongitude", longitude)
&& DesktopPreferences.set("weatherLocation", label);
if (!ok) {
root.lastError = "That location could not be saved.";
return false;
}
root.results = [];
root.lastError = "";
return true;
}
}
@@ -0,0 +1,89 @@
pragma Singleton
// The GPUs that can report utilisation.
//
// The vitals readout needs one specific sysfs file, and card numbering is
// neither stable across machines nor meaningful to a person -- this machine has
// two amdgpu cards, one discrete and one integrated, and picking a number
// blindly measures whichever the kernel enumerated first.
//
// Enumerated on demand rather than polled: hardware does not appear while you
// are looking at a settings page.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-gpus"
// [{ card, path, name, busy }]
property var devices: []
property bool scanning: false
property string lastError: ""
readonly property string selectedPath: DesktopPreferences.get("gpuBusyPath")
readonly property var selected: root.devices.find(device => device.path === root.selectedPath) ?? null
// True when a GPU is stored that this machine does not have -- after moving
// the settings file between machines, say.
readonly property bool selectionMissing: root.devices.length > 0 && root.selected === null
Process {
id: scan
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.devices = Array.isArray(parsed) ? parsed : [];
root.lastError = "";
} catch (error) {
root.devices = [];
root.lastError = "The graphics devices could not be read.";
}
root.scanning = false;
}
}
onExited: (exitCode, exitStatus) => {
root.scanning = false;
if (exitCode !== 0)
root.lastError = "The graphics devices could not be read.";
}
}
Component.onCompleted: root.refresh()
function refresh(): void {
if (scan.running)
return;
root.scanning = true;
scan.running = true;
}
// Only a path this machine actually reported is accepted, so a hand-edited
// settings file cannot point the readout at an arbitrary file.
function select(path: string): bool {
if (!root.devices.some(device => device.path === path)) {
root.lastError = "That graphics device is not present.";
return false;
}
if (!DesktopPreferences.set("gpuBusyPath", path)) {
root.lastError = "That graphics device could not be saved.";
return false;
}
root.lastError = "";
return true;
}
// "AMD ... [Radeon RX 7700 XT / 7800 XT] (rev c8)" is what lspci gives; the
// bracketed marketing name is the part anyone recognises.
function shortName(name: string): string {
const bracketed = String(name).match(/\[([^\]]+)\]\s*(?:\(rev[^)]*\))?\s*$/);
return bracketed ? bracketed[1] : String(name).replace(/\s*\(rev[^)]*\)\s*$/, "");
}
}
@@ -0,0 +1,120 @@
pragma Singleton
// Redacted Home Assistant configuration state. The helper is the only object
// that touches the private env file; QML never receives the stored token.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant-config"
property string url: ""
property var entities: []
property bool tokenConfigured: false
property bool configured: false
property string lastError: ""
property string pendingPayload: ""
property var refreshHomeAssistant: function() { HomeAssistant.refresh(); }
readonly property bool busy: statusProc.running || writeProc.running
signal configurationSaved
function errorMessage(code: string): string {
switch (code) {
case "invalid-url": return "Enter an HTTP or HTTPS Home Assistant URL.";
case "invalid-token": return "The access token contains unsupported characters.";
case "invalid-entities": return "Entity IDs must look like light.living_room.";
case "read-failed": return "The private configuration file could not be read.";
case "write-failed": return "The private configuration file could not be saved.";
case "invalid-payload": return "The configuration could not be validated.";
default: return code ? "Home Assistant configuration is unavailable." : "";
}
}
function applyResult(text: string, saved: bool): void {
let result = null;
try {
result = JSON.parse(text);
} catch (error) {
result = { ok: false, error: "invalid-response" };
}
if (result.ok !== true) {
root.lastError = root.errorMessage(String(result.error || "invalid-response"));
return;
}
root.url = String(result.url || "");
root.entities = Array.isArray(result.entities) ? result.entities : [];
root.tokenConfigured = result.tokenConfigured === true;
root.configured = result.configured === true;
root.lastError = "";
if (saved) {
root.configurationSaved();
root.refreshHomeAssistant();
}
}
function refresh(): void {
if (!statusProc.running && !writeProc.running)
statusProc.running = true;
}
// An empty token means "keep the stored token". Clearing is an explicit
// separate action so editing the URL can never erase a secret by accident.
function save(url: string, entitiesText: string, token: string): bool {
if (root.busy)
return false;
const payload = { url: url, entities: entitiesText };
if (token.trim() !== "")
payload.token = token;
return root.startWrite(payload);
}
function clearToken(): bool {
if (root.busy || !root.tokenConfigured)
return false;
return root.startWrite({ token: "" });
}
function startWrite(payload: var): bool {
root.lastError = "";
root.pendingPayload = JSON.stringify(payload);
writeProc.running = true;
return true;
}
Process {
id: statusProc
command: [root.helperPath, "status"]
stdout: StdioCollector {
onStreamFinished: root.applyResult(this.text, false)
}
onExited: (code, status) => {
if (code !== 0 && root.lastError === "")
root.lastError = "Home Assistant configuration could not be loaded.";
}
}
Process {
id: writeProc
command: [root.helperPath, "write"]
stdinEnabled: true
stdout: StdioCollector {
onStreamFinished: root.applyResult(this.text, true)
}
onStarted: {
writeProc.write(root.pendingPayload + "\n");
root.pendingPayload = "";
}
onExited: (code, status) => {
root.pendingPayload = "";
if (code !== 0 && root.lastError === "")
root.lastError = "Home Assistant configuration could not be saved.";
}
}
Component.onCompleted: root.refresh()
}
@@ -89,6 +89,34 @@ Singleton {
onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled) onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled)
onAutomaticChanged: DesktopPreferences.set("nightLightAutomatic", root.automatic) onAutomaticChanged: DesktopPreferences.set("nightLightAutomatic", root.automatic)
// `enabled`, `temperature`, and `automatic` are declared as bindings on the
// store, but a binding is destroyed the moment anything assigns to the
// property -- which toggle() does. Without this, the service would write to
// the store and never read from it again: Quick Settings would keep working
// while the same settings on the Displays page silently did nothing, which
// is worse than not offering them there at all.
//
// No loop: set() is a no-op when the value is unchanged, and assigning a
// property its current value emits nothing, so this converges immediately.
Connections {
target: DesktopPreferences
function onRevisionChanged(): void { root.syncFromStore(); }
}
function syncFromStore(): void {
const storedEnabled = DesktopPreferences.get("nightLightEnabled") === true;
if (root.enabled !== storedEnabled)
root.enabled = storedEnabled;
const storedAutomatic = DesktopPreferences.get("nightLightAutomatic") === true;
if (root.automatic !== storedAutomatic)
root.automatic = storedAutomatic;
const storedTemperature = DesktopPreferences.get("nightLightTemperature");
if (typeof storedTemperature === "number" && root.temperature !== storedTemperature)
root.temperature = storedTemperature;
}
onActiveChanged: { onActiveChanged: {
if (!root.initialized) if (!root.initialized)
return; return;
+116 -4
View File
@@ -37,6 +37,39 @@ Singleton {
// Cleared when the notification centre is opened. The bar binds to this. // Cleared when the notification centre is opened. The bar binds to this.
property int unreadCount: 0 property int unreadCount: 0
// Kept separate from the persisted map so this version can safely run
// before the matching schema entry lands. A later accepted write folds the
// complete map into DesktopPreferences and clears this fallback.
property var fallbackAppRules: ({})
// Display metadata is intentionally session-only. The durable shape stays
// just the per-application rule map, while a fresh notification gives the
// settings page a human-readable name straight away.
property var rememberedApplications: ({})
readonly property var persistedAppRules: {
const stored = DesktopPreferences.get("notificationAppRules");
return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {};
}
// The schema change is the persistence boundary. This branch keeps a
// session fallback only so it remains usable while that companion change
// is being integrated; it intentionally makes no restart guarantee then.
readonly property bool appRulesSchemaAvailable: PreferenceSchema.has("notificationAppRules")
readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules)
readonly property var applications: {
// byId()/heuristicLookup() do not make a binding by themselves. This
// read updates persisted app labels once DesktopEntries finishes scan.
const entries = DesktopEntries.applications.values;
const remembered = root.rememberedApplications;
return Object.keys(root.appRules).map(appId => ({
id: appId,
name: root.applicationLabel(appId, entries, remembered)
})).sort((a, b) => a.name.localeCompare(b.name));
}
// Arrival times, keyed by notification id — the protocol carries no // Arrival times, keyed by notification id — the protocol carries no
// timestamp. Deliberately formatted once at arrival rather than shown as // timestamp. Deliberately formatted once at arrival rather than shown as
// "5 minutes ago", which would need a clock ticking behind every card. // "5 minutes ago", which would need a clock ticking behind every card.
@@ -49,6 +82,79 @@ Singleton {
readonly property bool hasNotifications: root.history.length > 0 readonly property bool hasNotifications: root.history.length > 0
function notificationAppId(notification: var): string {
const desktopEntry = String(notification.desktopEntry ?? "").trim();
return desktopEntry || String(notification.appName ?? "").trim() || "Notifications";
}
function applicationLabel(appId: string, entries: var, remembered: var): string {
const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId;
const entry = DesktopEntries.byId(appId)
|| DesktopEntries.byId(desktopId)
|| DesktopEntries.heuristicLookup(appId)
|| DesktopEntries.heuristicLookup(desktopId);
return entry?.name || remembered[appId]?.name || appId;
}
function normalizedAppRule(rule: var): var {
const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {};
return {
enabled: source.enabled !== false,
showOnLockScreen: source.showOnLockScreen !== false,
showContentOnLockScreen: source.showContentOnLockScreen !== false
};
}
function appRule(appId: string): var {
return root.normalizedAppRule(root.appRules[appId]);
}
function setAppRule(appId: string, patch: var): bool {
if (!appId)
return false;
const current = root.appRule(appId);
const next = {};
for (const knownAppId of Object.keys(root.appRules))
next[knownAppId] = root.appRule(knownAppId);
next[appId] = {
enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true,
showOnLockScreen: patch.showOnLockScreen === undefined ? current.showOnLockScreen : patch.showOnLockScreen === true,
showContentOnLockScreen: patch.showContentOnLockScreen === undefined ? current.showContentOnLockScreen : patch.showContentOnLockScreen === true
};
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
root.fallbackAppRules = {};
else
root.fallbackAppRules = next;
return true;
}
function rememberApplication(notification: var): string {
const appId = root.notificationAppId(notification);
const next = Object.assign({}, root.rememberedApplications);
next[appId] = {
name: String(notification.appName ?? "").trim() || appId
};
root.rememberedApplications = next;
if (root.appRules[appId] === undefined)
root.setAppRule(appId, {});
return appId;
}
// These policy getters deliberately accept Notification objects, so a lock
// screen can use the same source of truth without duplicating app matching.
function shouldShowOnLockScreen(notification: var): bool {
const rule = root.appRule(root.notificationAppId(notification));
return rule.enabled && rule.showOnLockScreen;
}
function shouldShowContentOnLockScreen(notification: var): bool {
const rule = root.appRule(root.notificationAppId(notification));
return rule.enabled && rule.showOnLockScreen && rule.showContentOnLockScreen;
}
// history grouped by app, in most-recent-app-first order — the shape // history grouped by app, in most-recent-app-first order — the shape
// NotificationCenter.qml renders directly. // NotificationCenter.qml renders directly.
readonly property var groups: { readonly property var groups: {
@@ -85,13 +191,20 @@ Singleton {
actionIconsSupported: true actionIconsSupported: true
inlineReplySupported: true inlineReplySupported: true
onNotification: notification => { onNotification: notification => root.handleNotification(notification)
}
function handleNotification(notification: var): void {
// Replayed from before a shell reload. Letting these through would // Replayed from before a shell reload. Letting these through would
// re-toast and re-list everything on every edit, so they are left // re-toast and re-list everything on every edit, so they are left
// untracked and allowed to die. // untracked and allowed to die.
if (notification.lastGeneration) if (notification.lastGeneration)
return; return;
const appId = root.rememberApplication(notification);
if (!root.appRule(appId).enabled)
return;
// Without this the object is destroyed the instant this returns. // Without this the object is destroyed the instant this returns.
notification.tracked = true; notification.tracked = true;
root.arrivals[notification.id] = new Date(); root.arrivals[notification.id] = new Date();
@@ -108,12 +221,11 @@ Singleton {
if (!root.doNotDisturb) if (!root.doNotDisturb)
root.popups = [notification].concat(root.popups); root.popups = [notification].concat(root.popups);
}
} }
// ── Mutation ──────────────────────────────────────────────────────────── // ── Mutation ────────────────────────────────────────────────────────────
function pushHistory(n: Notification): void { function pushHistory(n: var): void {
const next = [n].concat(root.history); const next = [n].concat(root.history);
// Anything past the cap is released, otherwise it stays tracked // Anything past the cap is released, otherwise it stays tracked
@@ -166,7 +278,7 @@ Singleton {
// Called from the `closed` signal — the object is on its way out, so this // Called from the `closed` signal — the object is on its way out, so this
// only ever removes references, never touches the notification. // only ever removes references, never touches the notification.
function forget(n: Notification): void { function forget(n: var): void {
delete root.arrivals[n.id]; delete root.arrivals[n.id];
if (root.history.indexOf(n) !== -1) if (root.history.indexOf(n) !== -1)
root.history = root.history.filter(x => x !== n); root.history = root.history.filter(x => x !== n);
@@ -38,6 +38,10 @@ Singleton {
property var initializeHome: function(ids) { HomePreferences.initialize(ids); } property var initializeHome: function(ids) { HomePreferences.initialize(ids); }
property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); } property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
property var reloadDesktop: function() { DesktopPreferences.reload(); } property var reloadDesktop: function() { DesktopPreferences.reload(); }
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); } property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
property var reloadKeybinds: function() { Keybinds.applyReload(); } property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; } property var keybindsReloading: function() { return Keybinds.reloading; }
@@ -47,6 +51,7 @@ Singleton {
} }
property var applyWallpaper: function(path) { Wallpaper.set(path); } property var applyWallpaper: function(path) { Wallpaper.set(path); }
property var reloadShell: function() { Quickshell.reload(false); } property var reloadShell: function() { Quickshell.reload(false); }
property var protectedDisplays: ({})
readonly property bool busy: listQuery.running || actionRun.running readonly property bool busy: listQuery.running || actionRun.running
|| applyRestoredState.running || settleReload.running || applyRestoredState.running || settleReload.running
@@ -81,6 +86,10 @@ Singleton {
root.lastError = actionRun.restoring root.lastError = actionRun.restoring
? "That snapshot could not be restored." ? "That snapshot could not be restored."
: "The settings could not be backed up."; : "The settings could not be backed up.";
if (actionRun.restoring) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
}
return; return;
} }
root.lastAction = actionRun.restoring ? "restored" : "saved"; root.lastAction = actionRun.restoring ? "restored" : "saved";
@@ -89,6 +98,10 @@ Singleton {
root.lastError = homeReloaded root.lastError = homeReloaded
? "" ? ""
: "Desktop settings were restored, but Home favourites could not be reloaded."; : "Desktop settings were restored, but Home favourites could not be reloaded.";
if (!homeReloaded) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
}
} else } else
root.lastError = ""; root.lastError = "";
root.refresh(); root.refresh();
@@ -124,6 +137,8 @@ Singleton {
// from leaving restored Home state stale indefinitely. // from leaving restored Home state stale indefinitely.
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) { if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
stop(); stop();
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.reloadShell(); root.reloadShell();
} }
} }
@@ -162,6 +177,8 @@ Singleton {
if (!root.reloadHomeState(text)) if (!root.reloadHomeState(text))
return false; return false;
root.reloadDesktop(); root.reloadDesktop();
if (!root.protectDisplays(root.protectedDisplays))
return false;
applyRestoredState.restart(); applyRestoredState.restart();
return true; return true;
} }
@@ -222,10 +239,18 @@ Singleton {
function restore(name: string): bool { function restore(name: string): bool {
if (actionRun.running) if (actionRun.running)
return false; return false;
if (root.displayBusy()) {
root.lastError = "Finish the current display change before restoring settings.";
return false;
}
if (!root.snapshots.some(snapshot => snapshot.name === name)) { if (!root.snapshots.some(snapshot => snapshot.name === name)) {
root.lastError = "That snapshot is not in the list."; root.lastError = "That snapshot is not in the list.";
return false; return false;
} }
const currentDisplays = root.readDisplays();
root.protectedDisplays = JSON.parse(JSON.stringify(
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
root.setDisplayBlocked(true);
actionRun.restoring = true; actionRun.restoring = true;
actionRun.exec([root.helperPath, "restore", name]); actionRun.exec([root.helperPath, "restore", name]);
return true; return true;
@@ -29,6 +29,7 @@ Singleton {
"dock": "desktop", "dock": "desktop",
"focus": "desktop", "focus": "desktop",
"display": "displays", "display": "displays",
"nightLight": "displays",
"idle": "power", "idle": "power",
"accessibility": "accessibility", "accessibility": "accessibility",
"input": "shortcuts", "input": "shortcuts",
@@ -0,0 +1,97 @@
pragma Singleton
// GNOME and GTK applications already honour these desktop sound preferences.
// Panama controls the same durable keys so moving between sessions does not
// create two competing notions of whether event feedback is enabled.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property bool eventSounds: true
property bool inputFeedback: false
property string lastError: ""
readonly property bool busy: eventRead.running || inputRead.running
|| eventWrite.running || inputWrite.running
function parsedBoolean(text: string, fallback: bool): bool {
const value = text.trim();
if (value === "true")
return true;
if (value === "false")
return false;
return fallback;
}
function refresh(): void {
if (!eventRead.running)
eventRead.running = true;
if (!inputRead.running)
inputRead.running = true;
}
function setEventSounds(enabled: bool): void {
root.eventSounds = enabled;
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(enabled)];
eventWrite.running = true;
}
function setInputFeedback(enabled: bool): void {
root.inputFeedback = enabled;
inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(enabled)];
inputWrite.running = true;
}
Process {
id: eventRead
command: ["gsettings", "get", "org.gnome.desktop.sound", "event-sounds"]
stdout: StdioCollector {
onStreamFinished: root.eventSounds = root.parsedBoolean(this.text, root.eventSounds)
}
onExited: (code, status) => {
if (code !== 0)
root.lastError = "Event sound preferences could not be read.";
}
}
Process {
id: inputRead
command: ["gsettings", "get", "org.gnome.desktop.sound", "input-feedback-sounds"]
stdout: StdioCollector {
onStreamFinished: root.inputFeedback = root.parsedBoolean(this.text, root.inputFeedback)
}
onExited: (code, status) => {
if (code !== 0)
root.lastError = "Input feedback preferences could not be read.";
}
}
Process {
id: eventWrite
onExited: (code, status) => {
if (code !== 0) {
root.lastError = "Event sound preferences could not be changed.";
root.refresh();
} else {
root.lastError = "";
}
}
}
Process {
id: inputWrite
onExited: (code, status) => {
if (code !== 0) {
root.lastError = "Input feedback preferences could not be changed.";
root.refresh();
} else {
root.lastError = "";
}
}
}
Component.onCompleted: root.refresh()
}
@@ -33,6 +33,16 @@ Singleton {
property string quickshellVersion: "0.3.0" property string quickshellVersion: "0.3.0"
property string lastError: "" property string lastError: ""
// Explicit seams keep reset sequencing testable without changing the live
// keymap, wallpaper, or display from an isolated contract harness.
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; }
property var applyWallpaper: function(path) { Wallpaper.set(path); }
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running || configWrite.running || configVerify.running || bluebubblesQuery.running
@@ -196,6 +206,11 @@ Singleton {
} }
// ── Applying options ──────────────────────────────────────────────────── // ── Applying options ────────────────────────────────────────────────────
// Test harnesses may replace the external compositor boundary while still
// exercising validation, commit routing, persistence, and reset replay.
// Production leaves this unset and always uses the verified Hyprland path.
property var compositorApplyOverride: null
// `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }. // `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }.
// The whole batch is validated before anything is sent, so one bad value // The whole batch is validated before anything is sent, so one bad value
// rejects the batch rather than half-applying it. // rejects the batch rather than half-applying it.
@@ -217,6 +232,9 @@ Singleton {
if (Object.keys(requested).length === 0) if (Object.keys(requested).length === 0)
return false; return false;
if (root.compositorApplyOverride !== null)
return root.compositorApplyOverride(requested);
// A write in flight is queued rather than refused. Options are applied // A write in flight is queued rather than refused. Options are applied
// and verified one batch at a time, but the callers are a settings UI // and verified one batch at a time, but the callers are a settings UI
// and a startup replay of every compositor-backed preference -- they // and a startup replay of every compositor-backed preference -- they
@@ -389,8 +407,22 @@ Singleton {
// //
// Compositor-backed values are re-applied afterwards, since resetting the // Compositor-backed values are re-applied afterwards, since resetting the
// stored value does not by itself tell Hyprland anything. // stored value does not by itself tell Hyprland anything.
function restoreDefaults(): void { function restoreDefaults(): bool {
if (root.displayBusy()) {
root.lastError = "Finish the current display change before restoring defaults.";
return false;
}
const currentDisplays = root.readDisplays();
const protectedDisplays = JSON.parse(JSON.stringify(
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
root.setDisplayBlocked(true);
DesktopPreferences.resetDesktopDefaults(); DesktopPreferences.resetDesktopDefaults();
if (!root.protectDisplays(protectedDisplays)) {
root.setDisplayBlocked(false);
root.lastError = "The current display setting could not be protected during reset.";
return false;
}
// Home accessories keep their own store (panama-home.json), so a reset // Home accessories keep their own store (panama-home.json), so a reset
// that only cleared the schema store would silently leave a customised // that only cleared the schema store would silently leave a customised
@@ -401,12 +433,33 @@ Singleton {
HomePreferences.resetHomeDefaults(); HomePreferences.resetHomeDefaults();
resettleTimer.restart(); resettleTimer.restart();
return true;
} }
Timer { Timer {
id: resettleTimer id: resettleTimer
interval: 60 interval: 60
onTriggered: root.applyPersistedDisplayPolicy() onTriggered: {
root.applyPersistedDisplayPolicy();
root.reloadKeybinds();
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
resetRelease.attempts = 0;
resetRelease.restart();
}
}
Timer {
id: resetRelease
property int attempts: 0
interval: 100
repeat: true
onTriggered: {
attempts++;
if ((!root.keybindsReloading() && !root.busy) || attempts >= 50) {
stop();
root.setDisplayBlocked(false);
}
}
} }
function setAutoHdr(enabled: bool): void { function setAutoHdr(enabled: bool): void {
+8 -9
View File
@@ -33,6 +33,7 @@ Singleton {
property bool scanning: false property bool scanning: false
readonly property string configured: DesktopPreferences.get("wallpaperPath") readonly property string configured: DesktopPreferences.get("wallpaperPath")
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
// Directories searched for wallpapers, in order. Screenshots are // Directories searched for wallpapers, in order. Screenshots are
// deliberately excluded: a folder of 300 screenshots is not a wallpaper // deliberately excluded: a folder of 300 screenshots is not a wallpaper
@@ -85,6 +86,7 @@ Singleton {
id: apply id: apply
property string requested: "" property string requested: ""
property string storedValue: ""
property var remaining: [] property var remaining: []
onExited: (exitCode, exitStatus) => { onExited: (exitCode, exitStatus) => {
@@ -100,7 +102,7 @@ Singleton {
return; return;
} }
root.lastError = ""; root.lastError = "";
DesktopPreferences.set("wallpaperPath", apply.requested); DesktopPreferences.set("wallpaperPath", apply.storedValue);
root.refreshActive(); root.refreshActive();
} }
} }
@@ -140,19 +142,16 @@ Singleton {
// Applies to every connected output. Returns false when the path is not one // Applies to every connected output. Returns false when the path is not one
// the schema will accept, so a caller can report the refusal. // the schema will accept, so a caller can report the refusal.
function set(path: string): bool { function set(path: string): bool {
if (PreferenceSchema.coerce("wallpaperPath", path) === undefined) { const effectivePath = path === "" ? root.shippedPath : path;
if (PreferenceSchema.coerce("wallpaperPath", effectivePath) === undefined) {
root.lastError = "That file path cannot be used as a wallpaper."; root.lastError = "That file path cannot be used as a wallpaper.";
return false; return false;
} }
if (apply.running) if (apply.running)
return false; return false;
apply.requested = path; apply.requested = effectivePath;
// "" clears the preference without touching what is on screen. apply.storedValue = path;
if (path === "") {
DesktopPreferences.set("wallpaperPath", "");
return true;
}
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name); const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
if (outputs.length === 0) { if (outputs.length === 0) {
@@ -161,7 +160,7 @@ Singleton {
} }
apply.remaining = outputs.slice(1); apply.remaining = outputs.slice(1);
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${path}`]); apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${effectivePath}`]);
return true; return true;
} }
@@ -13,6 +13,9 @@ ShellRoot {
property var calls: [] property var calls: []
property bool homeInitialized: false property bool homeInitialized: false
property var homeFavorites: [] property var homeFavorites: []
property bool displayOperationBusy: false
property bool displayBlocked: false
property var displayGeneration: ({ "DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0 } })
function record(name: string): void { function record(name: string): void {
const next = root.calls.slice(); const next = root.calls.slice();
@@ -43,6 +46,17 @@ ShellRoot {
favorite.id === id ? { id: id, alias: alias } : favorite); favorite.id === id ? { id: id, alias: alias } : favorite);
}; };
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); }; SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
SettingsBackup.readDisplays = function() { return root.displayGeneration; };
SettingsBackup.protectDisplays = function(value) {
root.record("display.protect:" + JSON.stringify(value));
root.displayGeneration = value;
return true;
};
SettingsBackup.displayBusy = function() { return root.displayOperationBusy; };
SettingsBackup.setDisplayBlocked = function(blocked) {
root.record("display.block:" + blocked);
root.displayBlocked = blocked;
};
SettingsBackup.applyCompositor = function() { root.record("system.apply"); }; SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); }; SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
SettingsBackup.keybindsReloading = function() { return false; }; SettingsBackup.keybindsReloading = function() { return false; };
@@ -59,17 +73,29 @@ ShellRoot {
root.calls = []; root.calls = [];
root.homeInitialized = false; root.homeInitialized = false;
root.homeFavorites = []; root.homeFavorites = [];
root.displayOperationBusy = false;
root.displayBlocked = false;
SettingsBackup.protectedDisplays = root.displayGeneration;
} }
function apply(output: string): bool { function apply(output: string): bool {
return SettingsBackup.handleRestoreOutput(output); return SettingsBackup.handleRestoreOutput(output);
} }
function restoreWhileDisplayBusy(): bool {
root.displayOperationBusy = true;
SettingsBackup.snapshots = [{ name: "settings-20260818-010203004.json" }];
return SettingsBackup.restore("settings-20260818-010203004.json");
}
function status(): string { function status(): string {
return JSON.stringify({ return JSON.stringify({
calls: root.calls, calls: root.calls,
initialized: root.homeInitialized, initialized: root.homeInitialized,
favorites: root.homeFavorites favorites: root.homeFavorites,
displayBlocked: root.displayBlocked,
displays: root.displayGeneration,
lastError: SettingsBackup.lastError
}); });
} }
} }
@@ -6,6 +6,48 @@ import qs.config
import qs.services import qs.services
ShellRoot { ShellRoot {
id: root
property var resetCalls: []
property var appliedBatches: []
property bool displayBlocked: false
function recordReset(name: string): void {
const next = root.resetCalls.slice();
next.push(name);
root.resetCalls = next;
}
Component.onCompleted: {
// Keep compositor verification entirely inside the isolated harness.
// Production applyOptions is covered separately by the Hyprland write
// contract; this seam proves commit/reset routing without changing the
// desktop that is running the test.
if (Quickshell.env("PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR") === "1") {
SystemSettings.compositorApplyOverride = function(requested) {
const batches = root.appliedBatches.slice();
batches.push(requested);
root.appliedBatches = batches;
for (const key in requested)
DesktopPreferences.set(key, requested[key]);
return true;
};
}
SystemSettings.displayBusy = function() { return false; };
SystemSettings.readDisplays = function() { return DesktopPreferences.get("displays"); };
SystemSettings.protectDisplays = function(value) {
root.recordReset("display.protect");
return DesktopPreferences.set("displays", value);
};
SystemSettings.setDisplayBlocked = function(blocked) {
root.recordReset("display.block:" + blocked);
root.displayBlocked = blocked;
};
SystemSettings.reloadKeybinds = function() { root.recordReset("keybinds.reload"); };
SystemSettings.keybindsReloading = function() { return false; };
SystemSettings.applyWallpaper = function(path) { root.recordReset("wallpaper.set:" + path); };
}
IpcHandler { IpcHandler {
target: "settings-system-test" target: "settings-system-test"
@@ -52,7 +94,22 @@ ShellRoot {
}); });
} }
function restoreDefaults(): void { SystemSettings.restoreDefaults(); } function restoreDefaults(): bool {
root.resetCalls = [];
return SystemSettings.restoreDefaults();
}
function resetState(): string {
return JSON.stringify({
calls: root.resetCalls,
displayBlocked: root.displayBlocked,
appliedBatches: root.appliedBatches
});
}
function applyState(): string {
return JSON.stringify(root.appliedBatches);
}
function panelAllowed(panel: string): bool { function panelAllowed(panel: string): bool {
return SystemSettings.isGnomePanelAllowed(panel); return SystemSettings.isGnomePanelAllowed(panel);
@@ -0,0 +1,34 @@
// Read-only contract harness for the Sound page. It instantiates every device
// row against the real PipeWire graph but exposes no mutating IPC methods.
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import QtQuick
import qs.services
import qs.modules.settings
ShellRoot {
SoundPage {
width: 760
height: 900
}
PwObjectTracker {
objects: AudioDevices.outputs.concat(AudioDevices.inputs)
}
IpcHandler {
target: "sound-page-test"
function status(): string {
return JSON.stringify({
ready: Pipewire.ready,
outputs: AudioDevices.outputs.length,
inputs: AudioDevices.inputs.length,
defaultOutput: AudioDevices.label(AudioDevices.current(true)),
defaultInput: AudioDevices.label(AudioDevices.current(false))
});
}
}
}
@@ -0,0 +1,50 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "weather-gpu-test"
function gpuStatus(): string {
return JSON.stringify({
count: GraphicsDevices.devices.length,
names: GraphicsDevices.devices.map(d => GraphicsDevices.shortName(d.name)),
paths: GraphicsDevices.devices.map(d => d.path),
selected: GraphicsDevices.selectedPath,
resolved: GraphicsDevices.selected !== null,
missing: GraphicsDevices.selectionMissing,
error: GraphicsDevices.lastError
});
}
function selectGpu(path: string): bool { return GraphicsDevices.select(path); }
function geoSearch(query: string): void { Geocoding.search(query); }
function geoStatus(): string {
return JSON.stringify({
searching: Geocoding.searching,
count: Geocoding.results.length,
top: Geocoding.results.length > 0 ? Geocoding.results[0].label : "",
error: Geocoding.lastError
});
}
function geoChooseTop(): bool {
if (Geocoding.results.length === 0) return false;
return Geocoding.choose(Geocoding.results[0]);
}
function storedLocation(): string {
return JSON.stringify({
label: DesktopPreferences.get("weatherLocation"),
lat: DesktopPreferences.get("weatherLatitude"),
lon: DesktopPreferences.get("weatherLongitude")
});
}
}
}
@@ -0,0 +1,56 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
id: root
property int refreshCalls: 0
FileView {
id: tokenFile
path: Quickshell.env("PANAMA_TEST_TOKEN_FILE")
blockLoading: true
printErrors: false
}
Component.onCompleted: {
HomeAssistant.fixtureMode = true;
HomeAssistantConfig.refreshHomeAssistant = function() { root.refreshCalls++; };
}
IpcHandler {
target: "home-assistant-config-test"
function save(url: string, entities: string): bool {
return HomeAssistantConfig.save(url, entities, "");
}
function saveWithToken(url: string, entities: string): bool {
return HomeAssistantConfig.save(url, entities, tokenFile.text());
}
function clearToken(): bool {
return HomeAssistantConfig.clearToken();
}
function refresh(): void {
HomeAssistantConfig.refresh();
}
function status(): string {
return JSON.stringify({
url: HomeAssistantConfig.url,
entities: HomeAssistantConfig.entities,
tokenConfigured: HomeAssistantConfig.tokenConfigured,
configured: HomeAssistantConfig.configured,
busy: HomeAssistantConfig.busy,
lastError: HomeAssistantConfig.lastError,
pendingPayloadEmpty: HomeAssistantConfig.pendingPayload === "",
refreshCalls: root.refreshCalls
});
}
}
}
@@ -0,0 +1,126 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
id: root
function notification(idValue: int, desktopEntryValue: string, appNameValue: string): var {
const closeHandlers = [];
return {
id: idValue,
desktopEntry: desktopEntryValue,
appName: appNameValue,
appIcon: "",
transient: false,
lastGeneration: false,
tracked: false,
dismissed: false,
closed: {
connect: callback => closeHandlers.push(callback)
},
dismiss: function() {
this.dismissed = true;
for (const callback of closeHandlers)
callback();
}
};
}
function resetNotifications(): void {
Notifs.history = [];
Notifs.popups = [];
Notifs.unreadCount = 0;
Notifs.doNotDisturb = false;
}
function reset(): void {
root.resetNotifications();
Notifs.fallbackAppRules = {};
Notifs.rememberedApplications = {};
DesktopPreferences.set("notificationAppRules", {});
}
IpcHandler {
target: "notification-app-rules-test"
function exercise(): string {
root.reset();
const signal = root.notification(1, "org.signal.Signal.desktop", "Signal");
Notifs.handleNotification(signal);
const appId = Notifs.notificationAppId(signal);
const initialRules = DesktopPreferences.get("notificationAppRules");
const fallback = root.notification(6, "", "Fallback Terminal");
Notifs.handleNotification(fallback);
const fallbackId = Notifs.notificationAppId(fallback);
const fallbackApplication = Notifs.applications.find(app => app.id === fallbackId);
root.resetNotifications();
Notifs.setAppRule(appId, { enabled: false });
const muted = root.notification(2, "org.signal.Signal.desktop", "Signal");
Notifs.handleNotification(muted);
const mutedResult = {
tracked: muted.tracked,
history: Notifs.history.length,
popups: Notifs.popups.length,
unread: Notifs.unreadCount
};
root.reset();
Notifs.doNotDisturb = true;
const dnd = root.notification(3, "org.signal.Signal.desktop", "Signal");
Notifs.handleNotification(dnd);
Notifs.setAppRule("org.privacy.App.desktop", {
showOnLockScreen: false,
showContentOnLockScreen: false
});
const privateNotification = root.notification(4, "org.privacy.App.desktop", "Private");
return JSON.stringify({
appId: appId,
initialRules: initialRules,
fallback: {
id: fallbackId,
application: fallbackApplication
},
muted: mutedResult,
dnd: {
tracked: dnd.tracked,
history: Notifs.history.length,
popups: Notifs.popups.length,
unread: Notifs.unreadCount
},
privacy: {
visible: Notifs.shouldShowOnLockScreen(privateNotification),
content: Notifs.shouldShowContentOnLockScreen(privateNotification)
}
});
}
function persist(): string {
root.reset();
const notification = root.notification(5, "org.persist.App.desktop", "Persist");
Notifs.handleNotification(notification);
Notifs.setAppRule("org.persist.App.desktop", {
enabled: false,
showOnLockScreen: true,
showContentOnLockScreen: false
});
return JSON.stringify(DesktopPreferences.get("notificationAppRules"));
}
function restored(): string {
return JSON.stringify(DesktopPreferences.get("notificationAppRules"));
}
function applications(): string {
return JSON.stringify(Notifs.applications);
}
}
}
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Network & Devices reads real NetworkManager and BlueZ state.
#
# Both of the bugs this contract exists to prevent were silent. Neither logged
# anything; both produced a page that looked fine and told the user something
# false:
#
# * the device lookups used enum names that do not exist
# (NetworkDeviceType.Wifi rather than DeviceType.Wifi), so they returned
# null and the page reported "No Wi-Fi adapter" on a machine whose Wi-Fi was
# connected;
# * signalStrength is 0.0-1.0, not a percentage, so thresholds written for
# 0-100 put every network including the connected one in the bottom bucket.
#
# So this compares what the service resolves against what NetworkManager itself
# reports, rather than merely checking the service does not crash.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/connectivity-harness.qml"
fail() {
printf 'connectivity contract: %s\n' "$1" >&2
exit 1
}
command -v nmcli >/dev/null || fail 'nmcli is needed to check the service against reality'
run() { qs -p "$harness" "$@"; }
harness_pid=""
cleanup() {
run ipc call connectivity-test setActive false >/dev/null 2>&1 || true
# By PID: never `pkill -f connectivity-harness`, which also matches the
# shell running this script.
[[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
}
trap cleanup EXIT
qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
run ipc show 2>/dev/null | rg -q '^target connectivity-test$' && break
sleep 0.1
done
run ipc show 2>/dev/null | rg -q '^target connectivity-test$' || fail 'test IPC target did not start'
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
# Scanning only runs while the page says it is visible.
run ipc call connectivity-test setActive true >/dev/null
sleep 3
state="$(run ipc call connectivity-test status)"
# ── Devices the service finds must match the ones NetworkManager reports ─────
nm_wifi="$(nmcli -t -f DEVICE,TYPE device | awk -F: '$2 == "wifi" { print $1; exit }')"
nm_wired="$(nmcli -t -f DEVICE,TYPE,STATE device | awk -F: '$2 == "ethernet" && $3 == "connected" { print $1; exit }')"
if [[ -n "$nm_wifi" ]]; then
[[ "$(jq -r .wifiDevice <<<"$state")" == "$nm_wifi" ]] \
|| fail "NetworkManager reports Wi-Fi device '$nm_wifi' but the service found '$(jq -r .wifiDevice <<<"$state")'"
fi
if [[ -n "$nm_wired" ]]; then
[[ "$(jq -r .wiredConnected <<<"$state")" == "true" ]] \
|| fail "NetworkManager reports '$nm_wired' connected but the service says it is not"
fi
# ── Signal strength is a ratio, and the labels must reflect that ─────────────
while IFS='|' read -r value expect; do
got="$(run ipc call connectivity-test labelFor "$value")"
[[ "$got" == "$expect" ]] || fail "signal $value labelled '$got', expected '$expect'"
done <<'CASES'
1.0|Excellent
0.85|Excellent
0.6|Good
0.4|Fair
0.1|Weak
0.0|No signal
CASES
# If a network is connected, it must not be described as the weakest possible
# thing -- that was the visible symptom of reading the ratio as a percentage.
active_ssid="$(jq -r .activeSsid <<<"$state")"
if [[ -n "$active_ssid" ]]; then
strength="$(jq -r .activeStrength <<<"$state")"
awk -v s="$strength" 'BEGIN { exit !(s >= 0 && s <= 1) }' \
|| fail "signalStrength $strength is outside 0.0-1.0; the label buckets assume a ratio"
fi
# ── Bluetooth ────────────────────────────────────────────────────────────────
if [[ "$(bluetoothctl list 2>/dev/null | wc -l)" -gt 0 ]]; then
[[ "$(jq -r .adapter <<<"$state")" == "true" ]] \
|| fail 'an adapter is present but the service did not find it'
fi
trap - EXIT
cleanup
printf 'connectivity contract: PASS\n'
@@ -0,0 +1,5 @@
[Desktop Entry]
Type=Application
Name=Persisted Fixture App
Exec=/usr/bin/true
Icon=applications-system
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config"
service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml"
password_field="$repo_dir/config/dot/quickshell/modules/settings/PasswordField.qml"
harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml"
work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)"
env_file="$work/env"
fail() {
printf 'Home Assistant config contract: %s\n' "$1" >&2
exit 1
}
cleanup() {
if declare -F qs_for_test >/dev/null; then
qs_for_test kill >/dev/null 2>&1 || true
fi
rm -rf "$work"
}
trap cleanup EXIT
[[ -x "$helper" ]] || fail 'credential helper is missing or not executable'
[[ -f "$service" ]] || fail 'credential service is missing'
[[ -f "$harness_fixture" ]] || fail 'credential runtime harness is missing'
rg -Fq 'stdinEnabled: true' "$service" || fail 'credential writes do not use process stdin'
rg -Fq 'writeProc.write(root.pendingPayload + "\n")' "$service" || fail 'credential payload is not written over stdin'
rg -Fq 'root.pendingPayload = ""' "$service" || fail 'credential payload remains in service memory after write'
if rg -q 'command:.*(token|pendingPayload)' "$service"; then
fail 'credential data can reach a process command line'
fi
rg -Fq 'PasswordField {' "$page" || fail 'Home Assistant token is not entered through the masked field'
rg -Fq 'activeFocusOnTab: true' "$password_field" || fail 'masked credential field is not keyboard reachable'
rg -Fq 'HomeAssistantConfig.save(' "$page" || fail 'Home Assistant configuration cannot be saved from Settings'
rg -Fq 'HomeAssistantConfig.clearToken()' "$page" || fail 'stored Home Assistant token cannot be cleared'
rg -Fq 'id: clearTokenButton' "$page" || fail 'clear-token action has no keyboard control identity'
rg -Fq 'id: saveHomeConfigButton' "$page" || fail 'save action has no keyboard control identity'
rg -Fq 'activeFocusOnTab: enabled' "$page" || fail 'credential actions are not in tab order'
rg -Fq 'Keys.onReturnPressed:' "$page" || fail 'credential actions have no keyboard activation'
cat >"$env_file" <<'EOF'
# Existing private shell settings must survive byte-for-byte.
export KEEP_ME='untouched value'
export JIRA_CREDENTIALS='unrelated-secret'
export PANAMA_HOME_ASSISTANT_URL='https://old.example.test'
export PANAMA_HOME_ASSISTANT_TOKEN='old-token'
export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'
EOF
chmod 0644 "$env_file"
run_helper() {
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" "$@"
}
status="$(run_helper status)" || fail 'status failed for a valid private env file'
jq -e '.configured == true and .tokenConfigured == true
and .url == "https://old.example.test"
and .entities == ["light.old"] and (has("token") | not)' \
<<<"$status" >/dev/null || fail "status exposed or misread credentials: $status"
if rg -q 'old-token|unrelated-secret' <<<"$status"; then
fail 'status output leaked a secret'
fi
secret='ha-secret-must-never-appear-in-ps-or-output'
payload="$work/payload.json"
jq -cn --arg token "$secret" '{
url: "https://home.example.test/",
token: $token,
entities: ["light.kitchen", "light.desk", "light.kitchen"]
}' >"$payload"
# Keep stdin open long enough to prove the token is absent from the helper's
# process arguments. The secret lives only in the private payload file/stdin.
fifo="$work/input.fifo"
mkfifo "$fifo"
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" write <"$fifo" >"$work/write.out" 2>"$work/write.err" &
helper_pid=$!
for _ in $(seq 1 30); do
kill -0 "$helper_pid" 2>/dev/null && break
sleep 0.05
done
if ps -o args= -p "$helper_pid" | rg -Fq "$secret"; then
fail 'token appeared in the credential helper process arguments'
fi
cp "$payload" "$fifo"
wait "$helper_pid" || fail 'stdin credential write failed'
write_result="$(cat "$work/write.out")"
jq -e '.ok == true and .configured == true and .tokenConfigured == true
and .url == "https://home.example.test"
and .entities == ["light.kitchen", "light.desk"] and (has("token") | not)' \
<<<"$write_result" >/dev/null || fail "write returned unsafe or incorrect state: $write_result"
if rg -q "$secret|old-token|unrelated-secret" "$work/write.out" "$work/write.err"; then
fail 'credential helper output leaked a secret'
fi
[[ "$(stat -c '%a' "$env_file")" == "600" ]] || fail 'private env file is not mode 0600'
rg -Fxq "export KEEP_ME='untouched value'" "$env_file" || fail 'unrelated env content changed'
rg -Fxq "export JIRA_CREDENTIALS='unrelated-secret'" "$env_file" || fail 'unrelated secret changed'
rg -Fq "$secret" "$env_file" || fail 'new token was not stored'
# Omitting token preserves it; an explicit empty token clears it.
printf '%s\n' '{"url":"https://new.example.test","entities":"light.office, light.hall"}' \
| run_helper write >/dev/null || fail 'non-secret update failed'
rg -Fq "$secret" "$env_file" || fail 'blank token field unexpectedly erased the stored token'
printf '%s\n' '{"token":""}' | run_helper write >/dev/null || fail 'token clear failed'
cleared="$(run_helper status)"
jq -e '.configured == false and .tokenConfigured == false
and .url == "https://new.example.test"
and .entities == ["light.office", "light.hall"]' \
<<<"$cleared" >/dev/null || fail "cleared state is wrong: $cleared"
before_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
printf '%s\n' '{"url":"file:///etc/passwd"}' | run_helper write >/dev/null 2>&1 \
&& fail 'invalid URL was accepted'
after_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
[[ "$before_hash" == "$after_hash" ]] || fail 'rejected input still modified the private env file'
# Exercise the actual QML Process.write() boundary with a pre-existing token.
# The IPC carries only non-secret fields; the helper must preserve the token.
config_path="$work/quickshell"
harness="$config_path/home-assistant-config-harness.qml"
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
cp "$harness_fixture" "$harness"
printf '%s\n' \
"export PANAMA_HOME_ASSISTANT_URL='https://qml-old.example.test'" \
"export PANAMA_HOME_ASSISTANT_TOKEN=''" \
"export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'" >"$env_file"
chmod 0600 "$env_file"
qml_token_file="$work/qml-token"
printf '%s' 'qml-private-token' >"$qml_token_file"
chmod 0600 "$qml_token_file"
qs_for_test() {
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" \
PANAMA_TEST_TOKEN_FILE="$qml_token_file" \
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \
qs -p "$harness" "$@"
}
stop_harness() {
qs_for_test kill >/dev/null 2>&1 || true
}
qs_for_test --daemonize >/dev/null
for _ in $(seq 1 60); do
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' && break
sleep 0.1
done
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' \
|| fail 'credential QML harness did not start'
for _ in $(seq 1 60); do
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
jq -e '.busy == false and .url == "https://qml-old.example.test"' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == false and .tokenConfigured == false and .pendingPayloadEmpty == true' \
<<<"$qml_status" >/dev/null || fail "QML service did not load redacted state: $qml_status"
qs_for_test ipc call home-assistant-config-test saveWithToken \
https://qml-new.example.test 'light.office,light.hall' >/dev/null \
|| fail 'QML service refused a private token-file update'
for _ in $(seq 1 60); do
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
jq -e '.busy == false and .configured == true and .tokenConfigured == true
and .refreshCalls > 0' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == true and .tokenConfigured == true
and .url == "https://qml-new.example.test"
and .entities == ["light.office", "light.hall"]
and .pendingPayloadEmpty == true and .lastError == "" and .refreshCalls > 0' \
<<<"$qml_status" >/dev/null || fail "QML secret stdin save did not settle safely: $qml_status"
rg -Fq 'qml-private-token' "$env_file" || fail 'QML secret stdin save did not store the token'
if ps -o args= -p "$(qs_for_test list | awk '/Process ID:/ {print $3; exit}')" | rg -Fq 'qml-private-token'; then
fail 'QML token appeared in the shell process arguments'
fi
qs_for_test ipc call home-assistant-config-test save \
https://qml-final.example.test 'light.bedroom,light.hall' >/dev/null \
|| fail 'QML service refused a non-secret update'
for _ in $(seq 1 60); do
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
jq -e '.busy == false and .url == "https://qml-final.example.test"
and .entities == ["light.bedroom", "light.hall"]' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == true and .tokenConfigured == true
and .pendingPayloadEmpty == true and .lastError == ""' \
<<<"$qml_status" >/dev/null || fail "QML stdin save did not settle safely: $qml_status"
rg -Fq 'qml-private-token' "$env_file" || fail 'QML non-secret save erased the stored token'
qs_for_test ipc call home-assistant-config-test clearToken >/dev/null \
|| fail 'QML service refused token clear'
for _ in $(seq 1 60); do
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
jq -e '.busy == false and .tokenConfigured == false' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == false and .tokenConfigured == false
and .pendingPayloadEmpty == true and .lastError == ""' \
<<<"$qml_status" >/dev/null || fail "QML token clear did not settle safely: $qml_status"
if rg -Fq 'qml-private-token' "$env_file"; then
fail 'QML token clear left the old token in the private env file'
fi
stop_harness
trap - EXIT
cleanup
printf 'Home Assistant config contract: PASS\n'
@@ -13,6 +13,20 @@ helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
[[ -x "$helper" ]] || fail 'helper is missing or not executable' [[ -x "$helper" ]] || fail 'helper is missing or not executable'
catalog="$($helper catalog)" catalog="$($helper catalog)"
# This asserts a LIVE, authenticated Home Assistant. An absent credential is not
# a defect in Panama, so it skips rather than fails -- otherwise the suite is red
# on any machine that has not been given a token, and a red suite that is
# expected to be red stops being read.
#
# A configured-but-broken bridge still fails, which is the case worth catching.
if [[ "$(jq -r '.configured' <<<"$catalog")" != "true" ]]; then
printf 'Home Assistant helper contract: SKIP (no token configured)\n'
printf ' Set PANAMA_HOME_ASSISTANT_TOKEN in config/bash/env to exercise this.\n'
printf ' Reason reported by the helper: %s\n' "$(jq -r '.error // "unknown"' <<<"$catalog")"
exit 0
fi
jq -e ' jq -e '
.ok == true and .configured == true and .error == "" and .ok == true and .configured == true and .error == "" and
(.entities | type == "array" and length > 0) and (.entities | type == "array" and length > 0) and
@@ -105,13 +105,21 @@ assert_contains 'Opens BlueBubbles' "$home_page"
if rg -Fq 'index: model.index' "$home_page"; then if rg -Fq 'index: model.index' "$home_page"; then
fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index' fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index'
fi fi
if rg -qi 'token|bearer|api/states' "$home_page"; then if rg -qi 'bearer|api/states' "$home_page"; then
fail 'HomePhonePage.qml crosses the credential or REST privacy boundary' fail 'HomePhonePage.qml crosses the Home Assistant REST boundary'
fi fi
assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card" assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card"
assert_contains 'signal removeRequested(string id)' "$favorite_card" assert_contains 'signal removeRequested(string id)' "$favorite_card"
assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card" assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card"
assert_contains 'DragHandler {' "$favorite_card" assert_contains 'text: "↑"' "$favorite_card"
assert_contains 'text: "↓"' "$favorite_card"
assert_contains 'enabled: root.canMoveEarlier' "$favorite_card"
assert_contains 'enabled: root.canMoveLater' "$favorite_card"
if rg -Fq 'DragHandler {' "$favorite_card"; then
fail 'Home light cards still expose the broken drag affordance'
fi
assert_contains 'canMoveEarlier: index > 0' "$home_page"
assert_contains 'canMoveLater: index < favoritesGrid.count - 1' "$home_page"
assert_contains 'onEditingFinished:' "$favorite_card" assert_contains 'onEditingFinished:' "$favorite_card"
assert_contains 'text: "Control Center"' "$favorite_card" assert_contains 'text: "Control Center"' "$favorite_card"
assert_contains 'activeFocusOnTab: true' "$favorite_card" assert_contains 'activeFocusOnTab: true' "$favorite_card"
@@ -96,6 +96,8 @@ assert_reset_persists_without_debounce() {
initial_ids=' ["light.kitchen","light.hall","light.desk"]' initial_ids=' ["light.kitchen","light.hall","light.desk"]'
expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}],"saveError":""}' expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}],"saveError":""}'
expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}' expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}'
reordered_expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"},{"id":"light.hall","alias":""}],"saveError":""}'
reordered_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"},{"id":"light.hall","alias":""}]}'
empty_expected='{"initialized":true,"favorites":[],"saveError":""}' empty_expected='{"initialized":true,"favorites":[],"saveError":""}'
empty_file='{"initialized":true,"favorites":[]}' empty_file='{"initialized":true,"favorites":[]}'
reset_expected='{"initialized":false,"favorites":[],"saveError":""}' reset_expected='{"initialized":false,"favorites":[],"saveError":""}'
@@ -106,6 +108,12 @@ start_harness
qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null
qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null
qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null
wait_for_status "$reordered_expected"
wait_for_file_content "$reordered_file"
stop_harness
start_harness
wait_for_status "$reordered_expected"
qs_for_harness ipc call home-pref-test remove light.hall >/dev/null qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
wait_for_status "$expected" wait_for_status "$expected"
wait_for_file_content "$expected_file" wait_for_file_content "$expected_file"
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/Notifs.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
harness_fixture="$repo_dir/tests/quickshell/NotificationAppRulesHarness.qml"
desktop_entry_fixture="$repo_dir/tests/quickshell/fixtures/org.persist.App.desktop"
fail() {
printf 'notification application rules contract: %s\n' "$1" >&2
exit 1
}
[[ -f "$service" ]] || fail 'notification service is missing'
[[ -f "$page" ]] || fail 'notification settings page is missing'
[[ -f "$harness_fixture" ]] || fail 'runtime harness fixture is missing'
[[ -f "$desktop_entry_fixture" ]] || fail 'runtime desktop entry fixture is missing'
SERVICE_PATH="$service" PAGE_PATH="$page" bun -e '
const source = await Bun.file(process.env.SERVICE_PATH).text();
const page = await Bun.file(process.env.PAGE_PATH).text();
function fail(message) {
console.error(`notification application rules contract: ${message}`);
process.exit(1);
}
function functionBody(name) {
const start = source.indexOf(`function ${name}(`);
if (start === -1)
fail(`missing ${name}()`);
const open = source.indexOf("{", start);
let depth = 0;
for (let index = open; index < source.length; index++) {
if (source[index] === "{") depth++;
if (source[index] === "}" && --depth === 0)
return source.slice(open + 1, index);
}
fail(`${name}() is unterminated`);
}
const notificationAppId = Function("notification", functionBody("notificationAppId"));
const normalizedAppRule = Function("rule", functionBody("normalizedAppRule"));
const identityFixtures = [
{ notification: { desktopEntry: "org.signal.Signal.desktop", appName: "Signal" }, expected: "org.signal.Signal.desktop" },
{ notification: { desktopEntry: "", appName: "Terminal" }, expected: "Terminal" },
{ notification: { desktopEntry: "", appName: "" }, expected: "Notifications" }
];
for (const fixture of identityFixtures) {
const actual = notificationAppId(fixture.notification);
if (actual !== fixture.expected)
fail(`stable app identity expected ${fixture.expected}, got ${actual}`);
}
const defaultRule = normalizedAppRule({});
if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true, showOnLockScreen: true, showContentOnLockScreen: true }))
fail(`missing rule fields did not default safely: ${JSON.stringify(defaultRule)}`);
const explicitRule = normalizedAppRule({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false });
if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false }))
fail(`explicit rule was not preserved: ${JSON.stringify(explicitRule)}`);
for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification", "shouldShowOnLockScreen", "shouldShowContentOnLockScreen"]) {
functionBody(required);
}
const handler = source.indexOf("function handleNotification(notification: var)");
const tracked = source.indexOf("notification.tracked = true", handler);
const muted = source.indexOf("!root.appRule(appId).enabled", handler);
if (handler === -1 || tracked === -1 || muted === -1 || muted > tracked)
fail("muted applications are not rejected before tracking/history/unread/toast work");
if (!source.includes("root.handleNotification(notification)"))
fail("NotificationServer does not delegate delivery to the callable handler");
if (!source.includes("PreferenceSchema.has(\"notificationAppRules\")"))
fail("the schema dependency is not explicit");
if (!source.includes("DesktopPreferences.get(\"notificationAppRules\")"))
fail("rules are not read through DesktopPreferences");
if (!source.includes("DesktopPreferences.set(\"notificationAppRules\", next)"))
fail("rules are not written through DesktopPreferences");
if (!source.includes("next[knownAppId] = root.appRule(knownAppId)"))
fail("persisted rules are not normalized to the required three-field shape");
if (!source.includes("root.fallbackAppRules = next"))
fail("missing-schema preference writes do not retain an in-memory fallback");
if (!source.includes("if (!root.doNotDisturb)"))
fail("global DND popup override was removed");
for (const required of [
"DesktopEntries.applications.values",
"DesktopEntries.byId(appId)",
"DesktopEntries.heuristicLookup(appId)"
]) {
if (!source.includes(required))
fail(`persisted desktop entry ids are not reactively resolved through ${required}`);
}
for (const required of [
"Notifs.applications",
"Notifs.appRule(app.id).enabled",
"showOnLockScreen",
"showContentOnLockScreen",
"Notifs.setAppRule"
]) {
if (!page.includes(required))
fail(`settings page is missing ${required}`);
}
console.log("notification application rules contract: PASS");
'
state_home="$(mktemp -d /tmp/panama-notification-rules-state.XXXXXX)"
config_home="$(mktemp -d /tmp/panama-notification-rules-config.XXXXXX)"
data_home="$(mktemp -d /tmp/panama-notification-rules-data.XXXXXX)"
config_path="$state_home/quickshell"
harness="$config_path/notification-app-rules-harness.qml"
shell_log="$state_home/notification-app-rules.log"
cleanup() {
if [[ -n "${bus_pid:-}" ]]; then
kill "$bus_pid" >/dev/null 2>&1 || true
fi
rm -rf "$state_home" "$config_home" "$data_home"
}
trap cleanup EXIT
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
cp "$harness_fixture" "$harness"
mkdir -p "$data_home/applications"
cp "$desktop_entry_fixture" "$data_home/applications/org.persist.App.desktop"
# This is intentionally a copy-local integration dependency. The production
# schema is Claude's change; the runtime contract proves persistence only once
# that key exists and never stages a schema edit from this branch.
perl -0pi -e 's@(\n // ── Capture)@\n {\n key: "notificationAppRules", type: "json", def: {}, group: "notifications", internal: true\n },$1@' \
"$config_path/config/PreferenceSchema.qml"
rg -q 'key: "notificationAppRules", type: "json"' "$config_path/config/PreferenceSchema.qml" \
|| fail 'temporary schema integration key was not installed'
mapfile -t dbus_info < <(dbus-daemon --session --fork --print-address=1 --print-pid=1)
bus_address="${dbus_info[0]:-}"
bus_pid="${dbus_info[1]:-}"
[[ -n "$bus_address" && "$bus_pid" =~ ^[0-9]+$ ]] || fail 'private D-Bus session did not start'
qs_for_test() {
DBUS_SESSION_BUS_ADDRESS="$bus_address" \
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
XDG_DATA_HOME="$data_home" XDG_DATA_DIRS="$data_home" \
qs -p "$harness" "$@"
}
stop_harness() {
qs_for_test kill >/dev/null 2>&1 || true
for _ in $(seq 1 40); do
! qs_for_test ipc show >/dev/null 2>&1 && return
sleep 0.1
done
fail 'isolated notification harness did not stop cleanly'
}
start_harness() {
qs_for_test --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 40); do
qs_for_test ipc show 2>/dev/null | rg -q '^target notification-app-rules-test$' && return
sleep 0.1
done
sed -n '1,240p' "$shell_log" >&2
fail 'isolated notification harness did not start'
}
start_harness
exercise="$(qs_for_test ipc call notification-app-rules-test exercise)"
jq -e '
.appId == "org.signal.Signal.desktop" and
.initialRules == {
"org.signal.Signal.desktop": {
enabled: true,
showOnLockScreen: true,
showContentOnLockScreen: true
}
} and
.muted == { tracked: false, history: 0, popups: 0, unread: 0 } and
.dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and
.privacy == { visible: false, content: false } and
.fallback == {
id: "Fallback Terminal",
application: { id: "Fallback Terminal", name: "Fallback Terminal" }
}
' <<<"$exercise" >/dev/null || fail "runtime notification policy fixture failed: $exercise"
persisted="$(qs_for_test ipc call notification-app-rules-test persist)"
jq -e '. == {
"org.persist.App.desktop": {
enabled: false,
showOnLockScreen: true,
showContentOnLockScreen: false
}
}' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted"
settings_file="$config_home/panama/settings.json"
for _ in $(seq 1 40); do
[[ -f "$settings_file" ]] && jq -e '.notificationAppRules["org.persist.App.desktop"].enabled == false' "$settings_file" >/dev/null && break
sleep 0.1
done
[[ -f "$settings_file" ]] || fail 'runtime persistence fixture did not write settings.json'
wait_for_persisted_application() {
local expected="$1"
local applications=""
for _ in $(seq 1 80); do
applications="$(qs_for_test ipc call notification-app-rules-test applications)"
if jq -e '.[] | select(.id == "org.persist.App.desktop" and .name == "Persisted Fixture App")' \
<<<"$applications" >/dev/null; then
[[ "$applications" == *"$expected"* ]] && printf '%s' "$applications" && return
fi
sleep 0.1
done
fail "persisted desktop entry did not resolve to a friendly name: $applications"
}
before_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')"
stop_harness
start_harness
restored="$(qs_for_test ipc call notification-app-rules-test restored)"
[[ "$restored" == "$persisted" ]] || fail "notification rules did not survive isolated restart: $restored"
after_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')"
[[ "$before_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \
|| fail "persisted application name was wrong before restart: $before_restart_applications"
[[ "$after_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \
|| fail "persisted application name was wrong after restart: $after_restart_applications"
stop_harness
printf 'notification application rules runtime contract: PASS\n'
+94
View File
@@ -0,0 +1,94 @@
#!/bin/bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-osd"
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
mkdir -p "$scratch/bin"
log="$scratch/calls"
cat >"$scratch/bin/wpctl" <<'SH'
#!/bin/bash
printf 'wpctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ $1 == "get-volume" ]]; then
printf '%s\n' "${WPCTL_OUTPUT:-Volume: 0.58}"
fi
SH
cat >"$scratch/bin/brightnessctl" <<'SH'
#!/bin/bash
printf 'brightnessctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
fi
SH
cat >"$scratch/bin/playerctl" <<'SH'
#!/bin/bash
printf 'playerctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ $1 == "metadata" ]]; then
printf '%s\n' "${PLAYER_OUTPUT:-Horizon — Tycho}"
elif [[ $1 == "status" ]]; then
printf '%s\n' "${PLAYER_STATUS:-Playing}"
fi
SH
cat >"$scratch/bin/qs" <<'SH'
#!/bin/bash
printf 'qs' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
SH
chmod +x "$scratch/bin/"*
run_helper() {
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" "$helper" "$@"
}
assert_line() {
local expected="$1"
grep -Fqx -- "$expected" "$log" || {
printf 'osd helper contract: missing call\n%s\nactual:\n' "$expected" >&2
cat "$log" >&2
exit 1
}
}
: >"$log"
run_helper volume up 6
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
assert_line 'wpctl <get-volume> <@DEFAULT_AUDIO_SINK@>'
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
: >"$log"
WPCTL_OUTPUT='Volume: 0.58 [MUTED]' run_helper volume toggle
assert_line 'wpctl <set-mute> <@DEFAULT_AUDIO_SINK@> <toggle>'
assert_line 'qs <ipc> <call> <osd> <progress> <volume-muted> <58> <100> <Muted>'
: >"$log"
WPCTL_OUTPUT='Volume: 0.72 [MUTED]' run_helper microphone toggle
assert_line 'wpctl <set-mute> <@DEFAULT_AUDIO_SOURCE@> <toggle>'
assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Muted>'
: >"$log"
run_helper brightness up 5
assert_line 'brightnessctl <-e4> <-n2> <set> <5%+>'
assert_line 'brightnessctl <-m> <-c> <backlight>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>'
: >"$log"
run_helper media next
assert_line 'playerctl <next>'
assert_line 'qs <ipc> <call> <osd> <message> <media-next> <Horizon — Tycho>'
printf 'osd helper contract: PASS\n'
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
model="$repo_dir/config/dot/quickshell/modules/osd/OsdModel.js"
node - "$model" <<'JS'
const assert = require('node:assert/strict')
const model = require(process.argv[2])
assert.equal(model.iconFor('volume', 0), 'audio-volume-muted-symbolic')
assert.equal(model.iconFor('volume', 0.2), 'audio-volume-low-symbolic')
assert.equal(model.iconFor('volume', 0.5), 'audio-volume-medium-symbolic')
assert.equal(model.iconFor('volume', 0.9), 'audio-volume-high-symbolic')
assert.equal(model.iconFor('microphone-muted', 0.7), 'microphone-sensitivity-muted-symbolic')
assert.equal(model.iconFor('brightness', 0.4), 'display-brightness-symbolic')
assert.deepEqual(
model.progressState('volume', 140, 100, '', 900),
{
kind: 'volume',
value: 100,
maximum: 100,
ratio: 1,
label: '100%',
icon: 'audio-volume-high-symbolic',
duration: 900,
progress: true
}
)
assert.deepEqual(
model.progressState('volume-muted', 43, 100, 'Muted', -50),
{
kind: 'volume-muted',
value: 43,
maximum: 100,
ratio: 0.43,
label: 'Muted',
icon: 'audio-volume-muted-symbolic',
duration: 0,
progress: true
}
)
assert.deepEqual(
model.messageState('media-next', 'Glass Beams', 'invalid'),
{
kind: 'media-next',
value: 0,
maximum: 100,
ratio: 0,
label: 'Glass Beams',
icon: 'media-skip-forward-symbolic',
duration: 1400,
progress: false
}
)
console.log('osd model contract: PASS')
JS
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
qs_dir="$repo_dir/config/dot/quickshell"
osd="$qs_dir/modules/osd/Osd.qml"
state="$qs_dir/services/OsdState.qml"
shell_file="$qs_dir/shell.qml"
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
fail() {
printf 'osd ui contract: %s\n' "$1" >&2
exit 1
}
[[ -f "$osd" ]] || fail 'Prism OSD surface is missing'
[[ -f "$state" ]] || fail 'OSD presentation state is missing'
[[ -f "$qs_dir/modules/osd/qmldir" ]] || fail 'OSD module manifest is missing'
rg -Fq 'import qs.modules.osd' "$shell_file" || fail 'shell does not import the OSD module'
[[ "$(rg -c '^[[:space:]]*Osd \{\}' "$shell_file")" -eq 1 ]] \
|| fail 'shell does not create exactly one OSD per screen'
rg -Fq 'target: "osd"' "$shell_file" || fail 'OSD IPC target is missing'
rg -Fq 'OsdState.progress(kind, value, maximum, label)' "$shell_file" \
|| fail 'progress IPC is not wired to OSD state'
rg -Fq 'OsdState.message(kind, label)' "$shell_file" \
|| fail 'message IPC is not wired to OSD state'
rg -Fq 'WlrLayershell.namespace: "qs-popover-osd"' "$osd" \
|| fail 'OSD does not use the existing Prism blur namespace'
rg -Fq 'WlrLayershell.keyboardFocus: WlrKeyboardFocus.None' "$osd" \
|| fail 'OSD may steal keyboard focus'
rg -Fq 'mask: Region {}' "$osd" || fail 'OSD may intercept pointer input'
rg -Fq 'PrismEdge {' "$osd" || fail 'OSD is missing the Prism signature edge'
rg -Fq 'font.features: Theme.tabularFigures' "$osd" \
|| fail 'changing percentages do not use tabular figures'
rg -Fq '$HOME/.config/quickshell/scripts/panama-osd' "$keybinds" \
|| fail 'keybinds do not use the deployed Panama OSD helper'
for action in \
'volume up 6' \
'volume down 6' \
'volume toggle' \
'microphone toggle' \
'volume up 1' \
'volume down 1' \
'media play-pause' \
'media next' \
'media previous' \
'media stop' \
'brightness up 5' \
'brightness down 5'; do
rg -Fq "osd(\"$action\")" "$keybinds" \
|| fail "keybind is not routed through panama-osd $action"
done
printf 'osd ui contract: PASS\n'
+13 -7
View File
@@ -48,19 +48,21 @@ run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported su
[[ "$(run list)" == "[]" ]] || fail 'an empty backup directory did not list as empty' [[ "$(run list)" == "[]" ]] || fail 'an empty backup directory did not list as empty'
# ── A snapshot round-trips ─────────────────────────────────────────────────── # ── A snapshot round-trips ───────────────────────────────────────────────────
printf '{"gapsOut":24,"windowRounding":6}' >"$settings" printf '{"gapsOut":24,"windowRounding":6,"displays":{"DP-2":{"mode":"3840x2160@60","scale":2,"transform":0}}}' >"$settings"
mkdir -p "$(dirname "$home")" mkdir -p "$(dirname "$home")"
printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"}]}' >"$home" printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"}]}' >"$home"
run save >/dev/null || fail 'save failed on a valid settings file' run save >/dev/null || fail 'save failed on a valid settings file'
name="$(run list | jq -r '.[0].name')" name="$(run list | jq -r '.[0].name')"
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "unexpected snapshot name: $name" [[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "unexpected snapshot name: $name"
[[ "$(run list | jq -r '.[0].keys')" == "2" ]] || fail 'snapshot key count is wrong' [[ "$(run list | jq -r '.[0].keys')" == "3" ]] || fail 'snapshot key count is wrong'
printf '{"gapsOut":99}' >"$settings" printf '{"gapsOut":99,"displays":{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0}}}' >"$settings"
printf '{"initialized":false,"favorites":[]}' >"$home" printf '{"initialized":false,"favorites":[]}' >"$home"
restore_result="$(run restore "$name")" || fail 'restore failed' restore_result="$(run restore "$name")" || fail 'restore failed'
[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'restore did not bring back the snapshot contents' [[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'restore did not bring back the snapshot contents'
[[ "$(jq -r .windowRounding "$settings")" == "6" ]] || fail 'restore lost a key' [[ "$(jq -r .windowRounding "$settings")" == "6" ]] || fail 'restore lost a key'
[[ "$(jq -r '.displays["DP-2"].scale' "$settings")" == "1.5" ]] \
|| fail 'restore bypassed display confirmation by applying snapshot geometry'
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.desk" ]] || fail 'restore did not bring back Home favourites' [[ "$(jq -r '.favorites[0].id' "$home")" == "light.desk" ]] || fail 'restore did not bring back Home favourites'
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Desk" ]] || fail 'restore lost a Home alias' [[ "$(jq -r '.favorites[0].alias' "$home")" == "Desk" ]] || fail 'restore lost a Home alias'
jq -e '.home.present == true and .home.data.favorites[0].id == "light.desk"' <<<"$restore_result" >/dev/null \ jq -e '.home.present == true and .home.data.favorites[0].id == "light.desk"' <<<"$restore_result" >/dev/null \
@@ -77,16 +79,20 @@ absent_result="$(run restore "$absent_name")" || fail 'restore failed for a snap
jq -e '.home.present == false and (.home | has("data") | not)' <<<"$absent_result" >/dev/null \ jq -e '.home.present == false and (.home | has("data") | not)' <<<"$absent_result" >/dev/null \
|| fail 'restore did not return absent Home state for the live service to reload' || fail 'restore did not return absent Home state for the live service to reload'
# Desktop absence is symmetric: a Home-only snapshot removes a desktop file # Desktop absence is symmetric for ordinary preferences, but confirmed display
# created later and restores the Home store. # geometry is protected state: it must survive even a Home-only snapshot.
rm -f "$settings" rm -f "$settings"
printf '{"initialized":true,"favorites":[{"id":"light.porch","alias":"Porch"}]}' >"$home" printf '{"initialized":true,"favorites":[{"id":"light.porch","alias":"Porch"}]}' >"$home"
run save >/dev/null || fail 'save failed when desktop settings were absent' run save >/dev/null || fail 'save failed when desktop settings were absent'
desktop_absent_name="$(run list | jq -r '.[0].name')" desktop_absent_name="$(run list | jq -r '.[0].name')"
printf '{"gapsOut":47}' >"$settings" printf '{"gapsOut":47,"windowRounding":9,"displays":{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0}}}' >"$settings"
printf '{"initialized":false,"favorites":[]}' >"$home" printf '{"initialized":false,"favorites":[]}' >"$home"
run restore "$desktop_absent_name" >/dev/null || fail 'Home-only snapshot restore failed' run restore "$desktop_absent_name" >/dev/null || fail 'Home-only snapshot restore failed'
[[ ! -e "$settings" ]] || fail 'restore did not preserve the snapshot’s absent desktop state' [[ -e "$settings" ]] || fail 'Home-only restore discarded confirmed display geometry'
[[ "$(jq -r '.displays["DP-2"].scale' "$settings")" == "1.5" ]] \
|| fail 'Home-only restore changed confirmed display geometry'
[[ "$(jq 'keys == ["displays"]' "$settings")" == "true" ]] \
|| fail 'Home-only restore retained ordinary desktop preferences'
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.porch" ]] \ [[ "$(jq -r '.favorites[0].id' "$home")" == "light.porch" ]] \
|| fail 'Home-only snapshot did not restore Home state' || fail 'Home-only snapshot did not restore Home state'
assert_transaction_clean assert_transaction_clean
@@ -43,6 +43,8 @@ for mapping in \
'HomePreferences.initialize(ids);' \ 'HomePreferences.initialize(ids);' \
'HomePreferences.setAlias(id, alias);' \ 'HomePreferences.setAlias(id, alias);' \
'DesktopPreferences.reload();' \ 'DesktopPreferences.reload();' \
'DesktopPreferences.set("displays", value);' \
'Displays.externalChangeBlocked = blocked;' \
'SystemSettings.applyPersistedDisplayPolicy();' \ 'SystemSettings.applyPersistedDisplayPolicy();' \
'Keybinds.applyReload();' \ 'Keybinds.applyReload();' \
'Wallpaper.set(path);' \ 'Wallpaper.set(path);' \
@@ -82,9 +84,11 @@ jq -e '
"home.alias:light.desk=Desk", "home.alias:light.desk=Desk",
"home.alias:light.office=Office", "home.alias:light.office=Office",
"desktop.reload", "desktop.reload",
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0}}",
"system.apply", "system.apply",
"keybinds.reload", "keybinds.reload",
"wallpaper.set:/tmp/restored-wallpaper.jpg", "wallpaper.set:/tmp/restored-wallpaper.jpg",
"display.block:false",
"shell.reload" "shell.reload"
] ]
and .initialized == true and .initialized == true
@@ -118,15 +122,26 @@ jq -e '
.calls == [ .calls == [
"home.reset", "home.reset",
"desktop.reload", "desktop.reload",
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0}}",
"system.apply", "system.apply",
"keybinds.reload", "keybinds.reload",
"wallpaper.set:/tmp/restored-wallpaper.jpg", "wallpaper.set:/tmp/restored-wallpaper.jpg",
"display.block:false",
"shell.reload" "shell.reload"
] ]
and .initialized == false and .initialized == false
and .favorites == [] and .favorites == []
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status" ' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status"
# Restore refuses before launching the helper while a display apply/recovery is
# active, so no snapshot can race the confirmation boundary.
qs_test ipc call settings-backup-behavior reset >/dev/null
[[ "$(qs_test ipc call settings-backup-behavior restoreWhileDisplayBusy)" == "false" ]] \
|| fail 'snapshot restore started during an active display operation'
status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls == [] and (.lastError | contains("display change"))' <<<"$status" >/dev/null \
|| fail "display-busy restore refusal was not clean: $status"
trap - EXIT trap - EXIT
cleanup cleanup
printf 'settings backup live contract: PASS\n' printf 'settings backup live contract: PASS\n'
@@ -20,10 +20,12 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml" harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml" system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
wallpaper_service="$repo_dir/config/dot/quickshell/services/Wallpaper.qml"
# Preferences are committed to $XDG_CONFIG_HOME, and the Home store lives under # Preferences are committed to $XDG_CONFIG_HOME, and the Home store lives under
# $XDG_STATE_HOME. Both are isolated so this contract cannot touch the real # $XDG_STATE_HOME. Both are isolated so this contract cannot touch the real
# desktop's settings; the compositor is the live one and is restored below. # desktop's settings. The harness replaces the compositor write seam as well,
# so interruption cannot leave the daily desktop modified.
config_home="$(mktemp -d /tmp/panama-commit-config.XXXXXX)" config_home="$(mktemp -d /tmp/panama-commit-config.XXXXXX)"
state_home="$(mktemp -d /tmp/panama-commit-state.XXXXXX)" state_home="$(mktemp -d /tmp/panama-commit-state.XXXXXX)"
@@ -37,22 +39,30 @@ rg -Fq 'HomePreferences.resetHomeDefaults();' "$system_settings" \
if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$system_settings"; then if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$system_settings"; then
fail 'restoreDefaults mutates Home aliases instead of using resetHomeDefaults' fail 'restoreDefaults mutates Home aliases instead of using resetHomeDefaults'
fi fi
rg -Fq 'root.protectDisplays(protectedDisplays)' "$system_settings" \
|| fail 'restoreDefaults can apply unconfirmed display geometry during reload'
rg -Fq 'Keybinds.applyReload();' "$system_settings" \
|| fail 'restoreDefaults does not replay shipped keybindings'
rg -Fq 'root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));' "$system_settings" \
|| fail 'restoreDefaults does not visibly reapply the shipped wallpaper'
rg -Fq 'const effectivePath = path === "" ? root.shippedPath : path;' "$wallpaper_service" \
|| fail 'clearing wallpaper preference leaves the old image visible'
rg -Fq 'property string storedValue:' "$wallpaper_service" \
|| fail 'the shipped wallpaper cannot remain represented by the default empty preference'
qs_for_harness() { qs_for_harness() {
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@" XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" "$@"
} }
original_rounding="$(hyprctl -j getoption decoration:rounding | jq -r .int)"
original_gaps="$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')"
restore() { restore() {
hyprctl eval "hl.config({ decoration = { rounding = $original_rounding }, general = { gaps_out = $original_gaps } })" >/dev/null 2>&1 || true
qs_for_harness kill >/dev/null 2>&1 || true qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home" "$state_home" rm -rf "$config_home" "$state_home"
} }
trap restore EXIT trap restore EXIT
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" --daemonize >/dev/null 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 for _ in $(seq 1 40); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' && break qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' && break
sleep 0.1 sleep 0.1
@@ -65,17 +75,13 @@ qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' || f
[[ "$(qs_for_harness ipc call settings-system-test stored showSeconds)" == "false" ]] \ [[ "$(qs_for_harness ipc call settings-system-test stored showSeconds)" == "false" ]] \
|| fail 'a local key was not stored' || fail 'a local key was not stored'
# ── A compositor key reaches Hyprland, then is stored ──────────────────────── # ── A compositor key reaches the verified apply boundary, then is stored ────
target_rounding=$(( original_rounding == 11 ? 13 : 11 )) target_rounding=11
[[ "$(qs_for_harness ipc call settings-system-test commit windowRounding "$target_rounding")" == "true" ]] \ [[ "$(qs_for_harness ipc call settings-system-test commit windowRounding "$target_rounding")" == "true" ]] \
|| fail 'commitPreference refused a compositor key' || fail 'commitPreference refused a compositor key'
apply_state="$(qs_for_harness ipc call settings-system-test applyState)"
for _ in $(seq 1 40); do jq -e '.[-1].windowRounding == 11' <<<"$apply_state" >/dev/null \
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "$target_rounding" ]] && break || fail "a compositor-backed commit did not reach the apply boundary: $apply_state"
sleep 0.1
done
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "$target_rounding" ]] \
|| fail "a compositor-backed commit did not reach Hyprland (rounding=$(hyprctl -j getoption decoration:rounding | jq -r .int))"
[[ "$(qs_for_harness ipc call settings-system-test stored windowRounding)" == "$target_rounding" ]] \ [[ "$(qs_for_harness ipc call settings-system-test stored windowRounding)" == "$target_rounding" ]] \
|| fail 'a verified compositor commit was not stored' || fail 'a verified compositor commit was not stored'
@@ -92,6 +98,9 @@ before="$(qs_for_harness ipc call settings-system-test stored windowRounding)"
# ── Reset spans every store, not just the schema one ───────────────────────── # ── Reset spans every store, not just the schema one ─────────────────────────
qs_for_harness ipc call settings-system-test seedHome >/dev/null qs_for_harness ipc call settings-system-test seedHome >/dev/null
qs_for_harness ipc call settings-system-test commit dockHideDelayMs 900 >/dev/null qs_for_harness ipc call settings-system-test commit dockHideDelayMs 900 >/dev/null
display_fixture='{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0}}'
[[ "$(qs_for_harness ipc call settings-system-test commit displays "$display_fixture")" == "true" ]] \
|| fail 'the protected display fixture did not apply'
sleep 0.4 sleep 0.4
home_before="$(qs_for_harness ipc call settings-system-test homeState)" home_before="$(qs_for_harness ipc call settings-system-test homeState)"
@@ -100,24 +109,33 @@ jq -e '.count == 1 and .initialized == true' <<<"$home_before" >/dev/null \
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "900" ]] \ [[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "900" ]] \
|| fail 'the dock fixture did not apply' || fail 'the dock fixture did not apply'
qs_for_harness ipc call settings-system-test restoreDefaults >/dev/null [[ "$(qs_for_harness ipc call settings-system-test restoreDefaults)" == "true" ]] \
|| fail 'restoreDefaults refused a safe reset'
sleep 0.6 sleep 0.6
reset_state="$(qs_for_harness ipc call settings-system-test resetState)"
jq -e '.calls == [
"display.block:true",
"display.protect",
"keybinds.reload",
"wallpaper.set:",
"display.block:false"
] and .displayBlocked == false' <<<"$reset_state" >/dev/null \
|| fail "reset did not safely replay non-reactive state: $reset_state"
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "250" ]] \ [[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "250" ]] \
|| fail 'reset did not restore a schema default' || fail 'reset did not restore a schema default'
[[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" == "$(jq -cS . <<<"$display_fixture")" ]] \
|| fail 'reset replaced confirmed display geometry without confirmation'
home_after="$(qs_for_harness ipc call settings-system-test homeState)" home_after="$(qs_for_harness ipc call settings-system-test homeState)"
jq -e '.count == 0 and .initialized == false' <<<"$home_after" >/dev/null \ jq -e '.count == 0 and .initialized == false' <<<"$home_after" >/dev/null \
|| fail "reset left the Home accessory store customised: $home_after" || fail "reset left the Home accessory store customised: $home_after"
# Resetting a stored value does not by itself tell Hyprland anything, so the # Resetting a stored value does not itself apply compositor policy, so the last
# reset must re-apply compositor-backed defaults too. # isolated batch must contain the shipped default.
for _ in $(seq 1 40); do jq -e '.appliedBatches[-1].windowRounding == 18' <<<"$reset_state" >/dev/null \
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "18" ]] && break || fail "reset did not re-apply the compositor default: $reset_state"
sleep 0.1
done
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "18" ]] \
|| fail "reset did not re-apply the compositor default (rounding=$(hyprctl -j getoption decoration:rounding | jq -r .int))"
trap - EXIT trap - EXIT
restore restore
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# The Sound page is a first-class PipeWire control surface, not a launcher for
# another settings app. This contract keeps the real device plumbing shared
# with Quick Settings and verifies the controls that must remain available.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml"
device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml"
device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.qml"
balance="$repo_dir/config/dot/quickshell/modules/settings/AudioBalance.qml"
audio_devices="$repo_dir/config/dot/quickshell/services/AudioDevices.qml"
sound_feedback="$repo_dir/config/dot/quickshell/services/SoundFeedback.qml"
quick_devices="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml"
harness="$repo_dir/config/dot/quickshell/sound-page-harness.qml"
config_home="$(mktemp -d /tmp/panama-sound-config.XXXXXX)"
state_home="$(mktemp -d /tmp/panama-sound-state.XXXXXX)"
fail() {
printf 'sound page contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home" "$state_home"
}
trap cleanup EXIT
for file in "$sound_page" "$device_list" "$device_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done
# One shared source of truth owns device discovery and default selection.
rg -Fq 'pragma Singleton' "$audio_devices" || fail 'audio device service is not a singleton'
rg -Fq 'Singleton {' "$audio_devices" || fail 'audio device service has no singleton root'
rg -Fq 'PwNodeType.AudioSource' "$audio_devices" || fail 'audio sources are not filtered by PipeWire type'
rg -Fq 'Pipewire.preferredDefaultAudioSink = node;' "$audio_devices" || fail 'output selection does not reach PipeWire'
rg -Fq 'Pipewire.preferredDefaultAudioSource = node;' "$audio_devices" || fail 'input selection does not reach PipeWire'
rg -Fq 'AudioDevices.outputs' "$quick_devices" || fail 'Quick Settings does not share output discovery'
rg -Fq 'AudioDevices.inputs' "$quick_devices" || fail 'Quick Settings does not share input discovery'
rg -Fq 'AudioDevices.select(root.output, node)' "$quick_devices" || fail 'Quick Settings does not share device selection'
# Every hardware row binds its node before reading/writing audio state.
rg -Fq 'PwObjectTracker {' "$device_row" || fail 'device rows do not bind PipeWire objects'
rg -Fq 'root.node.audio.muted = !root.node.audio.muted;' "$device_row" || fail 'device mute is not writable'
rg -Fq 'root.node.audio.volume = value;' "$device_row" || fail 'per-device volume is not writable'
rg -Fq 'PwNodePeakMonitor {' "$device_row" || fail 'input rows have no level monitor'
rg -Fq 'enabled: !root.output && root.selected' "$device_row" || fail 'input monitoring is not scoped to the selected source'
# Stereo hardware gets a real channel balance control.
rg -Fq 'PwAudioChannel.FrontLeft' "$balance" || fail 'balance does not identify the left channel'
rg -Fq 'PwAudioChannel.FrontRight' "$balance" || fail 'balance does not identify the right channel'
rg -Fq 'root.node.audio.volumes = next;' "$balance" || fail 'balance does not write per-channel volume'
[[ "$(rg -c 'SoundDeviceList \{' "$sound_page")" -eq 2 ]] || fail 'Sound page does not expose output and input device lists'
rg -Fq 'AudioBalance {' "$sound_page" || fail 'Sound page has no output balance control'
rg -Fq 'SystemSettings.openGnomePanel("sound")' "$sound_page" || fail 'advanced GNOME Sound handoff was removed'
rg -Fq 'SoundFeedback.setEventSounds(checked)' "$sound_page" || fail 'event sounds are not controllable'
rg -Fq 'SoundFeedback.setInputFeedback(checked)' "$sound_page" || fail 'input feedback sounds are not controllable'
rg -Fq 'org.gnome.desktop.sound' "$sound_feedback" || fail 'sound feedback does not use the desktop sound schema'
# Native bindings are the supported path. Shelling out would race the service
# that owns these same objects and regress Quick Settings coherence.
if rg -q '\b(Process|pactl|wpctl)\b' "$audio_devices" "$device_list" "$device_row" "$balance"; then
fail 'Sound controls bypass the Quickshell PipeWire service'
fi
printf 'sound page static contract: PASS\n'
# Instantiate the complete page against the real, read-only PipeWire graph.
# Merely constructing these controls must never change a default or volume.
qs_for_harness --daemonize >/dev/null
for _ in $(seq 1 60); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target sound-page-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target sound-page-test$' \
|| fail 'Sound page harness did not start'
for _ in $(seq 1 60); do
status="$(qs_for_harness ipc call sound-page-test status)"
[[ "$(jq -r .ready <<<"$status")" == "true" ]] && break
sleep 0.1
done
jq -e '.ready == true and .outputs > 0 and .inputs > 0
and (.defaultOutput | length > 0) and (.defaultInput | length > 0)' \
<<<"$status" >/dev/null \
|| fail "real PipeWire graph was not represented: $status"
trap - EXIT
cleanup
printf 'sound page runtime: PASS (%s outputs, %s inputs)\n' \
"$(jq -r .outputs <<<"$status")" "$(jq -r .inputs <<<"$status")"