Author SHA1 Message Date
Gabriel Brown 83391e0453 Make settings restore crash-safe 2026-08-18 02:22:23 -04:00
Gabriel Brown 172b099a04 Complete settings snapshot restoration 2026-08-18 02:22:23 -04:00
Gabriel Brown cae72b5179 Make Settings sidebar content scrollable 2026-08-18 01:54:00 -04:00
Gabriel Brown b3b8e0d66d Prevent shortcut reset collisions 2026-08-18 01:47:51 -04:00
Gabriel Brown 787d2b121a Use the durable Home reset boundary 2026-08-18 01:39:34 -04:00
Gabriel Brown 1b323c2fa5 Document Settings completion plan 2026-08-18 01:36:30 -04:00
Gabriel Brown 1ca571458c Complete Panama settings controls 2026-08-18 01:36:07 -04:00
Gabriel Brown 768801dbe4 Fix application role filtering 2026-08-18 01:28:29 -04:00
Gabriel Brown 375ecfcd95 Add application and autostart settings 2026-08-18 01:28:29 -04:00
Gabriel Brown ce95b34d19 Harden Home preferences reset contract 2026-08-18 01:28:29 -04:00
Gabriel Brown 9d430a3079 Finish Home and Phone settings cohesion 2026-08-18 01:28:29 -04:00
Gabriel Brown bac68d2bfb Document the settings vocabulary and its traps
Records how to add a setting -- one schema entry -- and the failure
modes found building the pages. Every one of them fails silently rather
than loudly, which is why they are worth writing down: a wrong readAs
makes successful writes look rejected, hyprpaper accepts an all-outputs
wallpaper request and ignores it, and a copy of the Quickshell config
shares the live shell's ID so an "isolated" harness can kill the running
desktop.

Also documents the two generated configurations and why they are
generated: ~/.config/hypr is a symlink into this repository, so writing
there at runtime would commit machine state to a tracked file.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 01:15:53 -04:00
38 changed files with 3787 additions and 1041 deletions
+47
View File
@@ -44,6 +44,30 @@ Validate any change without leaving your session:
Hyprland --verify-config
```
## Generated configuration
Two ecosystem tools cannot read the shared settings file, and their configs live
in this directory — which is a symlink into the Panama repository, so writing to
them at runtime would put machine state into a tracked file. Both are therefore
generated elsewhere:
| Tool | Generated to | Pointed there by |
|---|---|---|
| `hypridle` | `$XDG_STATE_HOME/panama/hypridle.conf` | a systemd user drop-in installed by `panama-idle install` |
| `hyprpaper` | not generated — the wallpaper is applied over IPC and re-applied at shell start | — |
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed;
Panama Settings shows which of the two states you are in rather than presenting
controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with:
```sh
~/.config/quickshell/scripts/panama-idle remove
```
## Settings: one file, both sides
`~/.config/panama/settings.json` is shared with the Quickshell side. The
@@ -67,6 +91,29 @@ wrong-typed settings file costs you your customisations and nothing else;
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
accepts the config in each of those states.
### Adding a keybind: use `bind`, not `hl.bind`
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from
Panama Settings without editing this file.
```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
Only the **chord** is ever taken from settings — the action is always the Lua
value written here. A stored override can therefore move a shortcut but can
never make one do something else, which is what makes reading overrides from a
file the user can edit safe.
Overrides are keyed by the **shipped chord**, not the description. Descriptions
are not unique: "Calculator" is both `SUPER + C` and the `XF86Calculator`
hardware key, and keying by description moved both onto the same new chord,
silently costing the hardware key.
An override whose value is not a plausible chord is ignored in favour of the
shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
### Keybind descriptions are required
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
@@ -82,6 +82,14 @@ Singleton {
values.initialized = true;
}
function resetHomeDefaults(): void {
persistTimer.stop();
values.favorites = [];
values.initialized = false;
root.saveError = "";
preferencesFile.writeAdapter();
}
function isSelected(entityId: string): bool {
for (var index = 0; index < values.favorites.length; index++) {
if (values.favorites[index].id === entityId) {
+10 -10
View File
@@ -28,13 +28,13 @@ Singleton {
// label deliberately general rather than exposing precise coordinates in
// the UI or guessing at a city from them.
readonly property string weatherLocation: "Local weather"
readonly property string temperatureUnit: "fahrenheit"
readonly property int weatherRefreshMinutes: 20
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
// ── Vitals ──────────────────────────────────────────────────────────────
// The GNOME Vitals extension showed processor usage, memory usage and GPU
// usage, in that order. Same here.
readonly property int vitalsIntervalMs: 2000
readonly property int vitalsIntervalMs: DesktopPreferences.get("vitalsIntervalMs")
readonly property bool showCpu: DesktopPreferences.get("showCpu")
readonly property bool showMemory: DesktopPreferences.get("showMemory")
readonly property bool showGpu: DesktopPreferences.get("showGpu")
@@ -51,10 +51,10 @@ Singleton {
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
// ── Notifications ───────────────────────────────────────────────────────
readonly property int notificationTimeoutMs: 5000
readonly property int notificationTimeoutCriticalMs: 0 // 0 = never auto-expire
readonly property int notificationHistoryLimit: 100
readonly property int maxVisibleToasts: 4
readonly property int notificationTimeoutMs: DesktopPreferences.get("notificationTimeoutMs")
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
// ── Focus ──────────────────────────────────────────────────────────────
// One deliberate default rather than a preset picker: quick settings and
@@ -79,9 +79,9 @@ Singleton {
readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs")
// ── Capture ─────────────────────────────────────────────────────────────
readonly property string screenshotDir: "Pictures/Screenshots"
readonly property string recordingDir: "Videos/Recordings"
readonly property string screenshotDir: DesktopPreferences.get("screenshotDir")
readonly property string recordingDir: DesktopPreferences.get("recordingDir")
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
// cost CPU while gaming.
readonly property string recorderArgs: "-c h264_vaapi -d /dev/dri/renderD128"
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
}
@@ -13,6 +13,7 @@ ShellRoot {
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
function move(id: string, index: int): void { HomePreferences.move(id, index); }
function remove(id: string): void { HomePreferences.remove(id); }
function reset(): void { HomePreferences.resetHomeDefaults(); }
function status(): string {
return JSON.stringify({
initialized: HomePreferences.initialized,
+10 -2
View File
@@ -13,9 +13,16 @@ ShellRoot {
return Keybinds.rebind(current, next);
}
function resetBind(current: string): void { Keybinds.resetBind(current); }
function resetBind(current: string): bool { return Keybinds.resetBind(current); }
function resetAll(): void { Keybinds.resetAll(); }
function seedResetCollision(shipped: string, current: string, occupantShipped: string): void {
const overrides = {};
overrides[shipped] = current;
overrides[occupantShipped] = shipped;
DesktopPreferences.set("keybindOverrides", overrides);
}
function chordFor(description: string): string {
const found = Keybinds.binds.find(bind => bind.description === description);
return found ? found.luaChord : "";
@@ -24,7 +31,8 @@ ShellRoot {
function overrideState(): string {
return JSON.stringify({
overrides: Keybinds.overrides,
count: Object.keys(Keybinds.overrides).length
count: Object.keys(Keybinds.overrides).length,
lastError: Keybinds.lastError
});
}
@@ -3,39 +3,49 @@ import Quickshell
import qs.config
import qs.services
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "About Panama"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "A curated Hyprland desktop built around focus, speed, and good taste."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsPage {
title: "About Panama"
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
SettingsCard {
title: "Panama Desktop"
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
SettingRow { label: "Hyprland"; value: SystemSettings.hyprlandVersion || "Detecting…" }
SettingRow { label: "Quickshell"; value: SystemSettings.quickshellVersion }
SettingRow { label: "Display"; value: SystemSettings.monitorName || "Detecting…" }
SettingRow { label: "Configuration"; detail: Quickshell.shellDir; value: "Local"; divider: false }
TextRow {
label: "Hyprland"
value: SystemSettings.hyprlandVersion || "Detecting…"
}
TextRow {
label: "Quickshell"
value: SystemSettings.quickshellVersion
}
TextRow {
label: "Display"
value: SystemSettings.monitorName || "Detecting…"
}
TextRow {
label: "Configuration"
detail: Quickshell.shellDir
value: "Local"
divider: false
}
}
SettingsCard {
title: "Design principles"
SettingRow { label: "Curated by default"; detail: "Strong choices instead of an incoherent matrix of switches" }
SettingRow { label: "Quiet while idle"; detail: "No continuous decorative repaint loops" }
SettingRow { label: "Real system boundaries"; detail: "Every control either works or clearly hands off to its owner"; divider: false }
TextRow {
label: "Curated by default"
detail: "Strong choices instead of an incoherent matrix of switches"
}
TextRow {
label: "Quiet while idle"
detail: "No continuous decorative repaint loops"
}
TextRow {
label: "Real system boundaries"
detail: "Every control either works or clearly hands off to its owner"
divider: false
}
}
}
@@ -1,19 +1,217 @@
// Applications — PLACEHOLDER.
//
// Owned by the codex agent, which is building default-application handling and
// the autostart list. This stub exists only so the page id can be routed,
// registered, and searchable before that work lands; it is expected to be
// replaced wholesale rather than edited.
// Applications and session startup.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "applications"
title: "Applications"
lede: "Default applications and what starts with your session."
lede: "Choose what opens your files and links, and what starts with your session."
property string expandedRole: ""
readonly property var applications: DesktopEntries.applications.values
readonly property var roles: [
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
{ key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] },
{ key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] },
{ key: "music", label: "Music", detail: "MP3 audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
{ key: "images", label: "Images", detail: "PNG images", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
{ key: "video", label: "Video", detail: "MP4 video", categorySets: [["video"]], terms: ["video player", "movie player"] }
]
function desktopId(entry: var): string {
const entryId = String(entry?.id ?? "");
return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop";
}
function displayName(entry: var): string {
return String(entry?.name || entry?.genericName || root.desktopId(entry));
}
function currentHandler(role: string): string {
return String(DefaultApps.handlers[role] ?? "");
}
function currentEntry(role: string): var {
const handler = root.currentHandler(role);
return root.applications.find(entry => root.desktopId(entry) === handler) ?? null;
}
function matchesRole(entry: var, role: var): bool {
const rawCategories = Array.isArray(entry.categories)
? entry.categories
: [String(entry.categories ?? "")];
const categories = [];
for (const rawCategory of rawCategories) {
for (const value of String(rawCategory).split(";")) {
const category = value.trim().toLowerCase();
if (category !== "")
categories.push(category);
}
}
const metadata = [entry.name, entry.genericName]
.map(value => String(value ?? "").toLowerCase())
.join(" ");
return role.categorySets.some(set => set.every(category => categories.includes(category)))
|| role.terms.some(term => metadata.includes(term));
}
function choicesForRole(role: var): var {
const choices = root.applications.filter(entry => root.matchesRole(entry, role));
const currentEntry = root.currentEntry(role.key);
if (currentEntry && !choices.some(entry => root.desktopId(entry) === root.desktopId(currentEntry)))
choices.push(currentEntry);
return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right)));
}
TextRow {
visible: DefaultApps.lastError !== ""
label: "Application settings need attention"
detail: DefaultApps.lastError
value: ""
divider: false
}
SettingsCard {
title: "Being built"
subtitle: "Default browser, mail, files, and terminal, plus the autostart list, are on their way."
title: "Default applications"
subtitle: "Open a row to choose from applications that advertise the matching role."
Repeater {
model: root.roles
delegate: Column {
id: roleBlock
required property var modelData
required property int index
readonly property var choices: root.choicesForRole(roleBlock.modelData)
readonly property var selectedEntry: root.currentEntry(roleBlock.modelData.key)
width: parent.width
SettingRow {
label: roleBlock.modelData.label
detail: roleBlock.modelData.detail
value: DefaultApps.busy ? "Loading…" : (
roleBlock.selectedEntry
? root.displayName(roleBlock.selectedEntry)
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
)
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
onActivated: {
root.expandedRole = root.expandedRole === roleBlock.modelData.key
? ""
: roleBlock.modelData.key;
}
}
Column {
width: parent.width
visible: root.expandedRole === roleBlock.modelData.key
Repeater {
model: roleBlock.choices
delegate: SettingRow {
id: candidateRow
required property var modelData
required property int index
readonly property string candidateId: root.desktopId(candidateRow.modelData)
readonly property bool selected: candidateRow.candidateId === root.currentHandler(roleBlock.modelData.key)
label: root.displayName(candidateRow.modelData)
detail: String(candidateRow.modelData.genericName || candidateRow.modelData.comment || candidateRow.candidateId)
value: candidateRow.selected ? "Current" : ""
activatable: !candidateRow.selected && !DefaultApps.busy
divider: candidateRow.index < roleBlock.choices.length - 1 || roleBlock.index < root.roles.length - 1
onActivated: {
DefaultApps.setDefault(roleBlock.modelData.key, candidateRow.candidateId);
root.expandedRole = "";
}
}
}
}
}
}
}
SettingsCard {
title: "User autostart"
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
TextRow {
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
label: "No user autostart entries"
detail: "Applications can add entries to ~/.config/autostart."
value: ""
divider: false
}
Repeater {
model: DefaultApps.autostartEntries
delegate: SettingRow {
id: autostartRow
required property var modelData
required property int index
label: autostartRow.modelData.name
detail: autostartRow.modelData.id
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled"
activatable: !DefaultApps.busy
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled)
}
}
}
SettingsCard {
title: "Compositor autostart"
subtitle: "Panama starts these from Hyprland configuration. They are read-only here."
TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
label: "No compositor entries found"
detail: "No hl.exec_cmd entries were found in config/dot/hypr/autostart.lua."
value: ""
divider: false
}
Repeater {
model: DefaultApps.luaAutostartEntries
delegate: TextRow {
id: luaRow
required property var modelData
required property int index
label: luaRow.modelData.name
detail: luaRow.modelData.command
value: "Hyprland"
divider: luaRow.index < DefaultApps.luaAutostartEntries.length - 1
}
}
}
SettingsCard {
title: "Refresh"
ActionRow {
label: "Reload application settings"
detail: "Re-read desktop entries, defaults, and user autostart files"
action: DefaultApps.busy ? "Refreshing…" : "Refresh"
enabled: !DefaultApps.busy
divider: false
onTriggered: DefaultApps.refresh()
}
}
}
@@ -5,9 +5,12 @@ import qs.config
import qs.services
import qs.modules.quicksettings
Item {
SettingsPage {
id: root
title: "Network & Devices"
lede: "Connect graphically—no terminal workflow required."
readonly property var wifiDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wifi)
@@ -17,30 +20,15 @@ Item {
}
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Network & Devices"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Connect graphically—no terminal workflow required."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsCard {
title: "Wi‑Fi"
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
SettingRow {
label: "Wi‑Fi"
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
@@ -49,40 +37,56 @@ Item {
onToggled: value => Networking.wifiEnabled = value
}
}
WifiList { width: parent.width; device: root.wifiDevice; active: true; maxHeight: 240 }
SettingRow {
WifiList {
width: parent.width
device: root.wifiDevice
active: true
maxHeight: 240
}
ActionRow {
label: "Advanced network settings"
detail: "VPN, wired profiles, DNS, and connection details"
divider: false
controlWidth: 104
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("network") }
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("network")
}
}
SettingsCard {
title: "Bluetooth"
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
SettingRow {
label: "Bluetooth"
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.bluetoothAdapter?.enabled ?? false
enabled: root.bluetoothAdapter !== null
onToggled: value => { if (root.bluetoothAdapter) root.bluetoothAdapter.enabled = value; }
onToggled: value => {
if (root.bluetoothAdapter)
root.bluetoothAdapter.enabled = value;
}
}
BluetoothList { width: parent.width; active: true; maxHeight: 220 }
SettingRow {
}
BluetoothList {
width: parent.width
active: true
maxHeight: 220
}
ActionRow {
label: "Advanced Bluetooth settings"
detail: "Device details and system-level options"
divider: false
controlWidth: 104
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("bluetooth") }
}
}
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("bluetooth")
}
}
}
@@ -3,29 +3,15 @@ import qs.config
import qs.services
import qs.widgets
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Displays"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: SystemSettings.monitorDescription || "Reading the active display…"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsPage {
title: "Displays"
lede: SystemSettings.monitorDescription || "Reading the active display…"
SettingsCard {
title: SystemSettings.monitorName || "Active display"
subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
SettingRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
SettingRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false }
TextRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
TextRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false }
}
SettingsCard {
@@ -92,6 +78,4 @@ Item {
wrapMode: Text.WordWrap
}
}
}
}
}
@@ -2,54 +2,32 @@ import QtQuick
import qs.config
import qs.services
Item {
SettingsPage {
id: root
readonly property int openedHour: new Date().getHours()
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text {
text: `${root.greeting}, Gabriel`
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Text {
text: "Your Panama desktop is configured and ready."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
bottomPadding: 6
}
title: `${root.greeting}, Gabriel`
lede: "Your Panama desktop is configured and ready."
SettingsCard {
title: SystemSettings.monitorDescription || "Active display"
subtitle: SystemSettings.monitorName || "Detecting your display…"
Row {
Grid {
id: monitorLayout
width: parent.width
height: 164
spacing: 28
columns: width >= 620 ? 2 : 1
columnSpacing: 28
rowSpacing: 16
Item {
width: parent.width * 0.47
height: parent.height
width: monitorLayout.columns === 2
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: 164
Rectangle {
width: Math.min(parent.width - 24, 260)
@@ -85,8 +63,10 @@ Item {
}
Column {
width: parent.width * 0.47
anchors.verticalCenter: parent.verticalCenter
width: monitorLayout.columns === 2
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: monitorLayout.columns === 2 ? 164 : implicitHeight
spacing: 13
Text {
@@ -120,12 +100,31 @@ Item {
}
}
Row {
width: parent.width
spacing: 16
SettingsCard {
title: "Weather"
subtitle: "Local conditions in the date menu"
ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
SettingsCard {
width: (parent.width - parent.spacing) / 2
title: "System vitals"
subtitle: "Processor, memory, and graphics activity in the bar"
SliderRow { setting: "vitalsIntervalMs"; divider: false }
}
Grid {
id: summaryCards
width: parent.width
columns: width >= 720 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
SettingsCard {
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Quiet focus"
subtitle: "Notifications and focused work"
@@ -144,11 +143,13 @@ Item {
}
SettingsCard {
width: (parent.width - parent.spacing) / 2
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Desktop services"
subtitle: "The essentials are running"
SettingRow {
TextRow {
label: "Sync & remote access"
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
divider: false
@@ -156,6 +157,4 @@ Item {
}
}
}
}
}
}
@@ -2,9 +2,11 @@ import QtQuick
import qs.config
import qs.services
Item {
SettingsPage {
id: root
objectName: "home-phone-page"
title: "Home & Phone"
lede: "Choose what appears in Control Center and keep phone continuity close at hand."
property string lightQuery: searchInput.text.trim().toLowerCase()
@@ -41,36 +43,6 @@ Item {
return "Home Assistant is unavailable";
}
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text {
text: "Home & Phone"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Text {
text: "Choose what appears in Control Center and keep phone continuity close at hand."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
bottomPadding: 6
}
SettingsCard {
title: "Home Assistant"
subtitle: root.homeStatus()
@@ -311,6 +283,4 @@ Item {
}
}
}
}
}
}
@@ -2,69 +2,90 @@ import QtQuick
import qs.config
import qs.services
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Notifications & Focus"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Control interruptions without losing useful history."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsPage {
title: "Notifications & Focus"
lede: "Control interruptions without losing useful history."
SettingsCard {
title: "Notifications"
SettingRow {
label: "Do Not Disturb"
detail: "Keep notifications in the center but suppress banners"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: Notifs.doNotDisturb; onToggled: value => Notifs.doNotDisturb = value }
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value
}
SettingRow { label: "Notification history"; detail: "Live notifications retained by Panama"; value: `${Notifs.history.length} items` }
SettingRow {
}
TextRow {
label: "Notification history"
detail: "Live notifications retained by Panama"
value: `${Notifs.history.length} items`
}
ActionRow {
label: "Clear notification history"
detail: "Dismiss every item currently in the notification center"
divider: false
controlWidth: 94
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Clear all"; enabled: Notifs.history.length > 0; onClicked: Notifs.dismissAll() }
action: "Clear all"
enabled: Notifs.history.length > 0
onTriggered: Notifs.dismissAll()
}
}
SettingsCard {
title: "Banner behavior"
SliderRow { setting: "notificationTimeoutMs" }
SliderRow {
setting: "notificationTimeoutCriticalMs"
zeroLabel: "Never"
}
SliderRow { setting: "notificationHistoryLimit" }
SliderRow { setting: "maxVisibleToasts"; divider: false }
}
SettingsCard {
title: "Focus sessions"
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
SettingRow {
id: durationRow
label: "Default duration"
detail: "Used by Super+Shift+F and Quick Settings"
controlWidth: 264
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Repeater {
model: [25, 45, 60, 90]
SettingsButton {
required property int modelData
text: `${modelData}m`
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
onClicked: DesktopPreferences.set("focusDurationMinutes", modelData)
onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
}
}
}
}
SettingRow {
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
divider: false
controlWidth: 120
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
@@ -74,6 +95,4 @@ Item {
}
}
}
}
}
}
@@ -0,0 +1,109 @@
# Panama Settings
The control centre for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
such rather than half-reimplemented.
## Adding a setting
One schema entry. That is the whole job.
```qml
// config/PreferenceSchema.qml
{
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
unit: "px", group: "effects",
label: "Blur radius",
detail: "Larger is softer and costs more frame time",
hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
}
```
```qml
// the page
SliderRow { setting: "blurSize" }
```
Persistence, validation, clamping, reset, search indexing, and — with a `hypr`
block — live application to the compositor and the startup replay all derive
from that entry. There is nothing else to register.
If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
file.
## The rows
| Component | For |
|---|---|
| `SettingsPage` | The page scaffold: title, lede, optional pinned `header` |
| `ToggleRow { setting }` | A boolean |
| `SliderRow { setting }` | A number; `zeroLabel` renders 0 as "Never"/"Instant"/"None" |
| `ChoiceRow { setting }` | An enum, as a segmented control |
| `ActionRow` | A button: opens a GNOME panel, runs a one-shot |
| `TextRow` | A genuinely read-only fact |
`TextRow` is for facts, not for settings that were merely expensive to wire.
Before Stage 3 more than half of all rows were static text standing in for
controls; that is the failure this vocabulary exists to prevent.
Rows write through `SystemSettings.commitPreference(key, value)`, which routes
compositor-backed keys through apply-and-verify and local keys straight to the
store. A row never needs to know which kind it holds.
## Things that will bite you
**`readAs` describes the answer, not the setting.** `hyprctl getoption` returns
the value in a different JSON field per type — `int`, `bool`, `float`, `str`,
and `css` for gaps (a four-value box). Declaring the wrong one does not fail
loudly: it makes every write to that key look *rejected*, and the user sees an
error for a change that worked. `tests/quickshell/schema-hypr-shape-contract.sh`
asks the compositor for the real shape of every mapped option.
**Never trust an exit code from `hyprctl`.** `keyword` refuses to work on a
Lua-configured Hyprland, prints the refusal to stdout, and exits 0. `eval` exits
0 on syntax and runtime errors too. The only trustworthy signal that a write
landed is reading the value back.
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
and it ranges from a half-screen split to the full display. `SliderRow` stacks
its control under the label below 520px. Test narrow.
**Binding an anchor to `undefined` does not reliably release it.** Switching
layouts that way left a slider anchored to both edges with the label squeezed
into what was left. Position explicitly instead.
**Inside a `SettingsCard`, `parent` is the card's internal Column.** So
`parent.modelData` in a nested `Repeater` is undefined and the rows silently
never appear — you get a card with a heading and nothing under it. Address the
outer model through an explicit `id`.
**A `TapHandler` declared as a child of `SettingRow` lands in the trailing
slot**, because that is the row's default property, so only the right-hand edge
becomes clickable. Use `activatable: true` with `onActivated` for a whole-row
target.
**A copy of the Quickshell config shares the live shell's ID.** Quickshell
derives the Shell ID from config *content*, not path, so
`cp -a config/dot/quickshell $tmp && qs -p $tmp kill` kills the running
desktop, and `qs -p $tmp ipc call …` can drive it. Harnesses that point at a
single distinct `.qml` file are safe; copying the whole directory is not.
## Where state lives
| File | Holds |
|---|---|
| `~/.config/panama/settings.json` | Everything in the schema. Read by the shell *and* by `hypr/prefs.lua` |
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favourites and aliases |
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
skipped one would be worse than having no reset, because nothing would say so.
## Not stored by Panama
Timezone and network time are read from and written to `timedatectl` directly.
They belong to the machine and are shared with sessions that never see Panama's
file; storing a copy would create a second answer to a question the system
already answers.
@@ -2,9 +2,12 @@ import QtQuick
import qs.config
import qs.services
Item {
SettingsPage {
id: root
title: "Screen Intelligence"
lede: "Turn text and codes on screen into content you can use."
Component.onCompleted: ScreenIntelligence.refresh()
Timer {
@@ -13,35 +16,6 @@ Item {
onTriggered: Capture.openIntelligence()
}
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text {
text: "Screen Intelligence"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Text {
text: "Turn text and codes on screen into content you can use."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
bottomPadding: 6
}
SettingsCard {
title: "Read anything on screen"
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
@@ -52,6 +26,7 @@ Item {
detail: "Copy, search, translate, or open detected links"
controlWidth: 160
divider: false
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
@@ -66,21 +41,30 @@ Item {
}
}
SettingsCard {
title: "Capture preferences"
subtitle: "Choose where captures go and how recordings are encoded."
ChoiceRow { setting: "screenshotDir" }
ChoiceRow { setting: "recordingDir" }
ChoiceRow { setting: "recorderArgs"; divider: false }
}
SettingsCard {
title: "Local recognition"
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
SettingRow {
TextRow {
label: "Text recognition"
detail: "Tesseract with the English language model"
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
}
SettingRow {
TextRow {
label: "QR & barcodes"
detail: "ZBar recognizes codes alongside ordinary text"
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
}
SettingRow {
TextRow {
label: "Privacy"
detail: "Only Search, Translate, and Open send the selected result to another application or service"
value: "Local first"
@@ -90,14 +74,15 @@ Item {
SettingsCard {
title: "Shortcut"
SettingRow {
TextRow {
label: "Read a screen selection"
detail: "Also available as Read in the Print-screen picker"
value: "Super + Shift + S"
controlWidth: 190
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
}
SettingRow {
TextRow {
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
label: "Install recognition engines"
detail: "sudo dnf install -y tesseract zbar"
@@ -105,6 +90,4 @@ Item {
divider: false
}
}
}
}
}
@@ -2,89 +2,132 @@ import QtQuick
import qs.config
import qs.services
Item {
function status(active: bool): string { return active ? "Running" : "Stopped"; }
SettingsPage {
title: "Startup & Services"
lede: "A clear view of the background tools that make the desktop feel complete."
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
function status(active: bool): string {
return active ? "Running" : "Stopped";
}
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Item {
width: parent.width
implicitHeight: refresh.implicitHeight
Row {
width: parent.width
Text { width: parent.width - refresh.width; text: "Startup & Services"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
SettingsButton { id: refresh; text: SystemSettings.busy ? "Refreshing…" : "Refresh"; enabled: !SystemSettings.busy; onClicked: SystemSettings.refresh() }
SettingsButton {
id: refresh
anchors.right: parent.right
text: SystemSettings.busy ? "Refreshing…" : "Refresh"
enabled: !SystemSettings.busy
onClicked: SystemSettings.refresh()
}
}
Text { text: "A clear view of the background tools that make the desktop feel complete."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsCard {
title: "Your services"
SettingRow {
label: "Nextcloud"
detail: "File synchronization and tray status"
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.nextcloudActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("nextcloud") }
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.nextcloudActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("nextcloud")
}
}
}
SettingRow {
label: "RustDesk"
detail: "Remote access through the enabled system service"
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.rustdeskActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("rustdesk") }
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.rustdeskActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("rustdesk")
}
}
}
SettingRow {
label: "KDE Connect"
detail: "Phone pairing, clipboard, files, and remote controls"
divider: false
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.kdeconnectActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("kdeconnect") }
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.kdeconnectActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("kdeconnect")
}
}
}
}
SettingsCard {
title: "Desktop foundation"
SettingRow { label: "Hyprpaper"; detail: "Wallpaper service"; value: status(SystemSettings.hyprpaperActive) }
SettingRow { label: "Hypridle"; detail: "Idle and lock policy"; value: status(SystemSettings.hypridleActive) }
SettingRow { label: "Vicinae"; detail: "Spotlight-style launcher daemon"; value: status(SystemSettings.vicinaeActive); divider: false }
TextRow {
label: "Hyprpaper"
detail: "Wallpaper service"
value: status(SystemSettings.hyprpaperActive)
}
TextRow {
label: "Hypridle"
detail: "Idle and lock policy"
value: status(SystemSettings.hypridleActive)
}
TextRow {
label: "Vicinae"
detail: "Spotlight-style launcher daemon"
value: status(SystemSettings.vicinaeActive)
divider: false
}
}
SettingsCard {
title: "Fedora system settings"
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
SettingRow {
ActionRow {
label: "Network, Bluetooth, printers, users, and accounts"
detail: "GNOME Settings remains searchable from the launcher too"
divider: false
controlWidth: 122
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open network"; onClicked: SystemSettings.openGnomePanel("network") }
}
}
action: "Open network"
onTriggered: SystemSettings.openGnomePanel("network")
}
}
}
@@ -11,6 +11,11 @@ Rectangle {
readonly property var results: SettingsSearch.search(root.query)
onQueryChanged: {
if (sidebarScroll)
sidebarScroll.contentY = 0;
}
function pageLabel(page: string): string {
const found = root.destinations.find(item => item.page === page);
return found ? found.label : "Settings";
@@ -40,8 +45,15 @@ Rectangle {
border.width: 0
Column {
anchors.fill: parent
anchors.margins: 18
id: sidebarHeader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 18
anchors.rightMargin: 18
anchors.topMargin: 18
height: implicitHeight
spacing: 12
Text {
@@ -103,12 +115,35 @@ Rectangle {
onClicked: searchInput.forceActiveFocus()
}
}
}
Flickable {
id: sidebarScroll
anchors.left: parent.left
anchors.right: parent.right
anchors.top: sidebarHeader.bottom
anchors.bottom: healthFooter.top
anchors.leftMargin: 18
anchors.rightMargin: 18
anchors.topMargin: 12
anchors.bottomMargin: 12
contentWidth: width
contentHeight: scrollContent.implicitHeight
flickableDirection: Flickable.VerticalFlick
boundsBehavior: Flickable.StopAtBounds
clip: true
// ── Search results ──────────────────────────────────────────────────
// Typing searches the settings themselves, not the twelve page names.
// "gaps", "wallpaper", and "screenshot" all used to find nothing, which
// made the app feel far smaller than it is.
Column {
id: scrollContent
width: sidebarScroll.width
// ── Search results ──────────────────────────────────────────────
// Typing searches the settings themselves, not page names.
Column {
id: searchResults
width: parent.width
spacing: 3
visible: root.query !== ""
@@ -183,6 +218,8 @@ Rectangle {
}
Column {
id: navigationList
width: parent.width
spacing: 4
visible: root.query === ""
@@ -251,8 +288,11 @@ Rectangle {
}
}
}
}
Rectangle {
id: healthFooter
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
@@ -4,50 +4,61 @@ import qs.config
import qs.services
import qs.modules.quicksettings
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Sound"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Live PipeWire output, input, and device selection."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
SettingsPage {
title: "Sound"
lede: "Live PipeWire output, input, and device selection."
SettingsCard {
title: "Output"
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device"
AudioSlider { 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; output: true; maxHeight: 190 }
AudioSlider {
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
output: true
maxHeight: 190
}
}
SettingsCard {
title: "Input"
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device"
AudioSlider { 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; output: false; maxHeight: 160 }
AudioSlider {
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
output: false
maxHeight: 160
}
}
SettingsCard {
title: "Advanced sound"
SettingRow {
ActionRow {
label: "Application volumes and profiles"
detail: "Open Fedora's complete sound panel"
divider: false
controlWidth: 104
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("sound") }
}
}
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("sound")
}
}
}
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Read and update freedesktop defaults for Panama's settings page."""
from __future__ import annotations
import ast
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
ROLE_TARGETS = {
"browser": ("settings", "default-web-browser"),
"mail": ("mime", "x-scheme-handler/mailto"),
"files": ("mime", "inode/directory"),
"terminal": ("mime", "x-scheme-handler/terminal"),
"music": ("mime", "audio/mpeg"),
"images": ("mime", "image/png"),
"video": ("mime", "video/mp4"),
}
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
class BoundaryError(RuntimeError):
"""A user-visible validation or command failure."""
def xdg_data_roots() -> list[Path]:
data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share"))
data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
def discovered_desktop_ids() -> set[str]:
desktop_ids: set[str] = set()
for root in xdg_data_roots():
applications = root / "applications"
if not applications.is_dir():
continue
for path in applications.rglob("*.desktop"):
if not path.is_file():
continue
relative = path.relative_to(applications)
desktop_ids.add("-".join(relative.parts))
return desktop_ids
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
if not DESKTOP_ID.fullmatch(desktop_id) or desktop_id not in discovered:
raise BoundaryError("That application is not available.")
def run(command: list[str]) -> str:
completed = subprocess.run(command, check=False, capture_output=True, text=True)
if completed.returncode != 0:
detail = completed.stderr.strip()
raise BoundaryError(detail or "The system default could not be updated.")
return completed.stdout.strip()
def query_handlers() -> dict[str, str]:
handlers: dict[str, str] = {}
for role, (kind, target) in ROLE_TARGETS.items():
command = (
["xdg-settings", "get", target]
if kind == "settings"
else ["xdg-mime", "query", "default", target]
)
output = run(command)
handlers[role] = output.splitlines()[0] if output else ""
return handlers
def parse_desktop_entry(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
section = ""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as error:
raise BoundaryError(f"Could not read {path.name}.") from error
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
section = stripped[1:-1]
continue
if section != "Desktop Entry" or "=" not in line or stripped.startswith("#"):
continue
key, value = line.split("=", 1)
values.setdefault(key.strip(), value.strip())
return values
def autostart_directory() -> Path:
config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return config_home / "autostart"
def user_autostart_entries() -> list[dict[str, object]]:
directory = autostart_directory()
if not directory.is_dir():
return []
entries: list[dict[str, object]] = []
for path in directory.glob("*.desktop"):
if path.is_symlink() or not path.is_file():
continue
values = parse_desktop_entry(path)
entries.append(
{
"id": path.name,
"name": values.get("Name", path.stem),
"enabled": values.get("Hidden", "false").lower() != "true",
}
)
return sorted(entries, key=lambda entry: (str(entry["name"]).casefold(), str(entry["id"])))
def hypr_autostart_path() -> Path:
override = os.environ.get("PANAMA_HYPR_AUTOSTART")
if override:
return Path(override)
return Path(__file__).resolve().parents[2] / "hypr" / "autostart.lua"
def lua_autostart_entries() -> list[dict[str, object]]:
path = hypr_autostart_path()
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return []
commands: list[str] = []
in_start_handler = False
for line in lines:
if not in_start_handler:
in_start_handler = bool(re.search(r'hl\.on\(\s*"hyprland\.start"', line))
continue
if line.strip() == "end)":
break
match = EXEC_CMD.search(line)
if match:
try:
commands.append(ast.literal_eval(match.group(1)))
except (SyntaxError, ValueError):
continue
return [
{
"id": f"hyprland:{index}",
"name": command.split()[0].rsplit("/", 1)[-1],
"command": command,
"enabled": True,
"readOnly": True,
"source": "config/dot/hypr/autostart.lua",
}
for index, command in enumerate(commands, start=1)
]
def snapshot() -> dict[str, object]:
return {
"handlers": query_handlers(),
"autostartEntries": user_autostart_entries(),
"luaAutostartEntries": lua_autostart_entries(),
}
def set_default(role: str, desktop_id: str) -> None:
target = ROLE_TARGETS.get(role)
if target is None:
raise BoundaryError("That default application role is not supported.")
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
kind, setting = target
command = (
["xdg-settings", "set", setting, desktop_id]
if kind == "settings"
else ["xdg-mime", "default", desktop_id, setting]
)
run(command)
def update_hidden(path: Path, *, hidden: bool) -> None:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That autostart entry could not be read.") from error
lines = original.splitlines()
output: list[str] = []
section = ""
found_section = False
wrote_hidden = False
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
if section == "Desktop Entry" and not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
wrote_hidden = True
section = stripped[1:-1]
found_section = found_section or section == "Desktop Entry"
output.append(line)
continue
if section == "Desktop Entry" and line.split("=", 1)[0].strip() == "Hidden":
if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
wrote_hidden = True
continue
output.append(line)
if not found_section:
raise BoundaryError("That autostart entry is not a desktop file.")
if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
mode = path.stat().st_mode
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
) as temporary:
temporary.write("\n".join(output) + "\n")
temporary.flush()
os.fsync(temporary.fileno())
temporary_path = Path(temporary.name)
temporary_path.chmod(mode)
os.replace(temporary_path, path)
except OSError as error:
if "temporary_path" in locals():
temporary_path.unlink(missing_ok=True)
raise BoundaryError("That autostart entry could not be updated.") from error
def set_autostart(desktop_id: str, enabled_text: str) -> None:
if enabled_text not in {"true", "false"}:
raise BoundaryError("Autostart state must be true or false.")
if not DESKTOP_ID.fullmatch(desktop_id):
raise BoundaryError("That autostart entry is not available.")
directory = autostart_directory()
path = directory / desktop_id
try:
resolved_directory = directory.resolve(strict=True)
resolved_path = path.resolve(strict=True)
except OSError as error:
raise BoundaryError("That autostart entry is not available.") from error
if path.is_symlink() or resolved_path.parent != resolved_directory or not resolved_path.is_file():
raise BoundaryError("That autostart entry is not available.")
update_hidden(resolved_path, hidden=enabled_text == "false")
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
elif len(arguments) == 3 and arguments[0] == "set-default":
set_default(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-autostart":
set_autostart(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
"set-autostart DESKTOP_ID true|false"
)
except BoundaryError as error:
print(str(error), file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
@@ -1,90 +1,625 @@
#!/usr/bin/env bash
#!/usr/bin/env python3
# Snapshots of the Panama settings store.
#
# The whole desktop configuration is one JSON file, which makes a backup a copy
# and a restore an overwrite. That is worth exposing: the settings app now
# changes real things -- compositor geometry, idle timeouts, the dock -- and
# being able to get back to a known-good state without hunting through git is
# the difference between experimenting freely and being cautious.
#
# panama-settings-backup save snapshot the current settings
# panama-settings-backup list JSON list of snapshots, newest first
# panama-settings-backup restore <name> replace settings with a snapshot
#
# Snapshots are validated as JSON on the way in and on the way out, so a
# truncated file can never be restored over a working configuration.
#
# Names carry milliseconds. At one-second resolution a save followed promptly by
# a restore produced the same filename twice, and the restore's own safety
# snapshot overwrote the very file it was about to read.
"""Crash-safe snapshots of Panama's desktop and Home preference stores.
set -euo pipefail
A restore is a two-file transaction. Its fixed journal and artifacts live at
`$XDG_STATE_HOME/panama/transactions/settings-restore`; they contain no
caller-provided paths. The journal is fsynced before either destination changes
and is removed only after both replacements are durable. Every invocation
recovers an incomplete transaction before doing any other work.
"""
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
backup_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama/backups"
keep=15
from __future__ import annotations
fail() {
printf '%s\n' "$1" >&2
exit 1
}
import json
import os
import re
import stat
import sys
import tempfile
import time
import fcntl
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator, NoReturn
case "${1:-list}" in
save)
[[ -r "$settings" ]] || fail "No settings file to back up."
jq -e . "$settings" >/dev/null 2>&1 || fail "The current settings file is not valid JSON."
mkdir -p "$backup_dir"
stamp="$(date +%Y%m%d-%H%M%S%3N)"
cp "$settings" "$backup_dir/settings-$stamp.json"
# Keep the most recent few. A snapshot per change would otherwise grow
# without bound in a directory nobody ever looks at.
ls -1t "$backup_dir"/settings-*.json 2>/dev/null | tail -n +$((keep + 1)) | while read -r old; do
rm -f "$old"
done
printf '{"saved":"settings-%s.json"}\n' "$stamp"
;;
list)
mkdir -p "$backup_dir"
first=true
printf '['
for file in $(ls -1t "$backup_dir"/settings-*.json 2>/dev/null); do
name="$(basename "$file")"
# settings-20260818-004512.json -> 2026-08-18 00:45
raw="${name#settings-}"; raw="${raw%.json}"
pretty="${raw:0:4}-${raw:4:2}-${raw:6:2} ${raw:9:2}:${raw:11:2}:${raw:13:2}"
keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)"
[[ "$first" == true ]] || printf ','
first=false
printf '{"name":"%s","when":"%s","keys":%s}' "$name" "$pretty" "$keys"
done
printf ']\n'
;;
HOME = Path(os.environ.get("HOME", str(Path.home())))
CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
STATE_ROOT = Path(os.environ.get("XDG_STATE_HOME", str(HOME / ".local/state")))
SETTINGS = CONFIG_ROOT / "panama/settings.json"
HOME_STATE = STATE_ROOT / "panama/panama-home.json"
BACKUP_DIR = STATE_ROOT / "panama/backups"
TRANSACTION_PARENT = STATE_ROOT / "panama/transactions"
TRANSACTION_DIR = TRANSACTION_PARENT / "settings-restore"
JOURNAL = TRANSACTION_DIR / "journal.json"
LOCK_FILE = TRANSACTION_PARENT / "settings-backup.lock"
KEEP = 15
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
restore)
name="${2:-}"
[[ -n "$name" ]] || fail "Which snapshot?"
# Only a bare filename from the backup directory, so a caller cannot
# walk out of it with a path.
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "Not a snapshot name."
source_file="$backup_dir/$name"
[[ -r "$source_file" ]] || fail "That snapshot is missing."
jq -e . "$source_file" >/dev/null 2>&1 || fail "That snapshot is not valid JSON."
# Snapshot what is being replaced, so restore is itself undoable.
if [[ -r "$settings" ]] && jq -e . "$settings" >/dev/null 2>&1; then
mkdir -p "$backup_dir"
cp "$settings" "$backup_dir/settings-$(date +%Y%m%d-%H%M%S%3N).json"
fi
class BackupError(RuntimeError):
pass
mkdir -p "$(dirname "$settings")"
cp "$source_file" "$settings.tmp"
mv "$settings.tmp" "$settings"
printf '{"restored":"%s"}\n' "$name"
;;
*)
fail "usage: panama-settings-backup [save|list|restore <name>]"
;;
esac
def fail(message: str) -> NoReturn:
raise BackupError(message)
def fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def ensure_directory(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
if path.is_symlink() or not path.is_dir():
fail(f"{path} is not a safe directory.")
@contextmanager
def process_lock() -> Iterator[None]:
ensure_directory(TRANSACTION_PARENT)
if LOCK_FILE.is_symlink():
fail("The settings transaction lock is a symbolic link.")
descriptor = os.open(
LOCK_FILE,
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
0o600,
)
try:
os.fchmod(descriptor, 0o600)
fcntl.flock(descriptor, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(descriptor, fcntl.LOCK_UN)
os.close(descriptor)
def is_present(path: Path) -> bool:
return path.exists() or path.is_symlink()
def require_regular(path: Path, label: str) -> None:
if path.is_symlink():
fail(f"{label} is a symbolic link and cannot be used safely.")
try:
mode = path.stat().st_mode
except FileNotFoundError:
fail(f"{label} is missing.")
if not stat.S_ISREG(mode):
fail(f"{label} is not a regular file.")
def read_json(path: Path, label: str) -> dict[str, Any]:
require_regular(path, label)
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise BackupError(f"{label} is not valid JSON.") from error
if not isinstance(value, dict):
fail(f"{label} is not a JSON object.")
return value
def valid_home(value: Any) -> bool:
if not isinstance(value, dict):
return False
initialized = value.get("initialized")
favorites = value.get("favorites")
if not isinstance(initialized, bool) or not isinstance(favorites, list):
return False
if not initialized and favorites:
return False
seen: set[str] = set()
for favorite in favorites:
if not isinstance(favorite, dict):
return False
entity_id = favorite.get("id")
alias = favorite.get("alias")
if (
not isinstance(entity_id, str)
or ENTITY_RE.fullmatch(entity_id) is None
or not isinstance(alias, str)
or entity_id in seen
):
return False
seen.add(entity_id)
return True
def validate_home(value: Any, label: str) -> dict[str, Any]:
if not valid_home(value):
fail(f"{label} does not contain valid Home favourites.")
return value
def json_bytes(value: Any) -> bytes:
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
def atomic_write_bytes(path: Path, content: bytes) -> None:
ensure_directory(path.parent)
if path.is_symlink():
fail(f"{path} is a symbolic link and cannot be replaced safely.")
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
fsync_directory(path.parent)
finally:
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
def atomic_write_json(path: Path, value: Any) -> None:
atomic_write_bytes(path, json_bytes(value))
def durable_remove(path: Path) -> None:
if path.exists() or path.is_symlink():
path.unlink()
fsync_directory(path.parent)
def transaction_path(name: str) -> Path:
if name not in {
"journal.json",
"desktop.old",
"desktop.new",
"home.old",
"home.new",
}:
fail("The restore transaction contains an unknown artifact name.")
path = TRANSACTION_DIR / name
resolved_parent = path.parent.resolve(strict=False)
if resolved_parent != TRANSACTION_DIR.resolve(strict=False):
fail("The restore transaction escaped its contained state directory.")
return path
def clean_transaction_artifacts() -> None:
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
return
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
fail("The restore transaction path is not a safe directory.")
for child in list(TRANSACTION_DIR.iterdir()):
if child.name not in {
"journal.json",
"desktop.old",
"desktop.new",
"home.old",
"home.new",
} and not child.name.startswith(".journal.json."):
fail("The restore transaction directory contains an unknown artifact.")
if child.is_dir() and not child.is_symlink():
fail("The restore transaction contains an unexpected directory.")
child.unlink()
fsync_directory(TRANSACTION_DIR)
TRANSACTION_DIR.rmdir()
fsync_directory(TRANSACTION_PARENT)
def clean_stale_atomic_files() -> None:
locations = (
(SETTINGS.parent, (".settings.json.",)),
(HOME_STATE.parent, (".panama-home.json.",)),
(BACKUP_DIR, (".settings-",)),
)
for directory, prefixes in locations:
if not directory.exists():
continue
if directory.is_symlink() or not directory.is_dir():
fail(f"{directory} is not a safe directory.")
changed = False
for child in directory.iterdir():
if not any(child.name.startswith(prefix) for prefix in prefixes):
continue
# Only Panama's hidden atomic-write names are eligible. A matching
# directory is unexpected and is never recursively removed.
if child.is_dir() and not child.is_symlink():
fail("A stale settings temporary path is an unexpected directory.")
child.unlink()
changed = True
if changed:
fsync_directory(directory)
def validate_journal_side(value: Any) -> dict[str, bool]:
if not isinstance(value, dict):
fail("The restore journal is malformed.")
if set(value) != {"touch", "oldPresent", "newPresent"}:
fail("The restore journal is malformed.")
if not all(isinstance(value[key], bool) for key in value):
fail("The restore journal is malformed.")
return value
def read_journal() -> dict[str, Any]:
value = read_json(JOURNAL, "The restore journal")
if set(value) != {"version", "desktop", "home"} or value.get("version") != 1:
fail("The restore journal uses an unsupported format.")
return {
"version": 1,
"desktop": validate_journal_side(value.get("desktop")),
"home": validate_journal_side(value.get("home")),
}
def target_for(store: str) -> Path:
if store == "desktop":
return SETTINGS
if store == "home":
return HOME_STATE
fail("The restore journal names an unknown store.")
def apply_artifact(store: str, generation: str, present: bool) -> None:
target = target_for(store)
if present:
artifact = transaction_path(f"{store}.{generation}")
require_regular(artifact, "A restore transaction artifact")
atomic_write_bytes(target, artifact.read_bytes())
else:
ensure_directory(target.parent)
if target.is_symlink():
fail(f"{target} is a symbolic link and cannot be replaced safely.")
durable_remove(target)
def recover_transaction() -> None:
ensure_directory(TRANSACTION_PARENT)
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
return
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
fail("The restore transaction path is not a safe directory.")
if not JOURNAL.exists() and not JOURNAL.is_symlink():
clean_transaction_artifacts()
return
journal = read_journal()
for store in ("desktop", "home"):
side = journal[store]
if side["touch"]:
apply_artifact(store, "old", side["oldPresent"])
# Journal absence is the durable commit marker for recovery too. If a
# second power loss occurs above, the journal remains and recovery retries.
durable_remove(JOURNAL)
clean_transaction_artifacts()
def is_v2_side(value: Any) -> bool:
return (
isinstance(value, dict)
and isinstance(value.get("present"), bool)
and (not value["present"] or isinstance(value.get("data"), dict))
)
def is_v2_envelope(value: Any) -> bool:
return (
isinstance(value, dict)
and value.get("version") == 2
and is_v2_side(value.get("desktop"))
and is_v2_side(value.get("home"))
)
def validate_snapshot(value: dict[str, Any]) -> tuple[str, dict[str, Any]]:
if not is_v2_envelope(value):
return "legacy", value
if value["home"]["present"]:
validate_home(value["home"]["data"], "That snapshot")
return "versioned", value
def current_store(path: Path, label: str, *, home_store: bool = False) -> tuple[bool, Any]:
if not is_present(path):
return False, None
value = read_json(path, label)
if home_store:
validate_home(value, label)
return True, value
def next_snapshot_path() -> Path:
ensure_directory(BACKUP_DIR)
while True:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S%f")[:18]
candidate = BACKUP_DIR / f"settings-{stamp}.json"
if not is_present(candidate):
return candidate
time.sleep(0.002)
def prune_snapshots() -> None:
snapshots = sorted(
(
path
for path in BACKUP_DIR.iterdir()
if SNAPSHOT_RE.fullmatch(path.name)
and path.is_file()
and not path.is_symlink()
),
key=lambda path: path.stat().st_mtime_ns,
reverse=True,
)
for old in snapshots[KEEP:]:
durable_remove(old)
def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
try:
desktop_present, desktop = current_store(
SETTINGS, "The current settings file"
)
home_present, home = current_store(
HOME_STATE, "The current Home state file", home_store=True
)
except BackupError:
if validate:
raise
return None
if not desktop_present and not home_present:
if require_any:
fail("No Panama settings exist to back up.")
return None
envelope: dict[str, Any] = {
"version": 2,
"desktop": {"present": desktop_present},
"home": {"present": home_present},
}
if desktop_present:
envelope["desktop"]["data"] = desktop
if home_present:
envelope["home"]["data"] = home
destination = next_snapshot_path()
atomic_write_json(destination, envelope)
prune_snapshots()
return destination
def snapshot_source(name: str) -> Path:
if SNAPSHOT_RE.fullmatch(name) is None:
fail("Not a snapshot name.")
ensure_directory(BACKUP_DIR)
candidate = BACKUP_DIR / name
require_regular(candidate, "That snapshot")
if candidate.resolve(strict=True).parent != BACKUP_DIR.resolve(strict=True):
fail("That snapshot is outside the backup directory.")
return candidate
def stage_artifact(name: str, content: bytes) -> None:
path = transaction_path(name)
if path.exists() or path.is_symlink():
fail("A stale restore transaction artifact was not recovered.")
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
finally:
# fdopen owns the descriptor after construction.
pass
fsync_directory(TRANSACTION_DIR)
def capture_old(store: str, target: Path) -> bool:
if not is_present(target):
return False
require_regular(target, f"The current {store} settings file")
stage_artifact(f"{store}.old", target.read_bytes())
return True
def prepare_transaction(
desktop_present: bool,
desktop_data: dict[str, Any] | None,
home_touch: bool,
home_present: bool,
home_data: dict[str, Any] | None,
) -> dict[str, Any]:
# Cleanup is active before the first artifact is created. A pre-journal
# error removes every staged/rollback file; a process death is recovered as
# stale preparation by the next invocation.
ensure_directory(TRANSACTION_PARENT)
clean_transaction_artifacts()
ensure_directory(TRANSACTION_DIR)
fsync_directory(TRANSACTION_PARENT)
try:
desktop_old = capture_old("desktop", SETTINGS)
if desktop_present:
stage_artifact("desktop.new", json_bytes(desktop_data))
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_FAIL") == "after-desktop-stage":
fail("Injected failure after desktop staging.")
home_old = capture_old("home", HOME_STATE) if home_touch else False
if home_touch and home_present:
stage_artifact("home.new", json_bytes(home_data))
journal = {
"version": 1,
"desktop": {
"touch": True,
"oldPresent": desktop_old,
"newPresent": desktop_present,
},
"home": {
"touch": home_touch,
"oldPresent": home_old,
"newPresent": home_present,
},
}
atomic_write_json(JOURNAL, journal)
return journal
except BaseException:
# SIGKILL/os._exit bypass this block by design; the next invocation
# cleans a pre-journal directory or recovers a journalled transaction.
if not JOURNAL.exists() and not JOURNAL.is_symlink():
clean_transaction_artifacts()
raise
def commit_restore(journal: dict[str, Any]) -> None:
try:
desktop = journal["desktop"]
apply_artifact("desktop", "new", desktop["newPresent"])
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_CRASH") == "after-desktop":
os._exit(86)
home = journal["home"]
if home["touch"]:
apply_artifact("home", "new", home["newPresent"])
# Both targets and their parent directories are durable. Removing and
# fsyncing the journal is the transaction's commit record.
durable_remove(JOURNAL)
clean_transaction_artifacts()
except BaseException:
# Ordinary failures roll back immediately. Process death leaves the
# journal in place and takes this same path on the next invocation.
recover_transaction()
raise
def write_live_home(text: str) -> None:
try:
value = json.loads(text)
except json.JSONDecodeError as error:
raise BackupError("The live Home state is not valid JSON.") from error
validate_home(value, "The live Home state")
atomic_write_json(HOME_STATE, value)
def command_save(arguments: list[str]) -> None:
if arguments:
write_live_home(arguments[0])
destination = save_snapshot(require_any=True, validate=True)
assert destination is not None
print(json.dumps({"saved": destination.name}, separators=(",", ":")))
def snapshot_files() -> list[Path]:
ensure_directory(BACKUP_DIR)
return sorted(
(
path
for path in BACKUP_DIR.iterdir()
if SNAPSHOT_RE.fullmatch(path.name)
and path.is_file()
and not path.is_symlink()
),
key=lambda path: path.stat().st_mtime_ns,
reverse=True,
)
def command_list() -> None:
output: list[dict[str, Any]] = []
for path in snapshot_files():
try:
value = read_json(path, "A snapshot")
if is_v2_envelope(value):
desktop = value["desktop"]
keys = len(desktop["data"]) if desktop["present"] else 0
else:
keys = len(value)
except BackupError:
keys = 0
raw = path.name.removeprefix("settings-").removesuffix(".json")
pretty = (
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]} "
f"{raw[9:11]}:{raw[11:13]}:{raw[13:15]}"
)
output.append({"name": path.name, "when": pretty, "keys": keys})
print(json.dumps(output, separators=(",", ":")))
def command_restore(arguments: list[str]) -> None:
if not arguments:
fail("Which snapshot?")
name = arguments[0]
source = snapshot_source(name)
snapshot = read_json(source, "That snapshot")
snapshot_format, value = validate_snapshot(snapshot)
if snapshot_format == "versioned":
desktop_present = value["desktop"]["present"]
desktop_data = value["desktop"].get("data")
home_touch = True
home_present = value["home"]["present"]
home_data = value["home"].get("data")
else:
desktop_present = True
desktop_data = value
home_touch = False
home_present = False
home_data = None
# Restoring remains undoable, but a corrupt current file must not prevent a
# known-good snapshot from recovering the desktop.
save_snapshot(require_any=False, validate=False)
journal = prepare_transaction(
desktop_present,
desktop_data,
home_touch,
home_present,
home_data,
)
commit_restore(journal)
if not home_touch:
home_result: dict[str, Any] = {"preserve": True}
elif home_present:
home_result = {"present": True, "data": home_data}
else:
home_result = {"present": False}
print(
json.dumps(
{"restored": name, "home": home_result},
separators=(",", ":"),
)
)
def main() -> None:
with process_lock():
clean_stale_atomic_files()
recover_transaction()
command = sys.argv[1] if len(sys.argv) > 1 else "list"
arguments = sys.argv[2:]
if command == "save":
command_save(arguments)
elif command == "list":
command_list()
elif command == "restore":
command_restore(arguments)
else:
fail("usage: panama-settings-backup [save|list|restore <name>]")
if __name__ == "__main__":
try:
main()
except BackupError as error:
print(str(error), file=sys.stderr)
raise SystemExit(1) from error
except OSError as error:
print("The settings backup could not access its state files.", file=sys.stderr)
raise SystemExit(1) from error
@@ -0,0 +1,102 @@
pragma Singleton
// Freedesktop default handlers and session autostart entries.
//
// The helper owns parsing and atomic desktop-file writes. This singleton keeps
// the QML side typed and reactive, and every external command crosses Process
// as an argument array.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property var handlers: ({})
property var autostartEntries: []
property var luaAutostartEntries: []
property string lastError: ""
readonly property bool busy: snapshotProcess.running || mutationProcess.running
readonly property string helper: Quickshell.shellDir + "/scripts/panama-default-apps"
readonly property var supportedRoles: ["browser", "mail", "files", "terminal", "music", "images", "video"]
Process {
id: snapshotProcess
stdout: StdioCollector {
onStreamFinished: root.applySnapshot(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Default applications could not be read. Try refreshing."
}
}
Process {
id: mutationProcess
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.lastError = "That application setting could not be changed."
return;
}
root.refresh();
}
}
function applySnapshot(text: string): void {
try {
const payload = JSON.parse(text);
root.handlers = payload.handlers ?? ({});
root.autostartEntries = payload.autostartEntries ?? [];
root.luaAutostartEntries = payload.luaAutostartEntries ?? [];
root.lastError = "";
} catch (error) {
root.lastError = "Default applications returned an unreadable response."
}
}
function refresh(): void {
if (root.busy)
return;
root.lastError = "";
snapshotProcess.exec([root.helper, "snapshot"]);
}
function knownDesktopId(desktopId: string): bool {
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$/.test(desktopId))
return false;
const entries = DesktopEntries.applications.values;
return entries.some(entry => {
const entryId = String(entry.id ?? "");
return entryId === desktopId || entryId + ".desktop" === desktopId;
});
}
function setDefault(role: string, desktopId: string): void {
if (root.busy)
return;
if (!root.supportedRoles.includes(role) || !root.knownDesktopId(desktopId)) {
root.lastError = "Choose an application from the available list."
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "set-default", role, desktopId]);
}
function setAutostart(desktopId: string, enabled: bool): void {
if (root.busy)
return;
const known = root.autostartEntries.some(entry => entry.id === desktopId);
if (!known) {
root.lastError = "That user autostart entry is no longer available."
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
}
Component.onCompleted: root.refresh()
}
+25 -3
View File
@@ -108,6 +108,17 @@ Singleton {
return "";
}
// A moved shortcut vacates its shipped chord, so another override may use
// it legitimately. Refuse to reset the first shortcut until that occupant
// moves away; otherwise Hyprland would receive two binds on one chord.
function overrideOccupantFor(chord: string, exceptShipped: string): string {
for (const shipped in root.overrides) {
if (shipped !== exceptShipped && root.overrides[shipped] === chord)
return shipped;
}
return "";
}
function rebind(currentChord: string, newChord: string): bool {
if (newChord === "" || newChord === currentChord)
return false;
@@ -133,14 +144,25 @@ Singleton {
return true;
}
function resetBind(currentChord: string): void {
function resetBind(currentChord: string): bool {
const shipped = root.shippedChordFor(currentChord);
if (shipped === currentChord)
return;
return true;
const occupant = root.overrideOccupantFor(shipped, shipped);
if (occupant !== "") {
root.lastError = `${shipped} is used by another rebound shortcut. Reset that shortcut first.`;
return false;
}
const next = Object.assign({}, root.overrides);
delete next[shipped];
DesktopPreferences.set("keybindOverrides", next);
if (!DesktopPreferences.set("keybindOverrides", next)) {
root.lastError = "That shortcut could not be reset.";
return false;
}
root.applyReload();
return true;
}
function resetAll(): void {
+154 -11
View File
@@ -1,14 +1,15 @@
pragma Singleton
// Snapshots of the settings store.
// Snapshots of Panama's durable settings stores.
//
// The whole desktop configuration is one JSON file, so a backup is a copy and a
// restore is an overwrite. Worth exposing now that the settings app changes
// real things -- compositor geometry, idle timeouts, the dock -- because being
// able to return to a known-good state is what makes experimenting feel safe.
// DesktopPreferences and HomePreferences use separate files. The helper owns
// the transactional filesystem boundary; this service owns settling the live
// desktop after those files have changed underneath it.
//
// Restoring rewrites the file underneath the running shell, so the store is
// told to re-read afterwards rather than waiting for the next change.
// HomePreferences intentionally keeps its FileView in Quickshell's private
// state directory while snapshots use Panama's canonical state directory. This
// service bridges them through HomePreferences' public mutation API, then soft
// reloads once external consumers have settled.
import Quickshell
import Quickshell.Io
@@ -24,7 +25,31 @@ Singleton {
property string lastError: ""
property string lastAction: ""
// Narrow service boundaries keep restore sequencing explicit and make it
// possible to verify the real handler in an isolated shell without ever
// calling the daily-driver compositor or wallpaper services.
property var readHomeState: function() {
return {
initialized: HomePreferences.initialized,
favorites: HomePreferences.favorites
};
}
property var resetHome: function() { HomePreferences.resetHomeDefaults(); }
property var initializeHome: function(ids) { HomePreferences.initialize(ids); }
property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
property var reloadDesktop: function() { DesktopPreferences.reload(); }
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; }
property var systemBusy: function() { return SystemSettings.busy; }
property var currentWallpaper: function() {
return String(DesktopPreferences.get("wallpaperPath") ?? "");
}
property var applyWallpaper: function(path) { Wallpaper.set(path); }
property var reloadShell: function() { Quickshell.reload(false); }
readonly property bool busy: listQuery.running || actionRun.running
|| applyRestoredState.running || settleReload.running
Process {
id: listQuery
@@ -34,6 +59,7 @@ Singleton {
try {
const parsed = JSON.parse(this.text);
root.snapshots = Array.isArray(parsed) ? parsed : [];
if (root.lastError === "Could not read the list of snapshots.")
root.lastError = "";
} catch (error) {
root.lastError = "Could not read the list of snapshots.";
@@ -45,6 +71,11 @@ Singleton {
Process {
id: actionRun
property bool restoring: false
property string outputText: ""
stdout: StdioCollector {
onStreamFinished: actionRun.outputText = this.text
}
onStarted: actionRun.outputText = ""
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.lastError = actionRun.restoring
@@ -52,14 +83,52 @@ Singleton {
: "The settings could not be backed up.";
return;
}
root.lastError = "";
root.lastAction = actionRun.restoring ? "restored" : "saved";
if (actionRun.restoring)
DesktopPreferences.reload();
if (actionRun.restoring) {
const homeReloaded = root.handleRestoreOutput(actionRun.outputText);
root.lastError = homeReloaded
? ""
: "Desktop settings were restored, but Home favourites could not be reloaded.";
} else
root.lastError = "";
root.refresh();
}
}
Timer {
id: applyRestoredState
interval: 80
repeat: false
onTriggered: {
// DesktopPreferences.reload() invalidates reactive shell bindings.
// These services also own state outside QML and need an explicit
// replay: compositor options, Lua-generated binds, and hyprpaper.
root.applyCompositor();
root.reloadKeybinds();
root.applyWallpaper(root.currentWallpaper());
settleReload.attempts = 0;
settleReload.restart();
}
}
Timer {
id: settleReload
property int attempts: 0
interval: 100
repeat: true
onTriggered: {
attempts++;
// Let the current instances finish their external writes before a
// soft reload replaces them. The cap keeps a failed external tool
// from leaving restored Home state stale indefinitely.
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
stop();
root.reloadShell();
}
}
}
Component.onCompleted: root.refresh()
function refresh(): void {
@@ -71,7 +140,81 @@ Singleton {
if (actionRun.running)
return;
actionRun.restoring = false;
actionRun.exec([root.helperPath, "save"]);
actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);
}
function serialiseHomeState(): string {
const current = root.readHomeState();
const favorites = [];
for (const favorite of current.favorites ?? []) {
favorites.push({
id: String(favorite.id ?? ""),
alias: String(favorite.alias ?? "")
});
}
return JSON.stringify({
initialized: current.initialized === true,
favorites: favorites
});
}
function handleRestoreOutput(text: string): bool {
if (!root.reloadHomeState(text))
return false;
root.reloadDesktop();
applyRestoredState.restart();
return true;
}
// Restore output carries the canonical Home state. Reconstructing through
// these methods keeps validation and persistence inside HomePreferences;
// this service never mutates its aliases or private FileView directly.
function reloadHomeState(text: string): bool {
try {
const result = JSON.parse(text);
const restored = result?.home;
if (!restored || restored.preserve === true)
return true;
if (restored.present !== true)
return restored.present === false
? root.resetHomeState()
: false;
const data = restored.data;
if (!data || typeof data.initialized !== "boolean" || !Array.isArray(data.favorites))
return false;
const ids = [];
const aliases = [];
const seen = {};
for (const favorite of data.favorites) {
const id = favorite?.id;
const alias = favorite?.alias;
if (typeof id !== "string" || !/^light\.[a-z0-9_]+$/.test(id)
|| typeof alias !== "string" || seen[id])
return false;
seen[id] = true;
ids.push(id);
aliases.push(alias);
}
if (!data.initialized && ids.length > 0)
return false;
root.resetHome();
if (!data.initialized)
return true;
root.initializeHome(ids);
for (let index = 0; index < ids.length; index++)
root.aliasHome(ids[index], aliases[index]);
return true;
} catch (error) {
return false;
}
}
function resetHomeState(): bool {
root.resetHome();
return true;
}
// The name is matched against the snapshot list rather than trusted, so no
@@ -396,12 +396,9 @@ Singleton {
// that only cleared the schema store would silently leave a customised
// favourites list behind while claiming to restore Panama's defaults.
//
// Done through HomePreferences' public writable aliases rather than a
// reset function of its own: clearing `favorites` and returning
// `initialized` to false is exactly the state a fresh install has, and
// it lets initialize() seed the list again on next use.
HomePreferences.favorites = [];
HomePreferences.initialized = false;
// HomePreferences owns the write-through boundary so the state file is
// rewritten before this reset can be considered complete.
HomePreferences.resetHomeDefaults();
resettleTimer.restart();
}
@@ -0,0 +1,76 @@
// Isolated behavioral harness for SettingsBackup's live restore handoff.
// Every external consumer is replaced before restore output is exercised, so
// this file never writes the real compositor, wallpaper, keymap, or shell.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
id: root
property var calls: []
property bool homeInitialized: false
property var homeFavorites: []
function record(name: string): void {
const next = root.calls.slice();
next.push(name);
root.calls = next;
}
Component.onCompleted: {
SettingsBackup.readHomeState = function() {
return {
initialized: root.homeInitialized,
favorites: root.homeFavorites
};
};
SettingsBackup.resetHome = function() {
root.record("home.reset");
root.homeInitialized = false;
root.homeFavorites = [];
};
SettingsBackup.initializeHome = function(ids) {
root.record("home.initialize:" + ids.join(","));
root.homeInitialized = true;
root.homeFavorites = ids.map(id => ({ id: id, alias: "" }));
};
SettingsBackup.aliasHome = function(id, alias) {
root.record("home.alias:" + id + "=" + alias);
root.homeFavorites = root.homeFavorites.map(favorite =>
favorite.id === id ? { id: id, alias: alias } : favorite);
};
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
SettingsBackup.keybindsReloading = function() { return false; };
SettingsBackup.systemBusy = function() { return false; };
SettingsBackup.currentWallpaper = function() { return "/tmp/restored-wallpaper.jpg"; };
SettingsBackup.applyWallpaper = function(path) { root.record("wallpaper.set:" + path); };
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
}
IpcHandler {
target: "settings-backup-behavior"
function reset(): void {
root.calls = [];
root.homeInitialized = false;
root.homeFavorites = [];
}
function apply(output: string): bool {
return SettingsBackup.handleRestoreOutput(output);
}
function status(): string {
return JSON.stringify({
calls: root.calls,
initialized: root.homeInitialized,
favorites: root.homeFavorites
});
}
}
}
@@ -285,3 +285,64 @@ can run in parallel with it.
Nothing here is committed yet; `main` has 20+ uncommitted paths from prior work
that should get a restore point before Stage 1 begins.
---
## Stage 5 — Replace GNOME Settings for what Panama owns
Not in the original plan. Added after the settings vocabulary made new pages
cheap enough that the limiting factor stopped being effort and started being
scope. Built jointly with the codex agent, which took default applications, the
page migrations, and the remaining hardcoded values.
- [x] **Wallpaper** — thumbnail grid, applied over hyprpaper IPC.
- [x] **Power & Lock** — screen blank, lock, suspend, lock-before-sleep.
- [x] **Date & Time** — timezone and network time via `timedatectl`.
- [x] **Accessibility** — pointer size, text scale, motion, contrast.
- [x] **Search that indexes settings**, not page names.
- [x] **Editable Dock** — reorder, unpin, add.
- [x] **Settings snapshots** — save, list, restore.
- [x] **Rebindable shortcuts.**
- [ ] Displays: resolution, refresh rate, scale, rotation.
- [ ] Per-application notification rules.
- [ ] Window rules (float/tile/workspace) as a page.
**Landed.** Each of these turned up something the compositor or its tools do
differently than documented, and in every case the failure mode was silence
rather than an error:
* **hyprpaper 0.8 ignores the "all outputs" form.** `<empty>,<path>` is accepted
and does nothing, so a wallpaper set that way appears to succeed and never
changes. Its IPC is also much smaller than older versions suggest — `preload`,
`listloaded`, `unload`, and `reload` all answer "invalid hyprpaper request".
* **`cursor:inactive_timeout` is answered as `float`, not `int`.** A wrong
`readAs` does not fail loudly; it makes every write to that key look rejected,
and the user sees an error for a change that worked.
* **Snapshot filenames collided at one-second resolution.** A save followed
promptly by a restore produced the same name twice, and the restore's own
safety snapshot overwrote the file it was about to read. Found by the
contract, which restores immediately after saving.
* **Keying bind overrides by description moved every bind sharing one.**
Rebinding `SUPER+C` also dragged `XF86Calculator` onto the same chord.
Descriptions are not unique; chords are.
* **The GNOME delegation allow-list named a panel that does not exist.**
`users` is not in `gnome-control-center --list`, so that button opened
nothing.
* **A copy of the Quickshell config shares the live shell's ID.** Quickshell
derives it from content rather than path, so an "isolated" harness built by
copying the config directory can kill or drive the running desktop. This took
the live shell down twice during development. Harnesses pointing at a single
distinct `.qml` file are unaffected.
Two configurations are now generated rather than edited, because `~/.config/hypr`
is a symlink into this repository and writing there at runtime would put machine
state into a tracked file: hypridle's config, into `XDG_STATE_HOME` with a
systemd drop-in pointing at it, and the wallpaper, which is applied over IPC and
re-applied at shell start instead of being written into `hyprpaper.conf`.
The schema gained a `json` type for structured values — the dock's pinned list
and the keybind overrides — so they live in the one settings file and are
covered by the one reset, rather than each growing a preference store of its
own. `restoreDefaults()` spans every store Panama owns, including the Home
accessory arrangement, which it reaches through that service's existing public
aliases rather than an API added for the purpose.
@@ -0,0 +1,119 @@
# Panama Settings Completion Codex Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Complete Codex's portion of Panama Settings with default-application and autostart management, shared page scaffolds, and real controls for every remaining hardcoded shell behavior value.
**Architecture:** `DefaultApps.qml` is the typed system boundary for freedesktop default handlers and user autostart entries; `ApplicationsPage.qml` binds reactively to `DesktopEntries.applications.values` and never snapshots the asynchronous model. Existing pages adopt `SettingsPage`; schema-bound values use `ChoiceRow`, `SliderRow`, and `ToggleRow`, while specialized controls and genuinely read-only facts remain specialized or use `TextRow`.
**Tech Stack:** Quickshell 0.3 QML, QtQuick, `xdg-settings`, `xdg-mime`, freedesktop `.desktop` files, Bash/Python contract tests, Hyprland.
**Spec:** `docs/superpowers/specs/2026-08-17-panama-cohesion-design.md`
**Status:** Implemented and independently reviewed on 2026-08-18. The combined
verification gate is recorded in the integrating commit history.
## Global Constraints
- Claude owns and must be the only editor of `PreferenceSchema.qml`, `SettingsSidebar.qml`, `SettingsShell.qml`, `qmldir`, `ShellState.qml`, `SystemSettings.qml`, `AppearancePage.qml`, `DesktopPage.qml`, `ShortcutsPage.qml`, and `config/dot/hypr/*`.
- Never use `hyprctl keyword`; every `hl.bind` requires a description.
- Use `DesktopEntries.applications.values` in reactive bindings. Do not call `byId()` or `heuristicLookup()` in a one-time initialization path.
- Use `SettingRow.activatable` for whole-row clicks. Nested Repeaters address outer models through explicit ids, never `parent.modelData`.
- Only these verified GNOME panels may be opened: applications, background, bluetooth, color, display, keyboard, mouse, multitasking, network, notifications, online-accounts, power, printers, privacy, search, sharing, sound, system, universal-access, wacom, wellbeing, wifi, wwan.
- No mock phase, new visual direction, color literals, or idle animation. Preserve page copy and behavior unless a dead read-only row is replaced by a real control.
- Automated tests isolate XDG config/state, do not change live defaults or autostart entries, do not launch applications, and do not invoke Home actions.
---
### Task 1: Applications, default handlers, and user autostart
**Files:**
- Create: `config/dot/quickshell/services/DefaultApps.qml`
- Create: `config/dot/quickshell/modules/settings/ApplicationsPage.qml`
- Create only if needed for a safe parser/writer boundary: `config/dot/quickshell/scripts/panama-default-apps`
- Create: `tests/quickshell/default-apps-contract.sh`
- Create: `tests/quickshell/applications-settings-contract.sh`
**Interfaces:**
- Consumes: `DesktopEntries.applications.values`, `SettingsPage`, `SettingsCard`, `SettingRow.activatable`, `ActionRow`, `TextRow`, and Claude-owned routing for page id `applications`.
- Produces: a singleton `DefaultApps` with reactive `handlers`, `autostartEntries`, `luaAutostartEntries`, `busy`, `lastError`, `refresh()`, `setDefault(role, desktopId)`, and `setAutostart(desktopId, enabled)`.
- [x] **Step 1: Write failing helper/service and page contracts**
Cover seven roles: browser (`xdg-settings default-web-browser`), mail (`x-scheme-handler/mailto`), files (`inode/directory`), terminal (`x-scheme-handler/terminal`), music (`audio/mpeg`), images (`image/png`), and video (`video/mp4`). Use temporary XDG directories and fake `xdg-settings`/`xdg-mime` binaries; assert setters pass separate arguments and reject unknown roles or desktop ids. Fixture user autostart entries must expose id/name/enabled and toggle with standard `Hidden=` semantics; `hl.exec_cmd` entries parsed from `config/dot/hypr/autostart.lua` are read-only and identify their source.
The page contract must require `DesktopEntries.applications.values`, page id/object name, all seven role labels, user and compositor autostart sections, `SettingRow.activatable`, and must reject `Component.onCompleted` snapshots plus `byId()`/`heuristicLookup()`.
- [x] **Step 2: Run focused contracts to verify RED**
```bash
tests/quickshell/default-apps-contract.sh
tests/quickshell/applications-settings-contract.sh
```
Expected: fail because the service/page and behavior do not exist.
- [x] **Step 3: Implement the minimal typed boundary and page**
All process commands use argument arrays. Validate roles against a fixed map and desktop ids against the reactive applications model or a strict freedesktop id pattern plus discovered entries. The page filters role choices from category/generic-name data, sorts by display name, keeps the current handler visible even when category metadata is sparse, and shows calm inline errors. Toggling applies only to files under `$XDG_CONFIG_HOME/autostart`; Lua entries remain read-only with explanatory copy.
- [x] **Step 4: Verify and commit**
```bash
tests/quickshell/default-apps-contract.sh
tests/quickshell/applications-settings-contract.sh
tests/quickshell/settings-pages-contract.sh
```
Commit subject: `Add application and autostart settings`.
---
### Task 2: Remaining page scaffolds and hardcoded behavior controls
**Files:**
- Modify: `config/dot/quickshell/config/Settings.qml`
- Modify: `config/dot/quickshell/modules/settings/HomePage.qml`
- Modify: `config/dot/quickshell/modules/settings/DisplaysPage.qml`
- Modify: `config/dot/quickshell/modules/settings/ConnectivityPage.qml`
- Modify: `config/dot/quickshell/modules/settings/SoundPage.qml`
- Modify: `config/dot/quickshell/modules/settings/NotificationsPage.qml`
- Modify: `config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml`
- Modify: `config/dot/quickshell/modules/settings/ServicesPage.qml`
- Modify: `config/dot/quickshell/modules/settings/AboutPage.qml`
- Modify: `tests/quickshell/settings-pages-contract.sh`
- Create: `tests/quickshell/settings-hardcoded-values-contract.sh`
**Interfaces:**
- Consumes: Claude-owned schema keys `temperatureUnit`, `weatherRefreshMinutes`, `vitalsIntervalMs`, `notificationTimeoutMs`, `notificationTimeoutCriticalMs`, `notificationHistoryLimit`, `maxVisibleToasts`, `screenshotDir`, `recordingDir`, and `recorderArgs`; shared Settings rows; existing services and system handoffs.
- Produces: the same public `Settings.qml` properties, now each reading `DesktopPreferences.get("key")`, and controls routed to weather/vitals, notifications, and capture pages.
- [x] **Step 1: Write failing contracts**
Require all ten `Settings.qml` properties to use `DesktopPreferences.get()` exactly. Require every listed page root to be `SettingsPage` and reject its copied root `Flickable` scaffold. Require weather/vitals controls on `HomePage`, notification controls on `NotificationsPage`, and capture directory/encoder controls on `ScreenIntelligencePage`; every schema-bound control uses a shared row and writes only through `SystemSettings.commitPreference` via that row.
- [x] **Step 2: Run contracts to verify RED**
```bash
tests/quickshell/settings-hardcoded-values-contract.sh
tests/quickshell/settings-pages-contract.sh
```
Expected: fail on hardcoded properties and copied page scaffolds.
- [x] **Step 3: Implement controls and migrate scaffolds**
Use `ChoiceRow` for `temperatureUnit`, `screenshotDir`, `recordingDir`, and `recorderArgs`; use `SliderRow` for numeric refresh, timeout, history, and toast limits. Give `notificationTimeoutCriticalMs` `zeroLabel: "Never"`. Preserve all specialized buttons, service status rows, display diagnostics, permission/privacy explanations, and GNOME handoff actions. Convert genuinely read-only rows to `TextRow`; delete only rows superseded by working controls.
- [x] **Step 4: Run focused and full suite, then commit**
```bash
tests/quickshell/settings-hardcoded-values-contract.sh
tests/quickshell/settings-pages-contract.sh
tests/quickshell/settings-preferences-contract.sh
tests/quickshell/settings-search-contract.sh
```
Then run every `tests/quickshell/*.sh`, every `tests/hypr/*.sh`, and all three `tests/quickshell/*_test.py` files sequentially.
Commit subject: `Complete Panama settings controls`.
@@ -0,0 +1,103 @@
# Settings Home & Phone Completion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Finish the Home & Phone Settings page on Panama's shared Settings vocabulary and give the global reset path a named, durable Home preference API.
**Architecture:** `HomePhonePage` adopts `SettingsPage` for the shared scrolling/title/lede scaffold while retaining its specialized catalog, reorder, alias, and phone controls. `HomePreferences.resetHomeDefaults()` becomes the sole Home-store reset boundary: it returns the store to fresh-install state and writes immediately so `SystemSettings.restoreDefaults()` can call it without mutating aliases.
**Tech Stack:** Quickshell 0.3 QML, QtQuick, Bash contract tests, Hyprland.
**Spec:** `docs/superpowers/specs/2026-08-17-panama-cohesion-design.md`
## Global Constraints
- Do not touch `SystemSettings.qml`, `PreferenceSchema.qml`, keybinds, or Claude's shared row implementations in this branch.
- Preserve ordered Home favorites, aliases, first-four Control Center badges, search, drag and keyboard reorder, retry state, Home Assistant status copy, and BlueBubbles availability behavior.
- Automated tests must not toggle or dim a real light and must not launch BlueBubbles.
- New behavior follows red-green TDD; fixture and state directories remain isolated from the live desktop.
- No mock phase and no new visual direction: this is cohesion work against the already-approved design.
---
### Task 1: Shared Home & Phone page and named reset boundary
**Files:**
- Modify: `config/dot/quickshell/config/HomePreferences.qml`
- Modify: `config/dot/quickshell/home-preferences-harness.qml`
- Modify: `config/dot/quickshell/modules/settings/HomePhonePage.qml`
- Modify only if required for shared vocabulary compatibility: `config/dot/quickshell/modules/settings/HomeFavoriteCard.qml`
- Modify only if required for shared vocabulary compatibility: `config/dot/quickshell/modules/settings/AvailableLightRow.qml`
- Modify: `tests/quickshell/home-preferences-contract.sh`
- Modify: `tests/quickshell/home-phone-settings-contract.sh`
**Interfaces:**
- Consumes: `SettingsPage { title; lede; default content }`, existing `SettingsCard`, `SettingRow`, `ActionRow`, `TextRow`, `HomeAssistant`, `SystemSettings.bluebubblesAvailable`, and writable `HomePreferences` adapter state.
- Produces: `HomePreferences.resetHomeDefaults(): void`, which sets `favorites` to `[]`, sets `initialized` to `false`, clears stale save error state, and invokes `preferencesFile.writeAdapter()` immediately after stopping the debounce timer.
- [ ] **Step 1: Write failing contracts**
Add `reset()` to the isolated `home-pref-test` IPC harness. Extend `home-preferences-contract.sh` to seed aliases/order, invoke reset, and require both IPC state and `panama-home.json` to become exactly `{"initialized":false,"favorites":[]}` without waiting for the 180 ms debounce interval. Extend `home-phone-settings-contract.sh` to require `SettingsPage {`, `title: "Home & Phone"`, and the existing lede through `lede:`, while rejecting the copied root `Flickable` scaffold.
- [ ] **Step 2: Run contracts to verify RED**
```bash
tests/quickshell/home-preferences-contract.sh
tests/quickshell/home-phone-settings-contract.sh
```
Expected: the preference contract fails because `reset` is missing; the page contract fails because the page still owns a copied `Flickable` scaffold.
- [ ] **Step 3: Implement the reset API and shared page scaffold**
Implement this public boundary in `HomePreferences.qml`:
```qml
function resetHomeDefaults(): void {
persistTimer.stop();
values.favorites = [];
values.initialized = false;
root.saveError = "";
preferencesFile.writeAdapter();
}
```
Replace the `HomePhonePage` root `Item` plus nested `Flickable`/title/lede scaffold with:
```qml
SettingsPage {
id: root
objectName: "home-phone-page"
title: "Home & Phone"
lede: "Choose what appears in Control Center and keep phone continuity close at hand."
// Existing SettingsCard content remains in order.
}
```
Use shared `ActionRow` or `TextRow` only where their single-action/read-only contracts preserve all current status and accessibility behavior. Keep specialized rows when the shared primitive would lose information.
- [ ] **Step 4: Run focused contracts to GREEN**
```bash
tests/quickshell/home-preferences-contract.sh
tests/quickshell/home-phone-settings-contract.sh
tests/quickshell/settings-pages-contract.sh
tests/quickshell/settings-rows-contract.sh
tests/quickshell/settings-commit-reset-contract.sh
```
Expected: every command exits 0; no test launches BlueBubbles or changes a real Home Assistant entity.
- [ ] **Step 5: Commit**
```bash
git add config/dot/quickshell/config/HomePreferences.qml \
config/dot/quickshell/home-preferences-harness.qml \
config/dot/quickshell/modules/settings/HomePhonePage.qml \
config/dot/quickshell/modules/settings/HomeFavoriteCard.qml \
config/dot/quickshell/modules/settings/AvailableLightRow.qml \
tests/quickshell/home-preferences-contract.sh \
tests/quickshell/home-phone-settings-contract.sh \
docs/superpowers/plans/2026-08-18-settings-home-phone-completion.md
git commit -m "Finish Home and Phone settings cohesion"
```
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'applications settings contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
page="$project_root/config/dot/quickshell/modules/settings/ApplicationsPage.qml"
[[ -f "$page" ]] || fail 'Applications page is missing'
assert_contains() {
rg -F --quiet "$1" "$page" || fail "page is missing: $1"
}
assert_contains 'SettingsPage {'
assert_contains 'objectName: "applications"'
assert_contains 'DesktopEntries.applications.values'
assert_contains 'DefaultApps'
assert_contains 'SettingsCard {'
assert_contains 'SettingRow {'
assert_contains 'activatable:'
assert_contains 'ActionRow {'
assert_contains 'TextRow {'
for label in Browser Mail Files Terminal Music Images Video; do
assert_contains "label: \"$label\""
done
assert_contains 'title: "Default applications"'
assert_contains 'title: "User autostart"'
assert_contains 'title: "Compositor autostart"'
assert_contains 'categories'
assert_contains 'genericName'
assert_contains '.sort('
assert_contains 'currentEntry'
assert_contains 'read-only'
assert_contains 'choices.push(currentEntry)'
assert_contains 'label: "Application settings need attention"'
assert_contains 'DefaultApps.busy ? "Loading…"'
assert_contains 'visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0'
assert_contains 'visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0'
PAGE_PATH="$page" bun -e '
const source = await Bun.file(process.env.PAGE_PATH).text();
const rolesSource = source.match(/readonly property var roles:\s*(\[[\s\S]*?\n \])/);
const matcherSource = source.match(/function matchesRole\(entry: var, role: var\): bool \{([\s\S]*?)\n \}/);
if (!rolesSource || !matcherSource) {
console.error("applications settings contract: role matcher could not be loaded");
process.exit(1);
}
const roles = Function(`return (${rolesSource[1]})`)();
const matchesRole = Function("entry", "role", matcherSource[1]);
const role = key => roles.find(candidate => candidate.key === key);
const fixtures = [
{
name: "AudioVideo does not imply music",
entry: { name: "Kodi", genericName: "Media Center", comment: "Entertainment hub", categories: "AudioVideo;Player;" },
role: "music",
expected: false
},
{
name: "Graphics does not imply image handler",
entry: { name: "Document Scanner", genericName: "Document Scanner", comment: "Scan documents", categories: ["Graphics"] },
role: "images",
expected: false
},
{
name: "Viewer does not imply image handler",
entry: { name: "Papers", genericName: "Document Viewer", comment: "Read documents", categories: "Office;Viewer;" },
role: "images",
expected: false
},
{
name: "comment does not nominate a default handler",
entry: { name: "Settings", genericName: "System Settings", comment: "Configure your video player", categories: ["System"] },
role: "video",
expected: false
},
{
name: "exact audio player categories match music",
entry: { name: "Rhythmbox", genericName: "Music Player", comment: "Play music", categories: "AudioVideo;Audio;Player;" },
role: "music",
expected: true
},
{
name: "exact video category matches video",
entry: { name: "Videos", genericName: "Video Player", comment: "Play movies", categories: ["AudioVideo", "Video", "Player"] },
role: "video",
expected: true
},
{
name: "descriptive metadata matches image handler",
entry: { name: "Loupe", genericName: "Image Viewer", comment: "Browse pictures", categories: "Graphics;Viewer;" },
role: "images",
expected: true
}
];
for (const fixture of fixtures) {
const actual = matchesRole(fixture.entry, role(fixture.role));
if (actual !== fixture.expected) {
console.error(`applications settings contract: ${fixture.name}: expected ${fixture.expected}, got ${actual}`);
process.exit(1);
}
}
'
if rg --quiet 'Component\.onCompleted|DesktopEntries\.(byId|heuristicLookup)' "$page"; then
fail 'page snapshots or performs a one-time desktop-entry lookup'
fi
if rg -F --quiet 'label: "Could not apply the change"' "$page"; then
fail 'error heading incorrectly describes read failures as apply failures'
fi
if rg --quiet '#[0-9A-Fa-f]{3,8}' "$page"; then
fail 'page introduces a color literal instead of the shared visual system'
fi
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|| fail 'default and autostart rows are not both whole-row activatable'
printf 'applications settings contract: PASS\n'
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'default apps contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$project_root/config/dot/quickshell/scripts/panama-default-apps"
service="$project_root/config/dot/quickshell/services/DefaultApps.qml"
test_root="$(mktemp -d /tmp/panama-default-apps.XXXXXX)"
trap 'rm -rf "$test_root"' EXIT
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
[[ -f "$service" ]] || fail 'DefaultApps service is missing'
assert_service_contains() {
rg -F --quiet "$1" "$service" || fail "service is missing: $1"
}
assert_service_contains 'pragma Singleton'
assert_service_contains 'property var handlers'
assert_service_contains 'property var autostartEntries'
assert_service_contains 'property var luaAutostartEntries'
assert_service_contains 'readonly property bool busy'
assert_service_contains 'property string lastError'
assert_service_contains 'function refresh(): void'
assert_service_contains 'function setDefault(role: string, desktopId: string): void'
assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void'
assert_service_contains 'DesktopEntries.applications.values'
if rg --quiet 'command\s*:\s*"' "$service"; then
fail 'Process command must be an argument array'
fi
config_home="$test_root/config"
data_home="$test_root/data"
data_dirs="$test_root/data-dirs"
fake_bin="$test_root/bin"
call_log="$test_root/calls"
lua_fixture="$test_root/autostart.lua"
mkdir -p "$config_home/autostart" "$data_home/applications" "$data_dirs" "$fake_bin"
write_application() {
local desktop_id="$1"
local name="$2"
local generic_name="$3"
local categories="$4"
cat >"$data_home/applications/$desktop_id" <<EOF
[Desktop Entry]
Type=Application
Name=$name
GenericName=$generic_name
Categories=$categories
Exec=/usr/bin/true
EOF
}
write_application org.mozilla.firefox.desktop Firefox 'Web Browser' 'Network;WebBrowser;'
write_application org.gnome.Geary.desktop Geary 'Mail Client' 'Network;Email;'
write_application org.gnome.Nautilus.desktop Files 'File Manager' 'System;FileManager;'
write_application org.gnome.Ptyxis.desktop Ptyxis Terminal 'System;TerminalEmulator;'
write_application org.gnome.Rhythmbox3.desktop Rhythmbox 'Music Player' 'AudioVideo;Audio;Player;'
write_application org.gnome.Loupe.desktop Loupe 'Image Viewer' 'Graphics;Viewer;'
write_application org.gnome.Totem.desktop Videos 'Video Player' 'AudioVideo;Video;Player;'
cat >"$config_home/autostart/nextcloud.desktop" <<'EOF'
[Desktop Entry]
Type=Application
Name=Nextcloud
Exec=nextcloud --background
Hidden=true
EOF
cat >"$lua_fixture" <<'EOF'
hl.on("hyprland.start", function()
hl.exec_cmd("quickshell --daemonize")
hl.exec_cmd("nextcloud --background")
end)
hl.on("hyprland.shutdown", function()
hl.exec_cmd("systemctl --user stop hyprland-session.target")
end)
EOF
cat >"$fake_bin/xdg-settings" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$@" >>"$PANAMA_CALL_LOG"
if [[ "$1" == "get" && "$2" == "default-web-browser" ]]; then
printf '%s\n' 'org.mozilla.firefox.desktop'
exit 0
fi
EOF
chmod +x "$fake_bin/xdg-settings"
cat >"$fake_bin/xdg-mime" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$@" >>"$PANAMA_CALL_LOG"
if [[ "$1" == "query" && "$2" == "default" ]]; then
case "$3" in
x-scheme-handler/mailto) printf '%s\n' 'org.gnome.Geary.desktop' ;;
inode/directory) printf '%s\n' 'org.gnome.Nautilus.desktop' ;;
x-scheme-handler/terminal) printf '%s\n' 'org.gnome.Ptyxis.desktop' ;;
audio/mpeg) printf '%s\n' 'org.gnome.Rhythmbox3.desktop' ;;
image/png) printf '%s\n' 'org.gnome.Loupe.desktop' ;;
video/mp4) printf '%s\n' 'org.gnome.Totem.desktop' ;;
*) exit 91 ;;
esac
exit 0
fi
EOF
chmod +x "$fake_bin/xdg-mime"
export XDG_CONFIG_HOME="$config_home"
export XDG_DATA_HOME="$data_home"
export XDG_DATA_DIRS="$data_dirs"
export PANAMA_HYPR_AUTOSTART="$lua_fixture"
export PANAMA_CALL_LOG="$call_log"
export PATH="$fake_bin:$PATH"
snapshot="$($helper snapshot)" || fail 'snapshot command failed'
[[ "$(rg --count '^get$' "$call_log")" == "1" ]] \
|| fail 'browser handler was queried more than once'
[[ "$(rg --count '^query$' "$call_log")" == "6" ]] \
|| fail 'MIME handlers were queried more than once'
jq -e '
.handlers == {
browser: "org.mozilla.firefox.desktop",
mail: "org.gnome.Geary.desktop",
files: "org.gnome.Nautilus.desktop",
terminal: "org.gnome.Ptyxis.desktop",
music: "org.gnome.Rhythmbox3.desktop",
images: "org.gnome.Loupe.desktop",
video: "org.gnome.Totem.desktop"
} and
.autostartEntries == [{id: "nextcloud.desktop", name: "Nextcloud", enabled: false}] and
(.luaAutostartEntries | length == 2) and
([.luaAutostartEntries[] |
.enabled == true and .readOnly == true and
.source == "config/dot/hypr/autostart.lua" and
(.id | startswith("hyprland:")) and
(.name | length > 0) and (.command | length > 0)
] | all) and
([.luaAutostartEntries[].command] |
index("systemctl --user stop hyprland-session.target") == null)
' <<<"$snapshot" >/dev/null || fail 'snapshot shape, handlers, or autostart parsing is incorrect'
assert_call() {
local expected="$1"
local actual
actual="$(cat "$call_log")"
[[ "$actual" == "$expected" ]] || {
printf 'expected argv:\n%s\nactual argv:\n%s\n' "$expected" "$actual" >&2
fail 'setter did not pass separate arguments'
}
}
: >"$call_log"
$helper set-default browser org.mozilla.firefox.desktop
assert_call $'set\ndefault-web-browser\norg.mozilla.firefox.desktop'
roles=(mail files terminal music images video)
desktop_ids=(
org.gnome.Geary.desktop
org.gnome.Nautilus.desktop
org.gnome.Ptyxis.desktop
org.gnome.Rhythmbox3.desktop
org.gnome.Loupe.desktop
org.gnome.Totem.desktop
)
mime_types=(
x-scheme-handler/mailto
inode/directory
x-scheme-handler/terminal
audio/mpeg
image/png
video/mp4
)
for index in "${!roles[@]}"; do
: >"$call_log"
$helper set-default "${roles[$index]}" "${desktop_ids[$index]}"
assert_call $'default\n'"${desktop_ids[$index]}"$'\n'"${mime_types[$index]}"
done
: >"$call_log"
if $helper set-default unknown org.mozilla.firefox.desktop >/dev/null 2>&1; then
fail 'unknown role was accepted'
fi
[[ ! -s "$call_log" ]] || fail 'unknown role reached an xdg command'
if $helper set-default browser org.example.Missing.desktop >/dev/null 2>&1; then
fail 'undiscovered desktop id was accepted'
fi
if $helper set-default browser ../escape.desktop >/dev/null 2>&1; then
fail 'unsafe desktop id was accepted'
fi
$helper set-autostart nextcloud.desktop true
rg --quiet '^Hidden=false$' "$config_home/autostart/nextcloud.desktop" \
|| fail 'enabling autostart did not set Hidden=false'
[[ "$(rg --count '^Hidden=' "$config_home/autostart/nextcloud.desktop")" == "1" ]] \
|| fail 'enabling autostart duplicated Hidden'
rg --quiet '^Exec=nextcloud --background$' "$config_home/autostart/nextcloud.desktop" \
|| fail 'autostart update damaged another desktop key'
jq -e '.autostartEntries == [{id: "nextcloud.desktop", name: "Nextcloud", enabled: true}]' \
<<<"$($helper snapshot)" >/dev/null || fail 'enabled state did not round-trip'
$helper set-autostart nextcloud.desktop false
rg --quiet '^Hidden=true$' "$config_home/autostart/nextcloud.desktop" \
|| fail 'disabling autostart did not set Hidden=true'
outside_entry="$test_root/outside.desktop"
cp "$config_home/autostart/nextcloud.desktop" "$outside_entry"
ln -s "$outside_entry" "$config_home/autostart/linked.desktop"
if $helper set-autostart linked.desktop true >/dev/null 2>&1; then
fail 'autostart symlink escaping XDG config was accepted'
fi
rg --quiet '^Hidden=true$' "$outside_entry" || fail 'outside autostart file was modified'
if $helper set-autostart missing.desktop true >/dev/null 2>&1; then
fail 'unknown autostart desktop id was accepted'
fi
if $helper set-autostart 'hyprland:1' false >/dev/null 2>&1; then
fail 'read-only compositor entry was accepted for mutation'
fi
printf 'default apps contract: PASS\n'
@@ -80,8 +80,12 @@ system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
[[ -f "$home_page" ]] || fail 'HomePhonePage.qml is missing'
[[ -f "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing'
[[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing'
assert_contains 'text: "Home & Phone"' "$home_page"
assert_contains 'text: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page"
assert_contains 'SettingsPage {' "$home_page"
assert_contains 'title: "Home & Phone"' "$home_page"
assert_contains 'lede: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page"
if rg -q '^\s*Flickable \{' "$home_page"; then
fail 'HomePhonePage.qml still owns a copied Flickable scaffold'
fi
assert_contains 'Connected · ' "$home_page"
assert_contains 'Last update unavailable · showing saved controls' "$home_page"
assert_contains 'Authentication required' "$home_page"
+25 -3
View File
@@ -4,6 +4,7 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/home-preferences-harness.qml"
preferences="$repo_dir/config/dot/quickshell/config/HomePreferences.qml"
state_home="$(mktemp -d /tmp/panama-home-preferences-state.XXXXXX)"
fail() {
@@ -73,9 +74,7 @@ wait_for_file_content() {
for _ in $(seq 1 40); do
state_file="$(find "$state_home" -name panama-home.json -print -quit)"
if [[ -n "$state_file" ]] \
&& jq -e --argjson expected "$expected" \
'.initialized == $expected.initialized and .favorites == $expected.favorites' \
"$state_file" >/dev/null; then
&& jq -e --argjson expected "$expected" '. == $expected' "$state_file" >/dev/null; then
return
fi
sleep 0.1
@@ -83,6 +82,15 @@ wait_for_file_content() {
fail 'preferences file did not contain the complete atomic update'
}
assert_reset_persists_without_debounce() {
local expected=$' function resetHomeDefaults(): void {\n persistTimer.stop();\n values.favorites = [];\n values.initialized = false;\n root.saveError = "";\n preferencesFile.writeAdapter();\n }'
local actual
actual="$(sed -n '/^ function resetHomeDefaults(): void {$/,/^ }$/p' "$preferences")"
[[ "$actual" == "$expected" ]] \
|| fail 'resetHomeDefaults must stop debounce before directly writing the default state'
}
# A leading JSON whitespace prevents qs from expanding the array into IPC
# positional arguments; JSON.parse() intentionally accepts that whitespace.
initial_ids=' ["light.kitchen","light.hall","light.desk"]'
@@ -90,7 +98,10 @@ expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":
expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}'
empty_expected='{"initialized":true,"favorites":[],"saveError":""}'
empty_file='{"initialized":true,"favorites":[]}'
reset_expected='{"initialized":false,"favorites":[],"saveError":""}'
reset_file='{"initialized":false,"favorites":[]}'
assert_reset_persists_without_debounce
start_harness
qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null
qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null
@@ -99,9 +110,20 @@ qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
wait_for_status "$expected"
wait_for_file_content "$expected_file"
qs_for_harness ipc call home-pref-test reset >/dev/null
wait_for_status "$reset_expected"
wait_for_file_content "$reset_file"
stop_harness
start_harness
wait_for_status "$reset_expected"
qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null
qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null
qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null
qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
wait_for_status "$expected"
wait_for_file_content "$expected_file"
qs_for_harness ipc call home-pref-test remove light.desk >/dev/null
qs_for_harness ipc call home-pref-test remove light.kitchen >/dev/null
+26 -1
View File
@@ -17,13 +17,25 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
config_home="$(mktemp -d /tmp/panama-rebind-config.XXXXXX)"
service="$repo_dir/config/dot/quickshell/services/Keybinds.qml"
fail() {
printf 'keybind rebind contract: %s\n' "$1" >&2
exit 1
}
rg -Fq 'function overrideOccupantFor(chord: string, exceptShipped: string): string' "$service" \
|| fail 'resetBind has no override collision guard'
rg -Fq 'root.overrideOccupantFor(shipped, shipped)' "$service" \
|| fail 'resetBind does not check whether another override occupies its shipped chord'
if [[ "${PANAMA_KEYBINDS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'keybind rebind contract: PASS (static)\n'
exit 0
fi
config_home="$(mktemp -d /tmp/panama-rebind-config.XXXXXX)"
# The compositor is the live one -- that is the point -- but preferences are
# isolated so this cannot leave an override in the user's real settings.
# hyprctl reload re-reads the real settings file, so the compositor is only
@@ -87,6 +99,19 @@ sleep 0.5
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|| fail 'resetAll left overrides behind'
# Terminal moved away from its shipped chord, then Files moved into it.
# Resetting Terminal must refuse instead of producing two binds on one chord.
qs_for_harness ipc call keybinds-test seedResetCollision \
"$terminal" "SUPER + SHIFT + F9" "$files" >/dev/null
sleep 0.2
[[ "$(qs_for_harness ipc call keybinds-test resetBind "SUPER + SHIFT + F9")" == "false" ]] \
|| fail 'resetBind reclaimed a shipped chord occupied by another override'
collision_state="$(qs_for_harness ipc call keybinds-test overrideState)"
jq -e --arg terminal "$terminal" --arg files "$files" \
'.count == 2 and .overrides[$terminal] == "SUPER + SHIFT + F9" and .overrides[$files] == $terminal and (.lastError | length > 0)' \
<<<"$collision_state" >/dev/null \
|| fail "a refused reset changed overrides or gave no explanation: $collision_state"
trap - EXIT
cleanup
printf 'keybind rebind contract: PASS\n'
+131 -2
View File
@@ -22,10 +22,26 @@ cleanup() { rm -rf "$work"; }
trap cleanup EXIT
settings="$work/config/panama/settings.json"
home="$work/state/panama/panama-home.json"
backups="$work/state/panama/backups"
transaction_dir="$work/state/panama/transactions/settings-restore"
mkdir -p "$(dirname "$settings")"
run() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" "$@"; }
run_with() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" env "$@"; }
assert_transaction_clean() {
if [[ -d "$transaction_dir" ]] && find "$transaction_dir" -mindepth 1 -print -quit | rg -q .; then
fail 'restore left staged, rollback, or journal files behind'
fi
if find "$work" -type f \( \
-name '.settings-restore.*' -o -name '.home-restore.*' \
-o -name '*rollback*' -o -name '.journal.json.*' \
-o -name '.settings.json.*' -o -name '.panama-home.json.*' \
\) -print -quit | rg -q .; then
fail 'restore left a temporary target or journal file behind'
fi
}
# ── Nothing to back up ───────────────────────────────────────────────────────
run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success'
@@ -33,15 +49,96 @@ run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported su
# ── A snapshot round-trips ───────────────────────────────────────────────────
printf '{"gapsOut":24,"windowRounding":6}' >"$settings"
mkdir -p "$(dirname "$home")"
printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"}]}' >"$home"
run save >/dev/null || fail 'save failed on a valid settings file'
name="$(run list | jq -r '.[0].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'
printf '{"gapsOut":99}' >"$settings"
run restore "$name" >/dev/null || fail 'restore failed'
printf '{"initialized":false,"favorites":[]}' >"$home"
restore_result="$(run restore "$name")" || fail 'restore failed'
[[ "$(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 '.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 -e '.home.present == true and .home.data.favorites[0].id == "light.desk"' <<<"$restore_result" >/dev/null \
|| fail 'restore did not return Home state for the live service to reload'
# ── Absence is part of a snapshot ───────────────────────────────────────────
rm -f "$home"
printf '{"gapsOut":30}' >"$settings"
run save >/dev/null || fail 'save failed when Home state was absent'
absent_name="$(run list | jq -r '.[0].name')"
printf '{"initialized":true,"favorites":[{"id":"light.living_room","alias":"Living room"}]}' >"$home"
absent_result="$(run restore "$absent_name")" || fail 'restore failed for a snapshot without Home state'
[[ ! -e "$home" ]] || fail 'restore did not preserve the snapshot’s absent Home state'
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'
# Desktop absence is symmetric: a Home-only snapshot removes a desktop file
# created later and restores the Home store.
rm -f "$settings"
printf '{"initialized":true,"favorites":[{"id":"light.porch","alias":"Porch"}]}' >"$home"
run save >/dev/null || fail 'save failed when desktop settings were absent'
desktop_absent_name="$(run list | jq -r '.[0].name')"
printf '{"gapsOut":47}' >"$settings"
printf '{"initialized":false,"favorites":[]}' >"$home"
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'
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.porch" ]] \
|| fail 'Home-only snapshot did not restore Home state'
assert_transaction_clean
printf '{"gapsOut":17}' >"$settings"
# A legacy settings-only snapshot predates presence metadata. Its safest
# interpretation is to restore desktop settings without deleting current Home
# state that the old format knew nothing about.
legacy="settings-20000101-010203004.json"
printf '{"gapsOut":17}' >"$backups/$legacy"
printf '{"initialized":true,"favorites":[{"id":"light.office","alias":"Office"}]}' >"$home"
run restore "$legacy" >/dev/null || fail 'legacy snapshot restore failed'
[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'legacy snapshot did not restore desktop settings'
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy snapshot destroyed Home state it did not describe'
# `version` is a valid unknown desktop preference. It is only an envelope when
# the complete v2 shape is present.
legacy_version="settings-20000101-010203005.json"
printf '{"version":77,"gapsOut":19}' >"$backups/$legacy_version"
run restore "$legacy_version" >/dev/null || fail 'a legacy snapshot with an unknown version key was rejected'
[[ "$(jq -r '.version' "$settings")" == "77" ]] || fail 'legacy version key was not restored as desktop data'
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy version key changed Home state'
# ── A durable journal recovers a process/power-loss split ────────────────────
printf '{"gapsOut":28,"windowRounding":12}' >"$settings"
printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Snapshot"}]}' >"$home"
run save >/dev/null || fail 'could not create crash-recovery snapshot'
crash_name="$(run list | jq -r '.[0].name')"
printf '{"gapsOut":91,"windowRounding":3}' >"$settings"
printf '{"initialized":true,"favorites":[{"id":"light.office","alias":"Before crash"}]}' >"$home"
run_with PANAMA_SETTINGS_BACKUP_TEST_CRASH=after-desktop "$helper" restore "$crash_name" >/dev/null 2>&1 \
&& fail 'crash injection completed restore instead of terminating after the first replacement'
[[ "$(jq -r '.gapsOut' "$settings")" == "28" ]] || fail 'crash did not occur after desktop replacement'
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'crash unexpectedly replaced Home state'
[[ -f "$transaction_dir/journal.json" ]] || fail 'crash left no durable recovery journal'
# Every entry point must recover before doing its own work. `list` is the least
# invasive proof and must put both stores back to the pre-restore generation.
run list >/dev/null || fail 'next invocation could not recover the interrupted restore'
[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'recovery did not roll desktop settings back'
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'recovery did not keep Home state in the same generation'
assert_transaction_clean
# Cleanup is installed before staging. A deterministic pre-journal failure
# must leave both destinations untouched and no hidden artifacts behind.
run_with PANAMA_SETTINGS_BACKUP_TEST_FAIL=after-desktop-stage "$helper" restore "$crash_name" >/dev/null 2>&1 \
&& fail 'staging failure injection unexpectedly restored the snapshot'
[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'staging failure changed desktop settings'
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'staging failure changed Home state'
assert_transaction_clean
# ── Restoring snapshots what it replaced, so it is undoable ──────────────────
count="$(run list | jq 'length')"
@@ -52,12 +149,44 @@ bad="settings-19990101-000000000.json"
mkdir -p "$backups"
printf '{ truncated' >"$backups/$bad"
run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored'
[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'a refused restore still damaged the settings file'
[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'a refused restore still damaged the settings file'
invalid_home="settings-19990101-000000001.json"
jq -n '{
version: 2,
desktop: {present: true, data: {gapsOut: 88}},
home: {present: true, data: {
initialized: true,
favorites: [
{id: "light.desk", alias: "Desk"},
{id: "light.desk", alias: "Duplicate"}
]
}}
}' >"$backups/$invalid_home"
run restore "$invalid_home" >/dev/null 2>&1 && fail 'a snapshot with duplicate Home favourites was restored'
[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'an invalid Home snapshot still damaged desktop settings'
printf '{ truncated' >"$home"
run save >/dev/null 2>&1 && fail 'a corrupt Home state file was backed up'
printf '{"initialized":true,"favorites":[]}' >"$home"
# ── The live service can sync its private Home state before save ─────────────
rm -f "$home"
printf '{"gapsOut":21}' >"$settings"
live_home='{"initialized":true,"favorites":[{"id":"light.studio","alias":"Studio"}]}'
run save "$live_home" >/dev/null || fail 'save rejected valid live Home state'
live_name="$(run list | jq -r '.[0].name')"
jq -e '.home.present == true and .home.data.favorites[0].alias == "Studio"' \
"$backups/$live_name" >/dev/null \
|| fail 'live Home state was not written to the canonical snapshot'
# ── A snapshot cannot name a path outside the backup directory ───────────────
printf '{"pwned":true}' >"$work/outside.json"
run restore "../../outside.json" >/dev/null 2>&1 && fail 'a traversing snapshot name was accepted'
run restore "/etc/passwd" >/dev/null 2>&1 && fail 'an absolute snapshot path was accepted'
link_name="settings-20000101-000000001.json"
ln -s "$work/outside.json" "$backups/$link_name"
run restore "$link_name" >/dev/null 2>&1 && fail 'a snapshot symlink escaping the backup directory was accepted'
jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the backup directory was restored'
# ── A snapshot that is not listed is refused ─────────────────────────────────
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Behavioral coverage for the QML handoff after the helper commits a restore.
# The harness has a unique shell identity, isolated XDG roots, and fake external
# consumers. It records the real SettingsBackup call order without touching the
# daily-driver shell, compositor, keymap, or wallpaper.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml"
harness="$repo_dir/config/dot/quickshell/settings-backup-harness.qml"
work="$(mktemp -d /tmp/panama-settings-backup-live.XXXXXX)"
fail() {
printf 'settings backup live contract: %s\n' "$1" >&2
exit 1
}
qs_test() {
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" qs -p "$harness" "$@"
}
cleanup() {
qs_test kill >/dev/null 2>&1 || true
rm -rf "$work"
}
trap cleanup EXIT
# The production command boundary must remain argv-only.
rg -Fq 'actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);' "$service" \
|| fail 'save does not pass live Home state as one argument'
rg -Fq 'actionRun.exec([root.helperPath, "restore", name]);' "$service" \
|| fail 'restore is not executed through an argument array'
if rg -q 'bash.*-c|sh.*-c' "$service"; then
fail 'the restore service constructs a shell command'
fi
# The harness replaces these seams, while these mappings prove the production
# defaults still delegate to Panama's existing public service APIs.
for mapping in \
'HomePreferences.resetHomeDefaults();' \
'HomePreferences.initialize(ids);' \
'HomePreferences.setAlias(id, alias);' \
'DesktopPreferences.reload();' \
'SystemSettings.applyPersistedDisplayPolicy();' \
'Keybinds.applyReload();' \
'Wallpaper.set(path);' \
'Quickshell.reload(false);'; do
rg -Fq "$mapping" "$service" || fail "production restore seam is missing: $mapping"
done
qs_test --daemonize >"$work/quickshell.log" 2>&1
ready=false
for _ in $(seq 1 60); do
if qs_test ipc show 2>/dev/null | rg -q '^target settings-backup-behavior$'; then
ready=true
break
fi
sleep 0.1
done
if [[ "$ready" != true ]]; then
sed -n '1,200p' "$work/quickshell.log" >&2
fail 'isolated SettingsBackup harness did not start'
fi
qs_test ipc call settings-backup-behavior reset >/dev/null
payload='{"restored":"settings-20260818-010203004.json","home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"},{"id":"light.office","alias":"Office"}]}}}'
[[ "$(qs_test ipc call settings-backup-behavior apply "$payload")" == "true" ]] \
|| fail 'valid restore output was rejected'
status=""
for _ in $(seq 1 50); do
status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
sleep 0.1
done
jq -e '
.calls == [
"home.reset",
"home.initialize:light.desk,light.office",
"home.alias:light.desk=Desk",
"home.alias:light.office=Office",
"desktop.reload",
"system.apply",
"keybinds.reload",
"wallpaper.set:/tmp/restored-wallpaper.jpg",
"shell.reload"
]
and .initialized == true
and .favorites == [
{"id":"light.desk","alias":"Desk"},
{"id":"light.office","alias":"Office"}
]
' <<<"$status" >/dev/null || fail "restore handoff order/state was wrong: $status"
# Invalid output is rejected before Home state or external consumers change.
qs_test ipc call settings-backup-behavior reset >/dev/null
invalid='{"home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"One"},{"id":"light.desk","alias":"Two"}]}}}'
[[ "$(qs_test ipc call settings-backup-behavior apply "$invalid")" == "false" ]] \
|| fail 'duplicate Home state was accepted'
status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls == [] and .initialized == false and .favorites == []' <<<"$status" >/dev/null \
|| fail 'invalid restore output caused partial live mutations'
# An absent Home generation uses the same ordered external handoff but leaves
# the live Home service reset rather than manufacturing an initialized store.
qs_test ipc call settings-backup-behavior reset >/dev/null
absent='{"restored":"settings-20260818-010203005.json","home":{"present":false}}'
[[ "$(qs_test ipc call settings-backup-behavior apply "$absent")" == "true" ]] \
|| fail 'absent Home restore output was rejected'
for _ in $(seq 1 50); do
status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
sleep 0.1
done
jq -e '
.calls == [
"home.reset",
"desktop.reload",
"system.apply",
"keybinds.reload",
"wallpaper.set:/tmp/restored-wallpaper.jpg",
"shell.reload"
]
and .initialized == false
and .favorites == []
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status"
trap - EXIT
cleanup
printf 'settings backup live contract: PASS\n'
@@ -19,6 +19,7 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
# 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
@@ -31,6 +32,12 @@ fail() {
exit 1
}
rg -Fq 'HomePreferences.resetHomeDefaults();' "$system_settings" \
|| fail 'restoreDefaults does not use the durable Home reset boundary'
if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$system_settings"; then
fail 'restoreDefaults mutates Home aliases instead of using resetHomeDefaults'
fi
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Public Settings.qml values are the compatibility surface consumed throughout
# the shell. Once a value becomes user-configurable, this file must read it from
# DesktopPreferences rather than keeping a second hardcoded source of truth.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
settings="$repo_dir/config/dot/quickshell/config/Settings.qml"
fail() {
printf 'settings hardcoded values contract: %s\n' "$1" >&2
exit 1
}
properties=(
temperatureUnit
weatherRefreshMinutes
vitalsIntervalMs
notificationTimeoutMs
notificationTimeoutCriticalMs
notificationHistoryLimit
maxVisibleToasts
screenshotDir
recordingDir
recorderArgs
)
for property in "${properties[@]}"; do
count="$(rg -c \
"^[[:space:]]*readonly property [A-Za-z]+ ${property}: DesktopPreferences\\.get\\(\"${property}\"\\)[[:space:]]*(//.*)?$" \
"$settings" || true)"
[[ "$count" == "1" ]] \
|| fail "$property must use DesktopPreferences.get(\"$property\") exactly once"
done
# dockPinned was already migrated on the shared branch. Pinning it here keeps a
# later bulk edit from accidentally restoring the old hardcoded app list.
rg -q '^[[:space:]]*readonly property var dockPinned: DesktopPreferences\.get\("dockPinned"\)[[:space:]]*$' "$settings" \
|| fail 'dockPinned no longer reads DesktopPreferences exactly'
printf 'settings hardcoded values contract: PASS\n'
+79 -5
View File
@@ -3,6 +3,85 @@
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
fail() {
printf 'settings pages contract: %s\n' "$1" >&2
exit 1
}
pages=(Home Displays Connectivity Sound Notifications ScreenIntelligence Services About)
for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
root_type="$(awk '
/^import / { next }
/^[[:space:]]*\/\// { next }
/^[[:space:]]*$/ { next }
match($0, /^[[:space:]]*([A-Za-z][A-Za-z0-9]*)[[:space:]]*\{/, found) {
print found[1]
exit
}
' "$page_file")"
[[ "$root_type" == "SettingsPage" ]] \
|| fail "${page}Page.qml root is ${root_type:-unknown}, expected SettingsPage"
! rg -q '^[[:space:]]*Flickable[[:space:]]*\{' "$page_file" \
|| fail "${page}Page.qml still copies the page Flickable scaffold"
done
require_row() {
local file="$1"
local row_type="$2"
local setting="$3"
python3 - "$file" "$row_type" "$setting" <<'PY' || \
fail "$(basename "$file") is missing $row_type for $setting"
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
row_type = re.escape(sys.argv[2])
setting = re.escape(sys.argv[3])
pattern = rf"{row_type}\s*\{{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{{).)*?setting\s*:\s*\"{setting}\""
raise SystemExit(0 if re.search(pattern, text, re.S) else 1)
PY
}
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
require_row "$home_page" ChoiceRow temperatureUnit
require_row "$home_page" SliderRow weatherRefreshMinutes
require_row "$home_page" SliderRow vitalsIntervalMs
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
require_row "$notifications_page" SliderRow "$setting"
done
python3 - "$notifications_page" <<'PY' || fail 'critical notification timeout does not render zero as Never'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(
r'SliderRow\s*\{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{).)*?'
r'setting\s*:\s*"notificationTimeoutCriticalMs"(?P<tail>.*?)\n\s*\}',
text,
re.S,
)
raise SystemExit(0 if block and re.search(r'zeroLabel\s*:\s*"Never"', block.group(0)) else 1)
PY
intelligence_page="$repo_dir/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml"
require_row "$intelligence_page" ChoiceRow screenshotDir
require_row "$intelligence_page" ChoiceRow recordingDir
require_row "$intelligence_page" ChoiceRow recorderArgs
# The source-only contract is safe during a shared Quickshell quiet window.
# The existing compositor integration checks remain available explicitly.
if [[ "${PANAMA_SETTINGS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'settings pages contract: PASS (static)\n'
exit 0
fi
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
source_config_path="$repo_dir/config/dot/quickshell"
config_path="$state_home/quickshell"
@@ -58,11 +137,6 @@ exit 97
EOF
chmod +x "$test_bin/flatpak"
fail() {
printf 'settings pages contract: %s\n' "$1" >&2
exit 1
}
qs_for_test() {
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
qs -p "$config_path" "$@"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml"
fail() {
printf 'settings sidebar layout contract: %s\n' "$1" >&2
exit 1
}
python3 - "$sidebar" <<'PY' || fail 'sidebar does not keep its header and footer pinned around one vertical scroll surface'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
def object_block(type_name: str, object_id: str) -> tuple[int, int, str]:
pattern = re.compile(
rf"\b{re.escape(type_name)}\s*\{{(?:(?!\n\s*[A-Za-z][A-Za-z0-9.]*\s*\{{).)*?"
rf"\bid\s*:\s*{re.escape(object_id)}\b",
re.S,
)
match = pattern.search(text)
if not match:
raise AssertionError(f"missing {type_name} id {object_id}")
start = match.start()
opening = text.index("{", start)
depth = 0
in_string = False
escaped = False
index = opening
while index < len(text):
character = text[index]
if in_string:
if escaped:
escaped = False
elif character == "\\":
escaped = True
elif character == '"':
in_string = False
elif character == '"':
in_string = True
elif character == "{":
depth += 1
elif character == "}":
depth -= 1
if depth == 0:
return start, index + 1, text[start:index + 1]
index += 1
raise AssertionError(f"unterminated {type_name} id {object_id}")
try:
header_start, header_end, header = object_block("Column", "sidebarHeader")
scroll_start, scroll_end, scroll = object_block("Flickable", "sidebarScroll")
footer_start, _, footer = object_block("Rectangle", "healthFooter")
assert header_start < scroll_start < scroll_end < footer_start
assert re.search(r"anchors\.top\s*:\s*parent\.top", header)
assert re.search(r"\bid\s*:\s*searchInput\b", header)
assert re.search(r"anchors\.top\s*:\s*sidebarHeader\.bottom", scroll)
assert re.search(r"anchors\.bottom\s*:\s*healthFooter\.top", scroll)
assert re.search(r"contentWidth\s*:\s*width", scroll)
assert re.search(r"contentHeight\s*:\s*scrollContent\.implicitHeight", scroll)
assert re.search(r"flickableDirection\s*:\s*Flickable\.VerticalFlick", scroll)
assert re.search(r"boundsBehavior\s*:\s*Flickable\.StopAtBounds", scroll)
assert re.search(r"clip\s*:\s*true", scroll)
assert scroll.count("Flickable {") == 1
assert re.search(r"\bid\s*:\s*scrollContent\b", scroll)
assert re.search(r"\bid\s*:\s*searchResults\b", scroll)
assert re.search(r"\bid\s*:\s*navigationList\b", scroll)
assert re.search(r"visible\s*:\s*root\.query\s*!==\s*\"\"", scroll)
assert re.search(r"visible\s*:\s*root\.query\s*===\s*\"\"", scroll)
assert re.search(r"anchors\.bottom\s*:\s*parent\.bottom", footer)
except AssertionError as error:
print(error, file=sys.stderr)
raise SystemExit(1)
PY
printf 'settings sidebar layout contract: PASS\n'