Close desktop safety gaps

This commit is contained in:
Gabriel Brown
2026-08-20 22:00:26 -04:00
parent c4733e0624
commit c4642919f7
13 changed files with 194 additions and 35 deletions
@@ -6,6 +6,10 @@ import qs.config
import qs.services
ShellRoot {
// The isolated screen model begins with both fixture outputs so changing
// it below exercises the same reactive topology path as a real hotplug.
Component.onCompleted: Displays.screenOverride = ["DP-2", "HDMI-A-1"]
IpcHandler {
target: "displays-test"
@@ -42,6 +46,7 @@ ShellRoot {
function transactionStatus(): string {
return JSON.stringify({
layout: Displays.currentLayout(),
primaryFirst: Displays.primaryFirstMonitors.map(monitor => monitor.name),
pending: Displays.pendingRequestedLayout,
previous: Displays.pendingPreviousLayout,
reverting: Displays.revertExpectedLayout,
@@ -141,5 +146,8 @@ ShellRoot {
if (monitor) Displays.forget(monitor.name);
}
function refresh(): void { Displays.refresh(); }
function setScreenModel(names: string): void {
Displays.screenOverride = JSON.parse(names);
}
}
}
@@ -22,7 +22,7 @@ SettingsPage {
property string selectedOutput: ""
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
?? (Displays.primaryFirstMonitors.length > 0 ? Displays.primaryFirstMonitors[0] : null)
readonly property string currentMode: root.monitor
? root.monitor.mode
: ""
@@ -38,7 +38,8 @@ SettingsPage {
function syncSelectedOutput(): void {
if (!Displays.monitorNamed(root.selectedOutput))
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
root.selectedOutput = Displays.primaryFirstMonitors.length > 0
? Displays.primaryFirstMonitors[0].name : "";
}
// Probing I2C for DDC-capable monitors takes on the order of a second, so
@@ -137,7 +138,7 @@ SettingsPage {
ChoiceGrid {
width: parent.width
label: "Display"
options: Displays.monitors.map(monitor => ({
options: Displays.primaryFirstMonitors.map(monitor => ({
value: monitor.name,
label: monitor.description || monitor.name
}))
@@ -18,6 +18,13 @@ Column {
width: parent ? parent.width : 620
spacing: 4
onOutputsChanged: root.syncSelectedOutput()
function syncSelectedOutput(): void {
if (!root.outputs.includes(root.selectedOutput))
root.selectedOutput = root.outputs.length > 0 ? root.outputs[0] : "";
}
ChoiceGrid {
width: parent.width
label: "Wallpaper mode"
+26 -8
View File
@@ -17,8 +17,10 @@ status_file="$state_dir/hyprlock-status.json"
fallback="$config_home/hypr/hyprlock.conf"
temporary="$generated.tmp.$$"
status_temporary="$status_file.tmp.$$"
shipped_wallpaper="$HOME/Pictures/Wallpapers/faroe_islands.jpg"
settings_valid=false
wallpaper_warning=""
cleanup() {
rm -f "$temporary" "$status_temporary" 2>/dev/null || true
@@ -76,7 +78,23 @@ read_object() {
}
valid_path() {
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]]
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* && -f "$1" && -r "$1" ]]
}
resolve_wallpaper_path() {
local candidate="$1"
if valid_path "$candidate"; then
resolved_wallpaper="$candidate"
return
fi
if [[ -n "$candidate" ]]; then
wallpaper_warning="One or more lock-screen wallpapers were unavailable; a safe fallback is in use."
fi
if valid_path "$shipped_wallpaper"; then
resolved_wallpaper="$shipped_wallpaper"
else
resolved_wallpaper="screenshot"
fi
}
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
@@ -134,10 +152,9 @@ load_preferences() {
wallpaper_mode="$(read_string wallpaperMode single)"
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|| wallpaper_mode=single
wallpaper_path="$(read_string wallpaperPath '')"
if ! valid_path "$wallpaper_path"; then
wallpaper_path="$HOME/Pictures/Wallpapers/faroe_islands.jpg"
fi
wallpaper_warning=""
resolve_wallpaper_path "$(read_string wallpaperPath '')"
wallpaper_path="$resolved_wallpaper"
wallpaper_assignments="$(read_object wallpaperPerMonitor)"
case "$blur_level" in
@@ -218,8 +235,9 @@ emit_backgrounds() {
candidate="$(jq -r --arg output "$output" \
'if has($output) and (.[$output] | type) == "string" then .[$output] else "" end' \
<<<"$wallpaper_assignments" 2>/dev/null || true)"
if valid_path "$candidate"; then
assigned="$candidate"
if [[ -n "$candidate" ]]; then
resolve_wallpaper_path "$candidate"
assigned="$resolved_wallpaper"
fi
fi
emit_background "$output" "$assigned"
@@ -344,7 +362,7 @@ generate() {
write_status false "$fallback" true "The lock-screen configuration could not be generated."
return 1
fi
write_status true "$generated" false ""
write_status true "$generated" false "$wallpaper_warning"
}
status() {
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Finds wallpaper candidates from fixed roots passed as separate arguments.
# Keeping paths as argv values avoids shell interpolation for names containing
# quotes, spaces, or other shell syntax.
set -euo pipefail
find "$@" -maxdepth 2 -type f \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) \
-printf '%T@ %p\n' 2>/dev/null \
| sort -rn \
| cut -d' ' -f2- \
| head -60
@@ -29,8 +29,24 @@ Singleton {
// [{ name, description, width, height, refreshRate, scale, transform,
// modes: [{ label, mode, width, height, refresh }] }]
property var monitors: []
// Quickshell.screens is the topology authority. The override is only the
// isolated harness model; normal sessions always observe Quickshell.
property var screenOverride: null
property string lastError: ""
readonly property var screenModel: Array.isArray(root.screenOverride)
? root.screenOverride : Quickshell.screens
readonly property string screenSignature: root.screenModel
.map(screen => typeof screen === "string" ? screen : screen.name)
.filter(name => !!name)
.sort()
.join("|")
readonly property var primaryFirstMonitors: root.monitors.slice().sort((left, right) => {
if (left.primary !== right.primary)
return left.primary ? -1 : 1;
return left.name.localeCompare(right.name);
})
// Set while a change is applied but not yet confirmed.
property var pendingPreviousLayout: null
property var pendingRequestedLayout: null
@@ -113,6 +129,17 @@ Singleton {
Component.onCompleted: root.refresh()
onScreenSignatureChanged: root.reconcileTopology()
// A hotplug can invalidate the unconfirmed layout while the confirmation
// is visible. Read the current compositor layout first; the normal queued
// rollback then filters out any output that has disappeared.
function reconcileTopology(): void {
root.refresh();
if (root.awaitingConfirmation)
root.revertWithMessage("A display was connected or disconnected, so Panama restored the previous setting.");
}
function refresh(): bool {
if (!query.running) {
query.generation = root.operationGeneration;
+18 -7
View File
@@ -45,9 +45,10 @@ Singleton {
readonly property string outputSignature: root.outputNames().slice().sort().join("|")
property var outputNames: function() {
if (Array.isArray(root.outputOverride))
return root.outputOverride.slice();
return Quickshell.screens.map(screen => screen.name).filter(name => !!name);
const outputs = Array.isArray(root.outputOverride)
? root.outputOverride.slice()
: Quickshell.screens.map(screen => screen.name).filter(name => !!name);
return root.primaryFirstOutputs(outputs);
}
readonly property var searchRoots: [
@@ -59,10 +60,8 @@ Singleton {
Process {
id: scan
command: ["bash", "-lc",
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
command: [Quickshell.shellDir + "/scripts/panama-wallpaper-scan"]
.concat(root.searchRoots)
stdout: StdioCollector {
onStreamFinished: {
root.available = this.text.split("\n")
@@ -164,6 +163,18 @@ Singleton {
activeQuery.running = true;
}
// The saved primary role anchors Panama's per-monitor choices. Keep the
// compositor's remaining order stable so a reconnect does not reshuffle
// the rest of the picker unnecessarily.
function primaryFirstOutputs(outputs: var): var {
const stored = DesktopPreferences.get("displays");
const layouts = stored && typeof stored === "object" ? stored : {};
const primary = outputs.find(output => layouts[output]?.primary === true);
return primary === undefined
? outputs
: [primary].concat(outputs.filter(output => output !== primary));
}
function candidates(extra: var): var {
const result = [];
const discovered = Array.isArray(root.candidateOverride)
@@ -7,7 +7,7 @@ import qs.services
ShellRoot {
Component.onCompleted: {
Wallpaper.outputOverride = ["DP-2", "HDMI-A-1"];
Wallpaper.outputOverride = ["HDMI-A-1", "DP-2"];
Wallpaper.candidateOverride = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"];
Wallpaper.startupRestoreEnabled = false;
Wallpaper.slideshowIntervalOverrideMs = 60000;
@@ -63,7 +63,9 @@ ShellRoot {
assignments: DesktopPreferences.get("wallpaperPerMonitor"),
slideshowPath: Wallpaper.slideshowPath,
shuffleBag: Wallpaper.shuffleBag,
slideshowTimerRunning: Wallpaper.slideshowTimerRunning
slideshowTimerRunning: Wallpaper.slideshowTimerRunning,
scanning: Wallpaper.scanning,
available: Wallpaper.available
});
}
}
@@ -46,6 +46,16 @@ ShellRoot {
return JSON.stringify(root.calls);
}
function selectOutput(output: string): void {
controls.selectedOutput = output;
}
function updateOutputs(outputs: string): void {
controls.outputs = outputs === "" ? [] : outputs.split("|");
}
function selectedOutput(): string { return controls.selectedOutput; }
function states(): string {
picker.mode = "single";
const single = {