Keep Settings recovery isolated and display-safe

This commit is contained in:
Gabriel Brown
2026-08-18 03:19:45 -04:00
parent 6d3f888784
commit 7b8270fdd6
5 changed files with 64 additions and 37 deletions
@@ -577,17 +577,22 @@ def command_restore(arguments: list[str]) -> None:
# restore followed by `hyprctl reload` must not bypass that safety boundary.
# Preserve the currently confirmed generation when it is readable, and
# otherwise remove the snapshot's geometry so startup uses shipped policy.
if desktop_present and desktop_data is not None:
try:
current_desktop = read_json(SETTINGS, "The current settings file") \
if is_present(SETTINGS) else {}
except BackupError:
current_desktop = {}
if isinstance(current_desktop, dict) and "displays" in current_desktop:
# Even a Home-only snapshot must retain the confirmed monitor layout.
# In that case the restored desktop file contains only the protected
# geometry; every ordinary desktop preference remains absent/default.
desktop_present = True
desktop_data = dict(desktop_data) if desktop_data is not None else {}
desktop_data["displays"] = current_desktop["displays"]
elif desktop_present and desktop_data is not None:
desktop_data = dict(desktop_data)
try:
current_desktop = read_json(SETTINGS, "The current settings file") \
if is_present(SETTINGS) else {}
except BackupError:
current_desktop = {}
if "displays" in current_desktop:
desktop_data["displays"] = current_desktop["displays"]
else:
desktop_data.pop("displays", None)
desktop_data.pop("displays", None)
# Restoring remains undoable, but a corrupt current file must not prevent a
# known-good snapshot from recovering the desktop.
@@ -206,6 +206,11 @@ Singleton {
}
// ── Applying options ────────────────────────────────────────────────────
// Test harnesses may replace the external compositor boundary while still
// exercising validation, commit routing, persistence, and reset replay.
// Production leaves this unset and always uses the verified Hyprland path.
property var compositorApplyOverride: null
// `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }.
// The whole batch is validated before anything is sent, so one bad value
// rejects the batch rather than half-applying it.
@@ -227,6 +232,9 @@ Singleton {
if (Object.keys(requested).length === 0)
return false;
if (root.compositorApplyOverride !== null)
return root.compositorApplyOverride(requested);
// A write in flight is queued rather than refused. Options are applied
// and verified one batch at a time, but the callers are a settings UI
// and a startup replay of every compositor-backed preference -- they
@@ -9,6 +9,7 @@ ShellRoot {
id: root
property var resetCalls: []
property var appliedBatches: []
property bool displayBlocked: false
function recordReset(name: string): void {
@@ -18,6 +19,18 @@ ShellRoot {
}
Component.onCompleted: {
// Keep compositor verification entirely inside the isolated harness.
// Production applyOptions is covered separately by the Hyprland write
// contract; this seam proves commit/reset routing without changing the
// desktop that is running the test.
SystemSettings.compositorApplyOverride = function(requested) {
const batches = root.appliedBatches.slice();
batches.push(requested);
root.appliedBatches = batches;
for (const key in requested)
DesktopPreferences.set(key, requested[key]);
return true;
};
SystemSettings.displayBusy = function() { return false; };
SystemSettings.readDisplays = function() { return DesktopPreferences.get("displays"); };
SystemSettings.protectDisplays = function(value) {
@@ -85,7 +98,15 @@ ShellRoot {
}
function resetState(): string {
return JSON.stringify({ calls: root.resetCalls, displayBlocked: root.displayBlocked });
return JSON.stringify({
calls: root.resetCalls,
displayBlocked: root.displayBlocked,
appliedBatches: root.appliedBatches
});
}
function applyState(): string {
return JSON.stringify(root.appliedBatches);
}
function panelAllowed(panel: string): bool {