Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2cd79b547 | ||
|
|
0a18290137 | ||
|
|
efc53435a2 | ||
|
|
67674d297e | ||
|
|
2d4b126f14 | ||
|
|
5671324eb2 | ||
|
|
83391e0453 | ||
|
|
172b099a04 |
@@ -17,11 +17,88 @@
|
|||||||
-- overrides.lua) and read the notes in that file first.
|
-- overrides.lua) and read the notes in that file first.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
|
-- Per-output overrides written by Panama Settings, keyed by output name:
|
||||||
|
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
|
||||||
|
--
|
||||||
|
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||||
|
-- stay here, because those are the settings with a documented reason attached
|
||||||
|
-- (see the header) rather than preferences, and a settings page has no way to
|
||||||
|
-- explain the screencopy tradeoff at the moment you would be changing it.
|
||||||
|
local displays = prefs.get("displays", {})
|
||||||
|
if type(displays) ~= "table" then
|
||||||
|
displays = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mode_dimensions(mode)
|
||||||
|
if type(mode) ~= "string" then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
local width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+%.%d+)$")
|
||||||
|
if width == nil then
|
||||||
|
width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+)$")
|
||||||
|
end
|
||||||
|
width, height, refresh = tonumber(width), tonumber(height), tonumber(refresh)
|
||||||
|
if width == nil or height == nil or refresh == nil
|
||||||
|
or width <= 0 or height <= 0 or refresh <= 0 then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
return width, height
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_mode(mode)
|
||||||
|
local width = mode_dimensions(mode)
|
||||||
|
return width ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_scale(mode, scale)
|
||||||
|
local width, height = mode_dimensions(mode)
|
||||||
|
if width == nil or type(scale) ~= "number" or scale ~= scale
|
||||||
|
or scale <= 0 or scale > 4 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local logical_width = width / scale
|
||||||
|
local logical_height = height / scale
|
||||||
|
return math.abs(logical_width - math.floor(logical_width + 0.5)) < 0.0001
|
||||||
|
and math.abs(logical_height - math.floor(logical_height + 0.5)) < 0.0001
|
||||||
|
end
|
||||||
|
|
||||||
|
local function valid_transform(transform)
|
||||||
|
return type(transform) == "number"
|
||||||
|
and transform == math.floor(transform)
|
||||||
|
and transform >= 0
|
||||||
|
and transform <= 3
|
||||||
|
end
|
||||||
|
|
||||||
|
local function display_entry(output)
|
||||||
|
if type(output) ~= "string" or output == ""
|
||||||
|
or output:match("^[%w_.-]+$") == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local entry = displays[output]
|
||||||
|
if type(entry) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if not valid_mode(entry.mode)
|
||||||
|
or not valid_scale(entry.mode, entry.scale)
|
||||||
|
or not valid_transform(entry.transform) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return entry
|
||||||
|
end
|
||||||
|
|
||||||
|
local shipped_mode = "4500x3000@60"
|
||||||
|
local shipped_scale = 1.5
|
||||||
|
local shipped_transform = 0
|
||||||
|
local dp2 = display_entry("DP-2")
|
||||||
|
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "DP-2",
|
output = "DP-2",
|
||||||
mode = "4500x3000@60",
|
mode = dp2 and dp2.mode or shipped_mode,
|
||||||
position = "0x0",
|
position = "0x0",
|
||||||
scale = 1.5,
|
scale = dp2 and dp2.scale or shipped_scale,
|
||||||
|
transform = dp2 and dp2.transform or shipped_transform,
|
||||||
|
|
||||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
||||||
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
|
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
|
||||||
@@ -32,6 +109,24 @@ hl.monitor({
|
|||||||
cm = "auto",
|
cm = "auto",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- Other connected outputs use the same validated per-output store. They keep
|
||||||
|
-- automatic placement and the compositor's normal colour policy; DP-2 alone
|
||||||
|
-- carries the panel-specific 10-bit policy documented above.
|
||||||
|
for output, _ in pairs(displays) do
|
||||||
|
if output ~= "DP-2" then
|
||||||
|
local entry = display_entry(output)
|
||||||
|
if entry ~= nil then
|
||||||
|
hl.monitor({
|
||||||
|
output = output,
|
||||||
|
mode = entry.mode,
|
||||||
|
position = "auto",
|
||||||
|
scale = entry.scale,
|
||||||
|
transform = entry.transform,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Any monitor not named above: sane defaults rather than nothing.
|
-- Any monitor not named above: sane defaults rather than nothing.
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "",
|
output = "",
|
||||||
|
|||||||
@@ -508,6 +508,19 @@ Singleton {
|
|||||||
detail: "Shortcuts you have moved from their shipped chord"
|
detail: "Shortcuts you have moved from their shipped chord"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Display configuration ───────────────────────────────────────────
|
||||||
|
// { "<output>": { mode, scale, transform } }, applied by
|
||||||
|
// hypr/monitors.lua on top of the shipped values. Colour management and
|
||||||
|
// bit depth are deliberately not here: those carry a documented
|
||||||
|
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||||
|
// you would be changing it.
|
||||||
|
{
|
||||||
|
key: "displays", type: "json", def: ({}), group: "display",
|
||||||
|
internal: true,
|
||||||
|
label: "Display configuration",
|
||||||
|
detail: "Resolution, scale, and rotation per connected display"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Internal ────────────────────────────────────────────────────────
|
// ── Internal ────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "lastPage", type: "string", def: "home", group: "internal",
|
key: "lastPage", type: "string", def: "home", group: "internal",
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "displays-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
const monitor = Displays.monitors.length > 0 ? Displays.monitors[0] : null;
|
||||||
|
return JSON.stringify({
|
||||||
|
count: Displays.monitors.length,
|
||||||
|
name: monitor ? monitor.name : "",
|
||||||
|
width: monitor ? monitor.width : 0,
|
||||||
|
height: monitor ? monitor.height : 0,
|
||||||
|
refresh: monitor ? monitor.refreshRate : 0,
|
||||||
|
mode: monitor ? monitor.mode : "",
|
||||||
|
scale: monitor ? monitor.scale : 0,
|
||||||
|
transform: monitor ? monitor.transform : -1,
|
||||||
|
modes: monitor ? monitor.modes.length : 0,
|
||||||
|
awaiting: Displays.awaitingConfirmation,
|
||||||
|
canConfirm: Displays.canConfirm,
|
||||||
|
secondsLeft: Displays.secondsLeft,
|
||||||
|
lastError: Displays.lastError,
|
||||||
|
overridden: monitor ? Displays.isOverridden(monitor.name) : false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScale(scale: real): bool {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (!monitor) return false;
|
||||||
|
const mode = monitor.mode;
|
||||||
|
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshIdentityFixture(): string {
|
||||||
|
const modes = Displays.normaliseModes([
|
||||||
|
"[email protected]",
|
||||||
|
"[email protected]"
|
||||||
|
]);
|
||||||
|
const monitor = { width: 1920, height: 1080, refreshRate: 59.94 };
|
||||||
|
return JSON.stringify({
|
||||||
|
count: modes.length,
|
||||||
|
modes: modes.map(mode => mode.mode),
|
||||||
|
selected: modes.filter(mode => Displays.modeIsCurrent(monitor, mode)).map(mode => mode.mode)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBad(kind: string): bool {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (!monitor) return false;
|
||||||
|
const mode = monitor.mode;
|
||||||
|
if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform);
|
||||||
|
if (kind === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform);
|
||||||
|
if (kind === "dirtyScale") {
|
||||||
|
const dirty = Displays.scales.find(scale => !Displays.isScaleClean(mode, scale));
|
||||||
|
return dirty === undefined ? false : Displays.apply(monitor.name, mode, dirty, monitor.transform);
|
||||||
|
}
|
||||||
|
if (kind === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9);
|
||||||
|
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmChange(): bool { return Displays.confirm(); }
|
||||||
|
function revertChange(): void { Displays.revert(); }
|
||||||
|
function forget(): void {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (monitor) Displays.forget(monitor.name);
|
||||||
|
}
|
||||||
|
function refresh(): void { Displays.refresh(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// A row of choices that wraps, for options that do not fit a segmented control.
|
||||||
|
//
|
||||||
|
// ChoiceRow puts two or three options on one line. Scales and rotations are
|
||||||
|
// more numerous and their labels are wider, so they wrap into a grid rather
|
||||||
|
// than shrinking to illegibility on a narrow, tiled window.
|
||||||
|
//
|
||||||
|
// Unlike ChoiceRow this is not schema-bound: it reports a value and lets the
|
||||||
|
// caller decide what to do with it, because a display change has to go through
|
||||||
|
// an apply-then-confirm cycle rather than straight into the store.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property string detail: ""
|
||||||
|
property var options: []
|
||||||
|
property var current: null
|
||||||
|
property bool enabled: true
|
||||||
|
property bool divider: true
|
||||||
|
|
||||||
|
signal picked(var value)
|
||||||
|
|
||||||
|
spacing: 9
|
||||||
|
bottomPadding: 12
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.label
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.detail !== ""
|
||||||
|
text: root.detail
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 7
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.options
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: option
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool selected: root.current === option.modelData.value
|
||||||
|
|
||||||
|
implicitWidth: Math.max(78, caption.implicitWidth + 26)
|
||||||
|
implicitHeight: 32
|
||||||
|
radius: 9
|
||||||
|
opacity: root.enabled ? 1 : 0.45
|
||||||
|
color: option.selected ? "transparent" : Theme.alpha(Theme.fg, hover.hovered && root.enabled ? 0.11 : 0.06)
|
||||||
|
border.width: option.selected ? 1 : 0
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
|
||||||
|
// The prism marks the selection here as everywhere else.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
visible: option.selected
|
||||||
|
border.width: 0
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||||
|
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: caption
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: option.modelData.label
|
||||||
|
color: option.selected ? Theme.fg : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: option.selected ? Font.DemiBold : Font.Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: hover
|
||||||
|
enabled: root.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
enabled: root.enabled && !option.selected
|
||||||
|
onTapped: root.picked(option.modelData.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
visible: root.divider
|
||||||
|
color: Theme.alpha(Theme.fg, 0.065)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// The resolution list for one display.
|
||||||
|
//
|
||||||
|
// Grouped by resolution with refresh rates beside it, rather than a flat list
|
||||||
|
// of "[email protected]" strings: this panel reports 35 modes, many of which
|
||||||
|
// differ only in refresh-rate rounding, and a flat list of those is a wall of
|
||||||
|
// near-identical text rather than a choice.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var monitor: null
|
||||||
|
property bool enabled: true
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
readonly property var grouped: {
|
||||||
|
if (!root.monitor)
|
||||||
|
return [];
|
||||||
|
const buckets = {};
|
||||||
|
const order = [];
|
||||||
|
for (const mode of root.monitor.modes) {
|
||||||
|
const key = mode.label;
|
||||||
|
if (!buckets[key]) {
|
||||||
|
buckets[key] = { label: key, width: mode.width, height: mode.height, rates: [] };
|
||||||
|
order.push(key);
|
||||||
|
}
|
||||||
|
buckets[key].rates.push(mode);
|
||||||
|
}
|
||||||
|
return order.map(key => buckets[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.grouped
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: resolution
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool isCurrent: root.monitor
|
||||||
|
&& root.monitor.width === resolution.modelData.width
|
||||||
|
&& root.monitor.height === resolution.modelData.height
|
||||||
|
|
||||||
|
label: resolution.modelData.label
|
||||||
|
detail: resolution.isCurrent ? "Current resolution" : ""
|
||||||
|
controlWidth: Math.max(120, resolution.modelData.rates.length * 84)
|
||||||
|
divider: resolution.index < root.grouped.length - 1
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: resolution.modelData.rates
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: rate
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool selected: resolution.isCurrent
|
||||||
|
&& Displays.modeIsCurrent(root.monitor, rate.modelData)
|
||||||
|
|
||||||
|
implicitWidth: Math.max(74, rateCaption.implicitWidth + 22)
|
||||||
|
implicitHeight: 30
|
||||||
|
radius: 9
|
||||||
|
opacity: root.enabled ? 1 : 0.45
|
||||||
|
color: rate.selected ? "transparent" : Theme.alpha(Theme.fg, rateHover.hovered && root.enabled ? 0.11 : 0.06)
|
||||||
|
border.width: rate.selected ? 1 : 0
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
visible: rate.selected
|
||||||
|
border.width: 0
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||||
|
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: rateCaption
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: rate.modelData.refreshLabel
|
||||||
|
color: rate.selected ? Theme.fg : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: rate.selected ? Font.DemiBold : Font.Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: rateHover
|
||||||
|
enabled: root.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
enabled: root.enabled && !rate.selected
|
||||||
|
onTapped: Displays.apply(
|
||||||
|
root.monitor.name,
|
||||||
|
rate.modelData.mode,
|
||||||
|
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
||||||
|
root.monitor.transform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,81 +1,219 @@
|
|||||||
|
// Displays.
|
||||||
|
//
|
||||||
|
// Resolution, refresh rate, scale, and rotation, plus the gaming display
|
||||||
|
// policy that was already here.
|
||||||
|
//
|
||||||
|
// Every geometry change goes through an apply-then-confirm countdown. This is
|
||||||
|
// the one page where a wrong value can leave the screen unreadable or blank,
|
||||||
|
// and no other control in the app can undo it once that happens. Confirming is
|
||||||
|
// what writes the choice to the settings store; letting the countdown run
|
||||||
|
// leaves nothing behind.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
title: "Displays"
|
title: "Displays"
|
||||||
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
||||||
|
|
||||||
|
property string selectedOutput: ""
|
||||||
|
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
||||||
|
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
|
||||||
|
readonly property string currentMode: root.monitor
|
||||||
|
? root.monitor.mode
|
||||||
|
: ""
|
||||||
|
|
||||||
|
function syncSelectedOutput(): void {
|
||||||
|
if (!Displays.monitorNamed(root.selectedOutput))
|
||||||
|
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.syncSelectedOutput()
|
||||||
|
Connections {
|
||||||
|
target: Displays
|
||||||
|
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The confirmation sits above everything, because while it is counting down
|
||||||
|
// it is the only thing that matters on this page.
|
||||||
|
header: Component {
|
||||||
|
Rectangle {
|
||||||
|
visible: Displays.awaitingConfirmation
|
||||||
|
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.warn, 0.4)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: confirmRow
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 14
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width - keepButton.width - revertButton.width - 28
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Keep this display setting?"
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Reverting in " + Displays.secondsLeft + (Displays.secondsLeft === 1 ? " second" : " seconds")
|
||||||
|
+ " if you do nothing. If you cannot read this, just wait."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: revertButton
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Revert now"
|
||||||
|
onClicked: Displays.revert()
|
||||||
|
}
|
||||||
|
SettingsButton {
|
||||||
|
id: keepButton
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Keep"
|
||||||
|
enabled: Displays.canConfirm
|
||||||
|
onClicked: Displays.confirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: SystemSettings.monitorName || "Active display"
|
visible: Displays.monitors.length > 1
|
||||||
subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
|
title: "Connected display"
|
||||||
TextRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
|
subtitle: "Choose the output whose resolution, scale, and rotation you want to adjust."
|
||||||
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 }
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Display"
|
||||||
|
options: Displays.monitors.map(monitor => ({
|
||||||
|
value: monitor.name,
|
||||||
|
label: monitor.description || monitor.name
|
||||||
|
}))
|
||||||
|
current: root.monitor ? root.monitor.name : ""
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
divider: false
|
||||||
|
onPicked: value => root.selectedOutput = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display")
|
||||||
|
subtitle: root.monitor
|
||||||
|
? `${root.monitor.description} · ${root.monitor.width} × ${root.monitor.height} at ${Math.round(root.monitor.refreshRate)} Hz · ${root.monitor.scale.toFixed(2)}× scale`
|
||||||
|
: "Reading the active display…"
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Color mode"
|
||||||
|
detail: "Wide-gamut SDR at 10-bit. Full-time HDR is left to the Hyprland config: it currently breaks screenshots, OBS, and the lock screen's blurred background."
|
||||||
|
value: root.monitor
|
||||||
|
? `${root.monitor.colorPreset || "standard"} · ${root.monitor.currentFormat || "detecting format"}`
|
||||||
|
: "Detecting"
|
||||||
|
}
|
||||||
|
TextRow {
|
||||||
|
label: "Variable refresh"
|
||||||
|
detail: root.monitor && root.monitor.vrr
|
||||||
|
? "Active on this output for current fullscreen content"
|
||||||
|
: "This output is ready when game or video content requests it"
|
||||||
|
value: root.monitor && root.monitor.vrr ? "Active" : "Standby"
|
||||||
|
divider: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||||
|
}
|
||||||
|
ActionRow {
|
||||||
|
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||||
|
label: "Using a custom display setting"
|
||||||
|
detail: "Forget it to go back to the resolution and scale Panama ships"
|
||||||
|
action: "Forget"
|
||||||
|
divider: false
|
||||||
|
onTriggered: Displays.forget(root.monitor.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.monitor !== null
|
||||||
|
title: "Resolution"
|
||||||
|
subtitle: "Applied straight away, then reverted automatically unless you confirm."
|
||||||
|
|
||||||
|
DisplayModePicker {
|
||||||
|
width: parent.width
|
||||||
|
monitor: root.monitor
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.monitor !== null
|
||||||
|
title: "Scale and rotation"
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Scale"
|
||||||
|
detail: "Fractional scales that do not divide the resolution into whole pixels are rejected by the compositor, so only clean ones are offered."
|
||||||
|
options: Displays.scalesForMode(root.currentMode)
|
||||||
|
.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
|
||||||
|
current: root.monitor ? root.monitor.scale : 1
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
onPicked: value => root.applyWith({ scale: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Rotation"
|
||||||
|
options: Displays.transforms
|
||||||
|
current: root.monitor ? root.monitor.transform : 0
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
divider: false
|
||||||
|
onPicked: value => root.applyWith({ transform: value })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Gaming display policy"
|
title: "Gaming display policy"
|
||||||
subtitle: "These values apply immediately and are restored when Panama starts."
|
subtitle: "Applied immediately and restored when Panama starts."
|
||||||
SettingRow {
|
|
||||||
label: "Game-aware HDR"
|
ToggleRow { setting: "autoHdr" }
|
||||||
detail: "Enter HDR only for fullscreen content that requests it"
|
ChoiceRow { setting: "vrrPolicy" }
|
||||||
controlWidth: 48
|
ChoiceRow { setting: "directScanoutPolicy"; divider: false }
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Content-aware VRR"
|
|
||||||
detail: "Enable variable refresh only for game and video content"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Direct scanout for games"
|
|
||||||
detail: "Bypass compositing only for windows classified as games"
|
|
||||||
divider: false
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Night Light"
|
visible: Displays.lastError !== ""
|
||||||
SettingRow {
|
title: "Display problem"
|
||||||
label: "Warm display colors"
|
subtitle: Displays.lastError
|
||||||
detail: NightLight.automatic ? "Following the evening schedule" : "Manual control"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Color temperature"
|
|
||||||
detail: `${NightLight.temperature} K`
|
|
||||||
divider: false
|
|
||||||
controlWidth: 230
|
|
||||||
ValueSlider {
|
|
||||||
anchors.fill: parent
|
|
||||||
value: (6500 - NightLight.temperature) / 4000
|
|
||||||
icon: "weather-clear-night-symbolic"
|
|
||||||
onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
// Applies a change to one field, keeping the others at what is in effect.
|
||||||
width: parent.width
|
function applyWith(change: var): void {
|
||||||
height: warningText.implicitHeight + 30
|
if (!root.monitor)
|
||||||
radius: Theme.cardRadius
|
return;
|
||||||
color: Theme.alpha(Theme.warn, 0.085)
|
const mode = change.mode ?? root.currentMode;
|
||||||
border.width: 1
|
const requestedScale = change.scale ?? root.monitor.scale;
|
||||||
border.color: Theme.alpha(Theme.warn, 0.22)
|
Displays.apply(
|
||||||
Text {
|
root.monitor.name,
|
||||||
id: warningText
|
mode,
|
||||||
anchors.fill: parent
|
Displays.isScaleClean(mode, requestedScale)
|
||||||
anchors.margins: 15
|
? requestedScale
|
||||||
text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games."
|
: Displays.nearestCleanScale(mode, requestedScale),
|
||||||
color: Theme.mix(Theme.fg, Theme.warn, 0.25)
|
change.transform ?? root.monitor.transform);
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,31 @@ Item {
|
|||||||
// scrolls -- the Appearance page pins its live preview here.
|
// scrolls -- the Appearance page pins its live preview here.
|
||||||
property Component header: null
|
property Component header: null
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: pinnedHeader
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.leftMargin: 34
|
||||||
|
anchors.rightMargin: 34
|
||||||
|
anchors.topMargin: 30
|
||||||
|
active: root.header !== null
|
||||||
|
sourceComponent: root.header
|
||||||
|
z: 1
|
||||||
|
}
|
||||||
|
|
||||||
Flickable {
|
Flickable {
|
||||||
anchors.fill: parent
|
id: pageScroll
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: pinnedHeader.implicitHeight > 0 ? pinnedHeader.bottom : parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.topMargin: pinnedHeader.implicitHeight > 0 ? 16 : 0
|
||||||
clip: true
|
clip: true
|
||||||
contentWidth: width
|
contentWidth: width
|
||||||
contentHeight: layout.implicitHeight + 64
|
contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64)
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@@ -40,15 +60,9 @@ Item {
|
|||||||
|
|
||||||
width: parent.width - 68
|
width: parent.width - 68
|
||||||
x: 34
|
x: 34
|
||||||
y: 30
|
y: pinnedHeader.implicitHeight > 0 ? 0 : 30
|
||||||
spacing: 16
|
spacing: 16
|
||||||
|
|
||||||
Loader {
|
|
||||||
width: parent.width
|
|
||||||
active: root.header !== null
|
|
||||||
sourceComponent: root.header
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: root.title !== ""
|
visible: root.title !== ""
|
||||||
|
|||||||
@@ -35,3 +35,5 @@ ApplicationsPage 1.0 ApplicationsPage.qml
|
|||||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||||
DockAppPicker 1.0 DockAppPicker.qml
|
DockAppPicker 1.0 DockAppPicker.qml
|
||||||
ShortcutCapture 1.0 ShortcutCapture.qml
|
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||||
|
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||||
|
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||||
|
|||||||
@@ -1,90 +1,625 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
# Snapshots of the Panama settings store.
|
"""Crash-safe snapshots of Panama's desktop and Home preference stores.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
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"
|
from __future__ import annotations
|
||||||
backup_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama/backups"
|
|
||||||
keep=15
|
|
||||||
|
|
||||||
fail() {
|
import json
|
||||||
printf '%s\n' "$1" >&2
|
import os
|
||||||
exit 1
|
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)
|
HOME = Path(os.environ.get("HOME", str(Path.home())))
|
||||||
mkdir -p "$backup_dir"
|
CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
|
||||||
first=true
|
STATE_ROOT = Path(os.environ.get("XDG_STATE_HOME", str(HOME / ".local/state")))
|
||||||
printf '['
|
SETTINGS = CONFIG_ROOT / "panama/settings.json"
|
||||||
for file in $(ls -1t "$backup_dir"/settings-*.json 2>/dev/null); do
|
HOME_STATE = STATE_ROOT / "panama/panama-home.json"
|
||||||
name="$(basename "$file")"
|
BACKUP_DIR = STATE_ROOT / "panama/backups"
|
||||||
# settings-20260818-004512.json -> 2026-08-18 00:45
|
TRANSACTION_PARENT = STATE_ROOT / "panama/transactions"
|
||||||
raw="${name#settings-}"; raw="${raw%.json}"
|
TRANSACTION_DIR = TRANSACTION_PARENT / "settings-restore"
|
||||||
pretty="${raw:0:4}-${raw:4:2}-${raw:6:2} ${raw:9:2}:${raw:11:2}:${raw:13:2}"
|
JOURNAL = TRANSACTION_DIR / "journal.json"
|
||||||
keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)"
|
LOCK_FILE = TRANSACTION_PARENT / "settings-backup.lock"
|
||||||
[[ "$first" == true ]] || printf ','
|
KEEP = 15
|
||||||
first=false
|
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
|
||||||
printf '{"name":"%s","when":"%s","keys":%s}' "$name" "$pretty" "$keys"
|
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
|
||||||
done
|
|
||||||
printf ']\n'
|
|
||||||
;;
|
|
||||||
|
|
||||||
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.
|
class BackupError(RuntimeError):
|
||||||
if [[ -r "$settings" ]] && jq -e . "$settings" >/dev/null 2>&1; then
|
pass
|
||||||
mkdir -p "$backup_dir"
|
|
||||||
cp "$settings" "$backup_dir/settings-$(date +%Y%m%d-%H%M%S%3N).json"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$settings")"
|
|
||||||
cp "$source_file" "$settings.tmp"
|
|
||||||
mv "$settings.tmp" "$settings"
|
|
||||||
printf '{"restored":"%s"}\n' "$name"
|
|
||||||
;;
|
|
||||||
|
|
||||||
*)
|
def fail(message: str) -> NoReturn:
|
||||||
fail "usage: panama-settings-backup [save|list|restore <name>]"
|
raise BackupError(message)
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
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,465 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Display configuration: resolution, refresh rate, scale, and rotation.
|
||||||
|
//
|
||||||
|
// This is the only page in Panama Settings where a wrong value can leave you
|
||||||
|
// unable to SEE the screen well enough to undo it. A mode the display cannot
|
||||||
|
// show, or a scale that makes everything unreadable, is not recoverable through
|
||||||
|
// the same UI that caused it.
|
||||||
|
//
|
||||||
|
// So a change is never applied irreversibly. It is applied, then reverted
|
||||||
|
// automatically after a countdown unless confirmed -- the same contract every
|
||||||
|
// desktop uses for this one setting, and for the same reason. Confirming is
|
||||||
|
// what writes it to the settings store; letting the countdown run leaves
|
||||||
|
// nothing behind.
|
||||||
|
//
|
||||||
|
// Applied with `hyprctl eval` and hl.monitor{}. As everywhere else in Panama,
|
||||||
|
// success means the value was read back from the compositor and matched, never
|
||||||
|
// that a command exited zero.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// [{ name, description, width, height, refreshRate, scale, transform,
|
||||||
|
// modes: [{ label, mode, width, height, refresh }] }]
|
||||||
|
property var monitors: []
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// Set while a change is applied but not yet confirmed.
|
||||||
|
property string pendingOutput: ""
|
||||||
|
property var pendingPrevious: null
|
||||||
|
property var pendingRequested: null
|
||||||
|
property bool pendingVerified: false
|
||||||
|
property bool revertQueued: false
|
||||||
|
property var revertExpected: null
|
||||||
|
property string revertReason: ""
|
||||||
|
property bool revertVerificationActive: false
|
||||||
|
property int operationGeneration: 0
|
||||||
|
property int revertGeneration: -1
|
||||||
|
property int secondsLeft: 0
|
||||||
|
|
||||||
|
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
|
||||||
|
readonly property bool canConfirm: root.awaitingConfirmation
|
||||||
|
&& root.pendingVerified
|
||||||
|
&& !root.busy
|
||||||
|
readonly property bool busy: query.running || applyRun.running || revertRun.running
|
||||||
|
|| root.revertExpected !== null
|
||||||
|
|
||||||
|
readonly property int confirmSeconds: 15
|
||||||
|
|
||||||
|
readonly property var transforms: [
|
||||||
|
{ value: 0, label: "Landscape" },
|
||||||
|
{ value: 1, label: "Portrait" },
|
||||||
|
{ value: 2, label: "Landscape (flipped)" },
|
||||||
|
{ value: 3, label: "Portrait (flipped)" }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Scales that divide this desktop's common resolutions into whole pixels.
|
||||||
|
// Hyprland rejects a fractional scale that does not, and the message it
|
||||||
|
// gives is not something to put in front of a user.
|
||||||
|
readonly property var scales: [1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
property int generation: 0
|
||||||
|
command: ["hyprctl", "-j", "monitors"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.parse(this.text, query.generation)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.lastError = "Could not read the connected displays.";
|
||||||
|
if (root.revertQueued && !applyRun.running && root.awaitingConfirmation)
|
||||||
|
root.performRevert();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: applyRun
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (!root.awaitingConfirmation)
|
||||||
|
return;
|
||||||
|
if (root.revertQueued) {
|
||||||
|
if (!query.running)
|
||||||
|
root.performRevert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verifyTimer.attempts = 0;
|
||||||
|
verifyTimer.ticks = 0;
|
||||||
|
verifyTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: revertRun
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
// Exit status is advisory only. Hyprland's Lua bridge can report
|
||||||
|
// success without applying a value, so exact readback decides.
|
||||||
|
root.revertVerificationActive = true;
|
||||||
|
revertVerifyTimer.attempts = 0;
|
||||||
|
revertVerifyTimer.ticks = 0;
|
||||||
|
revertVerifyTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
function refresh(): bool {
|
||||||
|
if (!query.running) {
|
||||||
|
query.generation = root.operationGeneration;
|
||||||
|
query.running = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(text: string, generation: int): void {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(text);
|
||||||
|
root.monitors = raw.map(monitor => {
|
||||||
|
const modes = root.normaliseModes(monitor.availableModes ?? []);
|
||||||
|
const width = monitor.width ?? 0;
|
||||||
|
const height = monitor.height ?? 0;
|
||||||
|
const refreshRate = monitor.refreshRate ?? 0;
|
||||||
|
const current = modes
|
||||||
|
.filter(mode => mode.width === width && mode.height === height)
|
||||||
|
.sort((left, right) =>
|
||||||
|
Math.abs(left.refresh - refreshRate) - Math.abs(right.refresh - refreshRate))[0];
|
||||||
|
return {
|
||||||
|
name: monitor.name ?? "",
|
||||||
|
description: monitor.description ?? monitor.model ?? "Display",
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
refreshRate: refreshRate,
|
||||||
|
mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
|
||||||
|
scale: monitor.scale ?? 1,
|
||||||
|
transform: monitor.transform ?? 0,
|
||||||
|
currentFormat: monitor.currentFormat ?? "",
|
||||||
|
colorPreset: monitor.colorManagementPreset ?? "",
|
||||||
|
vrr: monitor.vrr === true,
|
||||||
|
modes: modes
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (root.awaitingConfirmation && root.pendingRequested
|
||||||
|
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||||
|
root.pendingVerified = true;
|
||||||
|
verifyTimer.stop();
|
||||||
|
root.lastError = "";
|
||||||
|
} else if (root.revertVerificationActive
|
||||||
|
&& generation === root.revertGeneration
|
||||||
|
&& root.revertExpected
|
||||||
|
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
|
||||||
|
revertVerifyTimer.stop();
|
||||||
|
root.revertVerificationActive = false;
|
||||||
|
root.revertGeneration = -1;
|
||||||
|
root.revertExpected = null;
|
||||||
|
if (root.revertReason === "")
|
||||||
|
root.lastError = "";
|
||||||
|
else
|
||||||
|
root.lastError = root.revertReason;
|
||||||
|
root.revertReason = "";
|
||||||
|
} else if (!root.awaitingConfirmation && !root.revertExpected && (
|
||||||
|
root.lastError === "Could not read the connected displays."
|
||||||
|
|| root.lastError === "The display list could not be read.")) {
|
||||||
|
root.lastError = "";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
root.lastError = "The display list could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "[email protected]" -> a sortable record. The compositor reports the same
|
||||||
|
// resolution at distinct rates such as 60.00 and 59.94. Those identities
|
||||||
|
// remain separate because confirmation and recovery must read back the
|
||||||
|
// exact mode the user chose, even when their rounded labels look similar.
|
||||||
|
function normaliseModes(raw: var): var {
|
||||||
|
const seen = {};
|
||||||
|
const out = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
const match = String(entry).match(/^(\d+)x(\d+)@([\d.]+)Hz$/);
|
||||||
|
if (!match)
|
||||||
|
continue;
|
||||||
|
const width = Number(match[1]);
|
||||||
|
const height = Number(match[2]);
|
||||||
|
const refreshText = match[3];
|
||||||
|
const refresh = Number(refreshText);
|
||||||
|
const roundedRefresh = Math.round(refresh);
|
||||||
|
const key = `${width}x${height}@${refreshText}`;
|
||||||
|
if (seen[key])
|
||||||
|
continue;
|
||||||
|
seen[key] = true;
|
||||||
|
out.push({
|
||||||
|
label: `${width} × ${height}`,
|
||||||
|
refreshLabel: Math.abs(refresh - roundedRefresh) < 0.005
|
||||||
|
? `${roundedRefresh} Hz`
|
||||||
|
: `${refresh.toFixed(2)} Hz`,
|
||||||
|
mode: `${width}x${height}@${refreshText}`,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
refresh: refresh
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.refresh - a.refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monitorNamed(name: string): var {
|
||||||
|
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeParts(mode: string): var {
|
||||||
|
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!match)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
width: Number(match[1]),
|
||||||
|
height: Number(match[2]),
|
||||||
|
refresh: Number(match[3])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScaleClean(mode: string, scale: real): bool {
|
||||||
|
const parts = root.modeParts(mode);
|
||||||
|
if (!parts || root.scales.indexOf(scale) < 0 || !Number.isFinite(scale) || scale <= 0)
|
||||||
|
return false;
|
||||||
|
const logicalWidth = parts.width / scale;
|
||||||
|
const logicalHeight = parts.height / scale;
|
||||||
|
return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.0001
|
||||||
|
&& Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.0001;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalesForMode(mode: string): var {
|
||||||
|
return root.scales.filter(scale => root.isScaleClean(mode, scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestCleanScale(mode: string, preferred: real): real {
|
||||||
|
const choices = root.scalesForMode(mode);
|
||||||
|
if (choices.length === 0)
|
||||||
|
return 1.0;
|
||||||
|
return choices.reduce((best, candidate) =>
|
||||||
|
Math.abs(candidate - preferred) < Math.abs(best - preferred) ? candidate : best,
|
||||||
|
choices[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRequest(monitor: var, requested: var): bool {
|
||||||
|
if (!monitor || !requested || monitor.name !== requested.output)
|
||||||
|
return false;
|
||||||
|
const parts = root.modeParts(requested.mode);
|
||||||
|
return !!parts
|
||||||
|
&& monitor.width === parts.width
|
||||||
|
&& monitor.height === parts.height
|
||||||
|
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01
|
||||||
|
&& Math.abs(monitor.scale - requested.scale) < 0.001
|
||||||
|
&& monitor.transform === requested.transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeIsCurrent(monitor: var, candidate: var): bool {
|
||||||
|
return !!monitor && !!candidate
|
||||||
|
&& monitor.width === candidate.width
|
||||||
|
&& monitor.height === candidate.height
|
||||||
|
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||||
|
// settings file is only written by confirm().
|
||||||
|
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
||||||
|
if (root.busy) {
|
||||||
|
root.lastError = "Wait for the current display operation to finish.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (root.awaitingConfirmation) {
|
||||||
|
root.lastError = "Finish the current display change first.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const monitor = root.monitorNamed(output);
|
||||||
|
if (!monitor) {
|
||||||
|
root.lastError = "That display is not connected.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
|
||||||
|
root.lastError = "That display does not offer that mode.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!root.isScaleClean(mode, scale)) {
|
||||||
|
root.lastError = "That scale does not divide this resolution cleanly.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!root.transforms.some(candidate => candidate.value === transform)) {
|
||||||
|
root.lastError = "That rotation is not one Panama offers.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.pendingPrevious = {
|
||||||
|
output: output,
|
||||||
|
mode: monitor.mode,
|
||||||
|
scale: monitor.scale,
|
||||||
|
transform: monitor.transform
|
||||||
|
};
|
||||||
|
root.operationGeneration++;
|
||||||
|
root.pendingRequested = {
|
||||||
|
output: output,
|
||||||
|
mode: mode,
|
||||||
|
scale: scale,
|
||||||
|
transform: transform
|
||||||
|
};
|
||||||
|
root.pendingOutput = output;
|
||||||
|
root.pendingVerified = false;
|
||||||
|
root.revertQueued = false;
|
||||||
|
root.secondsLeft = root.confirmSeconds;
|
||||||
|
root.lastError = "";
|
||||||
|
countdown.restart();
|
||||||
|
|
||||||
|
root.push(output, mode, scale, transform);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(output: string, mode: string, scale: real, transform: int): void {
|
||||||
|
// Values are validated above and the output name comes from the
|
||||||
|
// compositor's own list, so nothing user-authored reaches the payload.
|
||||||
|
applyRun.exec(["hyprctl", "eval",
|
||||||
|
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm(): bool {
|
||||||
|
if (!root.canConfirm || !root.matchesRequest(
|
||||||
|
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||||
|
if (root.awaitingConfirmation)
|
||||||
|
root.lastError = "Wait for the display to finish applying before keeping it.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
||||||
|
next[root.pendingOutput] = {
|
||||||
|
mode: root.pendingRequested.mode,
|
||||||
|
scale: root.pendingRequested.scale,
|
||||||
|
transform: root.pendingRequested.transform
|
||||||
|
};
|
||||||
|
if (!DesktopPreferences.set("displays", next)) {
|
||||||
|
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.clearPending();
|
||||||
|
root.lastError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPending(): void {
|
||||||
|
countdown.stop();
|
||||||
|
verifyTimer.stop();
|
||||||
|
root.pendingOutput = "";
|
||||||
|
root.pendingPrevious = null;
|
||||||
|
root.pendingRequested = null;
|
||||||
|
root.pendingVerified = false;
|
||||||
|
root.revertQueued = false;
|
||||||
|
root.secondsLeft = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revert(): void {
|
||||||
|
root.revertWithMessage("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function revertWithMessage(message: string): void {
|
||||||
|
if (!root.awaitingConfirmation)
|
||||||
|
return;
|
||||||
|
countdown.stop();
|
||||||
|
verifyTimer.stop();
|
||||||
|
root.pendingVerified = false;
|
||||||
|
root.revertReason = message;
|
||||||
|
if (message !== "")
|
||||||
|
root.lastError = message;
|
||||||
|
if (applyRun.running || query.running) {
|
||||||
|
root.revertQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.performRevert();
|
||||||
|
}
|
||||||
|
|
||||||
|
function performRevert(): void {
|
||||||
|
const previous = root.pendingPrevious;
|
||||||
|
root.operationGeneration++;
|
||||||
|
root.revertGeneration = root.operationGeneration;
|
||||||
|
root.revertExpected = previous;
|
||||||
|
root.revertVerificationActive = false;
|
||||||
|
root.clearPending();
|
||||||
|
if (previous) {
|
||||||
|
revertRun.exec(["hyprctl", "eval",
|
||||||
|
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears any stored override for an output so it returns to the value
|
||||||
|
// shipped in hypr/monitors.lua on the next start.
|
||||||
|
function forget(output: string): void {
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
if (!stored || typeof stored !== "object" || stored[output] === undefined)
|
||||||
|
return;
|
||||||
|
const next = Object.assign({}, stored);
|
||||||
|
delete next[output];
|
||||||
|
DesktopPreferences.set("displays", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverridden(output: string): bool {
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: verifyTimer
|
||||||
|
property int attempts: 0
|
||||||
|
property int ticks: 0
|
||||||
|
interval: 120
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
ticks++;
|
||||||
|
if (ticks > 50) {
|
||||||
|
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.refresh())
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: revertVerifyTimer
|
||||||
|
property int attempts: 0
|
||||||
|
property int ticks: 0
|
||||||
|
interval: 120
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
ticks++;
|
||||||
|
if (ticks > 50) {
|
||||||
|
stop();
|
||||||
|
root.revertVerificationActive = false;
|
||||||
|
root.revertGeneration = -1;
|
||||||
|
root.revertExpected = null;
|
||||||
|
root.revertReason = "";
|
||||||
|
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.refresh())
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: countdown
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
root.secondsLeft -= 1;
|
||||||
|
if (root.secondsLeft <= 0)
|
||||||
|
root.revert();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
pragma Singleton
|
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
|
// DesktopPreferences and HomePreferences use separate files. The helper owns
|
||||||
// restore is an overwrite. Worth exposing now that the settings app changes
|
// the transactional filesystem boundary; this service owns settling the live
|
||||||
// real things -- compositor geometry, idle timeouts, the dock -- because being
|
// desktop after those files have changed underneath it.
|
||||||
// able to return to a known-good state is what makes experimenting feel safe.
|
|
||||||
//
|
//
|
||||||
// Restoring rewrites the file underneath the running shell, so the store is
|
// HomePreferences intentionally keeps its FileView in Quickshell's private
|
||||||
// told to re-read afterwards rather than waiting for the next change.
|
// 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
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
@@ -24,7 +25,31 @@ Singleton {
|
|||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
property string lastAction: ""
|
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
|
readonly property bool busy: listQuery.running || actionRun.running
|
||||||
|
|| applyRestoredState.running || settleReload.running
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: listQuery
|
id: listQuery
|
||||||
@@ -34,7 +59,8 @@ Singleton {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(this.text);
|
const parsed = JSON.parse(this.text);
|
||||||
root.snapshots = Array.isArray(parsed) ? parsed : [];
|
root.snapshots = Array.isArray(parsed) ? parsed : [];
|
||||||
root.lastError = "";
|
if (root.lastError === "Could not read the list of snapshots.")
|
||||||
|
root.lastError = "";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
root.lastError = "Could not read the list of snapshots.";
|
root.lastError = "Could not read the list of snapshots.";
|
||||||
}
|
}
|
||||||
@@ -45,6 +71,11 @@ Singleton {
|
|||||||
Process {
|
Process {
|
||||||
id: actionRun
|
id: actionRun
|
||||||
property bool restoring: false
|
property bool restoring: false
|
||||||
|
property string outputText: ""
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: actionRun.outputText = this.text
|
||||||
|
}
|
||||||
|
onStarted: actionRun.outputText = ""
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.lastError = actionRun.restoring
|
root.lastError = actionRun.restoring
|
||||||
@@ -52,14 +83,52 @@ Singleton {
|
|||||||
: "The settings could not be backed up.";
|
: "The settings could not be backed up.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.lastError = "";
|
|
||||||
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||||
if (actionRun.restoring)
|
if (actionRun.restoring) {
|
||||||
DesktopPreferences.reload();
|
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();
|
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()
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
function refresh(): void {
|
function refresh(): void {
|
||||||
@@ -71,7 +140,81 @@ Singleton {
|
|||||||
if (actionRun.running)
|
if (actionRun.running)
|
||||||
return;
|
return;
|
||||||
actionRun.restoring = false;
|
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
|
// The name is matched against the snapshot list rather than trusted, so no
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+300
@@ -0,0 +1,300 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Display configuration.
|
||||||
|
#
|
||||||
|
# This is the only setting in Panama that can leave the user unable to SEE the
|
||||||
|
# screen well enough to undo it: a mode the panel cannot show, or a scale that
|
||||||
|
# makes everything unreadable, is not recoverable through the UI that caused it.
|
||||||
|
#
|
||||||
|
# So the property under test is not "can it change the resolution" but "does an
|
||||||
|
# unconfirmed change always come back". A regression here is not a broken
|
||||||
|
# feature, it is a user staring at a blank monitor.
|
||||||
|
#
|
||||||
|
# * an unconfirmed change reverts on its own, and stores nothing
|
||||||
|
# * a confirmed change is what writes to the settings store
|
||||||
|
# * a mode, scale, rotation, or output the compositor did not offer is refused
|
||||||
|
# before anything is applied
|
||||||
|
#
|
||||||
|
# The compositor is the live one -- there is no way to test this otherwise --
|
||||||
|
# but preferences are isolated, and every path restores the display it started
|
||||||
|
# from.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
|
||||||
|
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
|
||||||
|
page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml"
|
||||||
|
settings_page="$repo_dir/config/dot/quickshell/modules/settings/SettingsPage.qml"
|
||||||
|
monitors_lua="$repo_dir/config/dot/hypr/monitors.lua"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'displays contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep is unavailable until compositor readback exactly matches the request.
|
||||||
|
for contract in \
|
||||||
|
'property var pendingRequested:' \
|
||||||
|
'property var revertExpected:' \
|
||||||
|
'property bool revertVerificationActive:' \
|
||||||
|
'property int revertGeneration:' \
|
||||||
|
'readonly property bool canConfirm:' \
|
||||||
|
'function matchesRequest(' \
|
||||||
|
'function scalesForMode(' \
|
||||||
|
'function isScaleClean('; do
|
||||||
|
rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract"
|
||||||
|
done
|
||||||
|
rg -Fq 'enabled: Displays.canConfirm' "$page" \
|
||||||
|
|| fail 'Keep is enabled before the display change is verified'
|
||||||
|
rg -Fq 'options: Displays.scalesForMode(' "$page" \
|
||||||
|
|| fail 'scale choices are not filtered for the active resolution'
|
||||||
|
rg -Fq 'property string selectedOutput:' "$page" \
|
||||||
|
|| fail 'connected outputs cannot be selected'
|
||||||
|
rg -Fq 'options: Displays.monitors.map(' "$page" \
|
||||||
|
|| fail 'the output selector is not populated from connected displays'
|
||||||
|
rg -Fq 'id: revertVerifyTimer' "$service" \
|
||||||
|
|| fail 'automatic restoration has no bounded readback verification'
|
||||||
|
rg -Fq 'if (root.busy)' "$service" \
|
||||||
|
|| fail 'the display service accepts a new apply while another operation is busy'
|
||||||
|
|
||||||
|
# Stored JSON is untyped at field level, so the Lua startup consumer is the
|
||||||
|
# final validation boundary and must support every named output it accepts.
|
||||||
|
for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'pairs(displays)'; do
|
||||||
|
rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract"
|
||||||
|
done
|
||||||
|
|
||||||
|
# SettingsPage headers are genuinely pinned outside its scrolling surface.
|
||||||
|
python3 - "$settings_page" <<'PY' || fail 'SettingsPage header is not pinned outside the Flickable'
|
||||||
|
import sys
|
||||||
|
text = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
loader = text.find("id: pinnedHeader")
|
||||||
|
flickable = text.find("id: pageScroll")
|
||||||
|
if loader < 0 or flickable < 0 or loader > flickable:
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
MONITORS_LUA="$monitors_lua" lua - <<'LUA' || fail 'monitor startup accepted invalid persisted geometry or ignored a named output'
|
||||||
|
package.preload["prefs"] = function()
|
||||||
|
return {
|
||||||
|
get = function()
|
||||||
|
return {
|
||||||
|
["DP-2"] = { mode = "not-a-mode", scale = -1, transform = 99 },
|
||||||
|
["HDMI-A-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 1 },
|
||||||
|
["BAD OUTPUT"] = { mode = "1920x1080@60", scale = 1, transform = 0 },
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local calls = {}
|
||||||
|
hl = { monitor = function(value) table.insert(calls, value) end }
|
||||||
|
assert(loadfile(os.getenv("MONITORS_LUA")))()
|
||||||
|
|
||||||
|
local by_output = {}
|
||||||
|
for _, value in ipairs(calls) do by_output[value.output] = value end
|
||||||
|
assert(by_output["DP-2"].mode == "4500x3000@60")
|
||||||
|
assert(by_output["DP-2"].scale == 1.5)
|
||||||
|
assert(by_output["DP-2"].transform == 0)
|
||||||
|
assert(by_output["HDMI-A-1"].mode == "1920x1080@60")
|
||||||
|
assert(by_output["HDMI-A-1"].scale == 1.5)
|
||||||
|
assert(by_output["HDMI-A-1"].transform == 1)
|
||||||
|
assert(by_output["BAD OUTPUT"] == nil)
|
||||||
|
assert(by_output[""] ~= nil)
|
||||||
|
LUA
|
||||||
|
|
||||||
|
if [[ "${PANAMA_DISPLAYS_STATIC_ONLY:-0}" == "1" ]]; then
|
||||||
|
printf 'displays contract: PASS (static)\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
|
||||||
|
|
||||||
|
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
||||||
|
status() { run ipc call displays-test status; }
|
||||||
|
|
||||||
|
original_mode=""
|
||||||
|
original_scale=""
|
||||||
|
original_transform=""
|
||||||
|
original_width=""
|
||||||
|
original_height=""
|
||||||
|
original_refresh=""
|
||||||
|
monitor_name=""
|
||||||
|
|
||||||
|
monitor_state() {
|
||||||
|
hyprctl -j monitors | jq -c --arg output "$monitor_name" '.[] | select(.name == $output)'
|
||||||
|
}
|
||||||
|
|
||||||
|
display_is_restored() {
|
||||||
|
local current
|
||||||
|
current="$(monitor_state)"
|
||||||
|
[[ -n "$current" ]] || return 1
|
||||||
|
jq -e \
|
||||||
|
--argjson width "$original_width" \
|
||||||
|
--argjson height "$original_height" \
|
||||||
|
--argjson refresh "$original_refresh" \
|
||||||
|
--argjson scale "$original_scale" \
|
||||||
|
--argjson transform "$original_transform" \
|
||||||
|
'.width == $width and .height == $height
|
||||||
|
and ((.refreshRate - $refresh) | fabs) < 0.01
|
||||||
|
and ((.scale - $scale) | fabs) < 0.001
|
||||||
|
and .transform == $transform' <<<"$current" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_display() {
|
||||||
|
[[ -n "$original_mode" ]] || return 0
|
||||||
|
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", scale = $original_scale, transform = $original_transform })" >/dev/null \
|
||||||
|
|| return 1
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
display_is_restored && return 0
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_harness() {
|
||||||
|
# Kill by PID, never `pkill -f displays-harness`: that pattern also matches
|
||||||
|
# any shell whose command line contains this script's text, which includes
|
||||||
|
# the invoking shell itself.
|
||||||
|
[[ -n "${harness_pid:-}" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$config_home"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT
|
||||||
|
if ! restore_display; then
|
||||||
|
printf 'displays contract: FAILED to restore %s to %s scale %s transform %s\n' \
|
||||||
|
"$monitor_name" "$original_mode" "$original_scale" "$original_transform" >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
stop_harness
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||||
|
harness_pid=""
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
run ipc show 2>/dev/null | rg -q '^target displays-test$' && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
run ipc show 2>/dev/null | rg -q '^target displays-test$' || fail 'test IPC target did not start'
|
||||||
|
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
||||||
|
|
||||||
|
refresh_fixture="$(run ipc call displays-test refreshIdentityFixture)"
|
||||||
|
jq -e '
|
||||||
|
.count == 2
|
||||||
|
and .modes == ["[email protected]", "[email protected]"]
|
||||||
|
and .selected == ["[email protected]"]
|
||||||
|
' <<<"$refresh_fixture" >/dev/null \
|
||||||
|
|| fail "59.94 Hz and 60.00 Hz lost their distinct selection identity: $refresh_fixture"
|
||||||
|
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
[[ "$(status | jq -r .count)" != "0" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
state="$(status)"
|
||||||
|
monitor_name="$(jq -r .name <<<"$state")"
|
||||||
|
[[ -n "$monitor_name" ]] || fail "no display was detected: $state"
|
||||||
|
original_mode="$(jq -r .mode <<<"$state")"
|
||||||
|
original_width="$(jq -r .width <<<"$state")"
|
||||||
|
original_height="$(jq -r .height <<<"$state")"
|
||||||
|
original_refresh="$(jq -r .refresh <<<"$state")"
|
||||||
|
original_scale="$(jq -r .scale <<<"$state")"
|
||||||
|
original_transform="$(jq -r .transform <<<"$state")"
|
||||||
|
|
||||||
|
[[ "$(jq -r .modes <<<"$state")" -gt 0 ]] || fail 'the display reported no usable modes'
|
||||||
|
|
||||||
|
# ── Anything the compositor did not offer is refused before applying ─────────
|
||||||
|
while IFS= read -r kind; do
|
||||||
|
[[ "$(run ipc call displays-test applyBad "$kind")" == "false" ]] \
|
||||||
|
|| fail "an invalid $kind was accepted"
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "false" ]] \
|
||||||
|
|| fail "an invalid $kind left a change pending"
|
||||||
|
done <<'KINDS'
|
||||||
|
mode
|
||||||
|
scale
|
||||||
|
transform
|
||||||
|
output
|
||||||
|
dirtyScale
|
||||||
|
KINDS
|
||||||
|
|
||||||
|
# The display must not have moved for any of those.
|
||||||
|
now="$(status)"
|
||||||
|
[[ "$(jq -r .scale <<<"$now")" == "$original_scale" ]] || fail 'a refused change still altered the scale'
|
||||||
|
|
||||||
|
# An immediate Revert may race both the apply process and its first readback.
|
||||||
|
# It must queue until both are clear, then verify the original generation.
|
||||||
|
target_scale=$(awk -v s="$original_scale" 'BEGIN { print (s == 1.25) ? 1.5 : 1.25 }')
|
||||||
|
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|
||||||
|
|| fail 'the immediate-revert fixture could not apply'
|
||||||
|
run ipc call displays-test revertChange >/dev/null
|
||||||
|
immediate_reverted=false
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if display_is_restored && [[ "$(status | jq -r .awaiting)" == "false" ]]; then
|
||||||
|
immediate_reverted=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
[[ "$immediate_reverted" == true ]] \
|
||||||
|
|| fail 'an immediate Revert raced the apply/readback and did not restore the display'
|
||||||
|
|
||||||
|
# ── An unconfirmed change reverts on its own and stores nothing ──────────────
|
||||||
|
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|
||||||
|
|| fail 'a valid scale change was refused'
|
||||||
|
|
||||||
|
applied=false
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
[[ "$(monitor_state | jq -r '.scale')" == "$target_scale" ]] && { applied=true; break; }
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
[[ "$applied" == true ]] || fail 'the scale change never reached the compositor'
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "true" ]] || fail 'an applied change is not awaiting confirmation'
|
||||||
|
[[ "$(status | jq -r .canConfirm)" == "true" ]] || fail 'an applied change was never verified by compositor readback'
|
||||||
|
|
||||||
|
# Wait out the countdown. This is the whole point of the contract.
|
||||||
|
reverted=false
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
|
if [[ "$(monitor_state | jq -r '.scale')" == "$original_scale" ]]; then
|
||||||
|
reverted=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
[[ "$reverted" == true ]] || fail 'an unconfirmed change did NOT revert -- this would strand a user on an unreadable display'
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'the pending state survived the revert'
|
||||||
|
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'an unconfirmed change was written to the settings store'
|
||||||
|
|
||||||
|
# ── A confirmed change is what writes ────────────────────────────────────────
|
||||||
|
run ipc call displays-test applyScale "$target_scale" >/dev/null
|
||||||
|
[[ "$(run ipc call displays-test confirmChange)" == "false" ]] \
|
||||||
|
|| fail 'Keep accepted a display change before compositor readback'
|
||||||
|
verified=false
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
[[ "$(status | jq -r .canConfirm)" == "true" ]] && { verified=true; break; }
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
[[ "$verified" == true ]] || fail 'the confirmed change never became safe to keep'
|
||||||
|
[[ "$(run ipc call displays-test confirmChange)" == "true" ]] \
|
||||||
|
|| fail 'Keep refused a verified display change'
|
||||||
|
sleep 0.6
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'confirming did not clear the pending state'
|
||||||
|
[[ "$(status | jq -r .overridden)" == "true" ]] || fail 'confirming did not store the change'
|
||||||
|
|
||||||
|
store="$config_home/panama/settings.json"
|
||||||
|
jq -e --arg m "$monitor_name" '.displays[$m].scale != null' "$store" >/dev/null \
|
||||||
|
|| fail 'the confirmed change is not in the settings store'
|
||||||
|
|
||||||
|
# ── Forgetting clears it ─────────────────────────────────────────────────────
|
||||||
|
run ipc call displays-test forget >/dev/null
|
||||||
|
sleep 0.6
|
||||||
|
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'forget did not clear the stored display setting'
|
||||||
|
|
||||||
|
restore_display || fail 'the final cleanup could not restore and verify the original display'
|
||||||
|
original_mode=""
|
||||||
|
stop_harness
|
||||||
|
trap - EXIT
|
||||||
|
printf 'displays contract: PASS\n'
|
||||||
@@ -22,10 +22,26 @@ cleanup() { rm -rf "$work"; }
|
|||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
settings="$work/config/panama/settings.json"
|
settings="$work/config/panama/settings.json"
|
||||||
|
home="$work/state/panama/panama-home.json"
|
||||||
backups="$work/state/panama/backups"
|
backups="$work/state/panama/backups"
|
||||||
|
transaction_dir="$work/state/panama/transactions/settings-restore"
|
||||||
mkdir -p "$(dirname "$settings")"
|
mkdir -p "$(dirname "$settings")"
|
||||||
|
|
||||||
run() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" "$@"; }
|
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 ───────────────────────────────────────────────────────
|
# ── Nothing to back up ───────────────────────────────────────────────────────
|
||||||
run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success'
|
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 ───────────────────────────────────────────────────
|
# ── A snapshot round-trips ───────────────────────────────────────────────────
|
||||||
printf '{"gapsOut":24,"windowRounding":6}' >"$settings"
|
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'
|
run save >/dev/null || fail 'save failed on a valid settings file'
|
||||||
name="$(run list | jq -r '.[0].name')"
|
name="$(run list | jq -r '.[0].name')"
|
||||||
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "unexpected snapshot name: $name"
|
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "unexpected snapshot name: $name"
|
||||||
[[ "$(run list | jq -r '.[0].keys')" == "2" ]] || fail 'snapshot key count is wrong'
|
[[ "$(run list | jq -r '.[0].keys')" == "2" ]] || fail 'snapshot key count is wrong'
|
||||||
|
|
||||||
printf '{"gapsOut":99}' >"$settings"
|
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 .gapsOut "$settings")" == "24" ]] || fail 'restore did not bring back the snapshot contents'
|
||||||
[[ "$(jq -r .windowRounding "$settings")" == "6" ]] || fail 'restore lost a key'
|
[[ "$(jq -r .windowRounding "$settings")" == "6" ]] || fail 'restore lost a key'
|
||||||
|
[[ "$(jq -r '.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 ──────────────────
|
# ── Restoring snapshots what it replaced, so it is undoable ──────────────────
|
||||||
count="$(run list | jq 'length')"
|
count="$(run list | jq 'length')"
|
||||||
@@ -52,12 +149,44 @@ bad="settings-19990101-000000000.json"
|
|||||||
mkdir -p "$backups"
|
mkdir -p "$backups"
|
||||||
printf '{ truncated' >"$backups/$bad"
|
printf '{ truncated' >"$backups/$bad"
|
||||||
run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored'
|
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 ───────────────
|
# ── A snapshot cannot name a path outside the backup directory ───────────────
|
||||||
printf '{"pwned":true}' >"$work/outside.json"
|
printf '{"pwned":true}' >"$work/outside.json"
|
||||||
run restore "../../outside.json" >/dev/null 2>&1 && fail 'a traversing snapshot name was accepted'
|
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'
|
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'
|
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 ─────────────────────────────────
|
# ── A snapshot that is not listed is refused ─────────────────────────────────
|
||||||
|
|||||||
+132
@@ -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'
|
||||||
Reference in New Issue
Block a user