Review everything shipped this weekend, and fix what the reviewers caught

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 13:29:42 -04:00
parent ffce48964e
commit 07db1068f1
42 changed files with 959 additions and 219 deletions
+47 -8
View File
@@ -71,16 +71,27 @@ Singleton {
return Object.keys(palette).sort().map(token => palette[token]).join(",");
}
// What the last push actually sent, so apply() can tell an accent-only
// What the last push actually LANDED, so apply() can tell an accent-only
// change apart from a scheme or theme change and skip the steps that do
// not depend on whichever did not move. Their starting values do not
// matter: the first apply() always runs with force set, which ignores all
// of them.
//
// Written by settleQueue() once the whole queue has drained without a
// failure, never by apply(). Recording them at enqueue time meant a
// gsettings write that failed, or a hyprctl issued before the compositor
// was listening, left the shell believing the desktop already agreed with
// it -- and nothing pushed again until some unrelated preference moved.
property bool appliedDark: false
property string appliedAccentName: ""
property string appliedThemeId: ""
property string appliedPalettePrint: ""
// What the in-flight queue is trying to make true, and whether any step of
// it has already failed.
property var pendingApplied: null
property bool queueFailed: false
// Applied one command at a time: Process runs a single command, and several
// of these are separate programs.
property var pending: []
@@ -88,15 +99,39 @@ Singleton {
Process {
id: runner
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
if (exitCode !== 0) {
root.lastError = "The color scheme could not be applied everywhere.";
root.queueFailed = true;
}
root.drain();
}
}
function drain(): void {
if (runner.running || root.pending.length === 0)
// Only a queue that emptied without a single failure counts as applied. A
// failed one leaves the fingerprint alone, so the next apply() sees the same
// difference it saw before and pushes the whole thing again.
function settleQueue(): void {
if (root.pendingApplied === null)
return;
const target = root.pendingApplied;
root.pendingApplied = null;
if (root.queueFailed) {
root.queueFailed = false;
return;
}
root.appliedDark = target.dark;
root.appliedAccentName = target.accentName;
root.appliedThemeId = target.themeId;
root.appliedPalettePrint = target.palettePrint;
}
function drain(): void {
if (runner.running)
return;
if (root.pending.length === 0) {
root.settleQueue();
return;
}
const next = root.pending[0];
root.pending = root.pending.slice(1);
runner.exec(next);
@@ -236,10 +271,14 @@ Singleton {
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
}
root.appliedDark = root.dark;
root.appliedAccentName = accentName;
root.appliedThemeId = themeId;
root.appliedPalettePrint = palettePrint;
// The target, not the record: settleQueue() promotes it to applied*
// once every command in the queue has come back clean.
root.pendingApplied = {
dark: root.dark,
accentName: accentName,
themeId: themeId,
palettePrint: palettePrint
};
root.enqueue(commands);
}
+83 -20
View File
@@ -156,7 +156,12 @@ Singleton {
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;
//
// performRevert already armed the timer; restarting it here gives
// the readback its full window from the moment the command actually
// returned, rather than from the moment it was issued.
if (!root.revertVerificationActive)
return;
revertVerifyTimer.ticks = 0;
revertVerifyTimer.restart();
}
@@ -177,9 +182,16 @@ Singleton {
}
// Nothing in flight, so a display genuinely arrived or left. Give it
// back the arrangement it was last confirmed with -- see restoreStored.
root.restoreDeferrals = 0;
restoreDebounce.restart();
}
// How many times the pending restore has been put off because something was
// in flight. Reset by every fresh hotplug and by every decision actually
// acted on; see restoreStored.
property int restoreDeferrals: 0
readonly property int restoreDeferralLimit: 20
// Docking and undocking should not cost you your arrangement.
//
// hypr/monitors.lua applies the stored per-output entries, but only when
@@ -207,12 +219,18 @@ Singleton {
// says so, which is recoverable. Silence would not be.
// The decision, with no side effects, so it can be tested without driving
// a real compositor. Returns one of:
// { action: "none" } nothing stored, or already correct
// { action: "apply", layout } restore this
// { action: "refuse" } stored arrangement does not fit
// { action: "none", reason: "settled" } nothing stored, or already correct
// { action: "none", reason: "unavailable" } cannot decide yet, ask again
// { action: "apply", layout } restore this
// { action: "refuse" } stored arrangement does not fit
//
// The two "none" answers are not the same answer. "Nothing to do" is final;
// "not now" is a question that has to be asked again, and a dock changes the
// topology and the readback at once, so "not now" is the common case exactly
// when the restore matters most.
function plannedRestore(): var {
if (root.busy || root.awaitingConfirmation || root.monitors.length === 0)
return { action: "none" };
return { action: "none", reason: "unavailable" };
const stored = DesktopPreferences.get("displays");
const persisted = stored && typeof stored === "object" ? stored : {};
@@ -248,7 +266,7 @@ Singleton {
}
if (!changed)
return { action: "none" };
return { action: "none", reason: "settled" };
// Exactly one primary, on a display that is actually here. Undocking
// takes the primary away, and a layout with none is one
@@ -271,8 +289,21 @@ Singleton {
function restoreStored(): void {
const plan = root.plannedRestore();
if (plan.action === "none")
if (plan.action === "none") {
// Deferred, not dropped: a query or an apply in flight is a "ask me
// again", and dropping it meant the arrangement stayed lost until
// somebody opened Settings and applied it by hand. Bounded so a
// stuck operation cannot leave this rescheduling itself all session
// -- twenty tries outlasts a full confirmation countdown.
if (plan.reason === "unavailable"
&& root.restoreDeferrals < root.restoreDeferralLimit) {
root.restoreDeferrals += 1;
restoreDebounce.restart();
}
return;
}
root.restoreDeferrals = 0;
if (plan.action === "refuse") {
StatusEvents.publish({
@@ -428,13 +459,20 @@ Singleton {
// The framebuffer format is the only honest report of the bit depth in
// effect: asking for 10-bit and getting it are different things, and a
// panel that cannot carry the link rate quietly stays at 8. Formats outside
// this map are read as "unknown", never as a mismatch.
// panel that cannot carry the link rate quietly stays at 8.
//
// Matched on the channel widths rather than on the two names Panama happens
// to have seen. The channel order is the compositor's business -- XRGB,
// XBGR and ARGB all carry ten bits per channel in 2101010 and eight in 8888
// -- and pinning the whole string reported an output as "unknown" for the
// one part of it that says nothing about depth. Anything else is still read
// as unknown, which is never treated as a mismatch.
function formatBitdepth(format: string): int {
if (format === "XRGB8888")
return 8;
if (format === "XRGB2101010")
const text = String(format);
if (/2101010$/.test(text))
return 10;
if (/8888$/.test(text))
return 8;
return 0;
}
@@ -544,7 +582,7 @@ Singleton {
const saved = root.savedEntry(monitor.name);
const live = DisplayLayout.validColorProfile(monitor.colorPreset)
? monitor.colorPreset : "auto";
return {
const record = {
name: monitor.name,
width: monitor.width,
height: monitor.height,
@@ -560,15 +598,25 @@ Singleton {
// Keeping the stored policy stops one apply from pinning the
// display to whatever automatic happened to pick today.
colorProfile: saved && saved.colorProfile === "auto" ? "auto" : live,
bitdepth: monitor.bitdepth !== 0
? monitor.bitdepth
: (saved && DisplayLayout.validBitdepth(saved.bitdepth) ? saved.bitdepth : 8),
sdrBrightness: DisplayLayout.validSdrBrightness(monitor.sdrBrightness)
? monitor.sdrBrightness : 1.0,
sdrSaturation: DisplayLayout.validSdrSaturation(monitor.sdrSaturation)
? monitor.sdrSaturation : 1.0,
mirrorOf: typeof monitor.mirrorOf === "string" ? monitor.mirrorOf : ""
};
// The bit depth is the one field with no honest default. A
// framebuffer format Panama cannot read means the depth is unknown,
// and filling in 8 turns that ignorance into a request: monitorRule
// would write `bitdepth = 8` at a panel that may well be running at
// 10, and confirm() would store the guess as though it were read.
// Left off instead, which every consumer already understands as "no
// opinion" -- the rule omits the key, the compositor keeps what it
// has, and isPersistedLayoutEntry accepts an entry without one.
if (monitor.bitdepth !== 0)
record.bitdepth = monitor.bitdepth;
else if (saved && DisplayLayout.validBitdepth(saved.bitdepth))
record.bitdepth = saved.bitdepth;
return record;
});
}
@@ -847,7 +895,7 @@ Singleton {
const stored = DesktopPreferences.get("displays");
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
for (const record of root.pendingRequestedLayout) {
next[record.name] = {
const entry = {
mode: record.mode,
scale: record.scale,
transform: record.transform,
@@ -856,11 +904,16 @@ Singleton {
primary: record.primary,
vrrMode: record.vrrMode,
colorProfile: record.colorProfile,
bitdepth: record.bitdepth,
sdrBrightness: record.sdrBrightness,
sdrSaturation: record.sdrSaturation,
mirrorOf: record.mirrorOf
};
// Absent rather than guessed, as currentLayout leaves it. A stored
// 8 that nobody read is one the next start would push at the display
// as a request.
if (DisplayLayout.validBitdepth(record.bitdepth))
entry.bitdepth = record.bitdepth;
next[record.name] = entry;
}
if (!DesktopPreferences.set("displays", next)) {
root.lastError = "That display setting could not be saved. Revert it and try again.";
@@ -926,10 +979,20 @@ Singleton {
root.revertExpectedLayout = previous.length > 0 ? previous : null;
root.revertVerificationActive = false;
root.clearPending();
if (previous.length > 0)
if (previous.length > 0) {
// Armed here, before the push, and not only from revertRun.onExited.
// A hyprctl that fails to start never emits `exited`, and the
// verification timer is the only thing that ever clears
// revertExpectedLayout -- which `busy` counts. So the one failure
// that most needs a way out used to latch the whole Displays page
// busy for the rest of the session, with no error to say why.
root.revertVerificationActive = true;
revertVerifyTimer.ticks = 0;
revertVerifyTimer.restart();
root.pushLayout(previous, revertRun);
else
} else {
root.lastError = root.revertReason;
}
}
// Clears any stored override for an output so it returns to the value
@@ -25,12 +25,12 @@ Singleton {
property string actionKind: ""
property string actionPath: ""
// actionProc's `exited` and its stdout `streamFinished` are not guaranteed
// to fire in a particular order (same hazard HomeAssistantConfig.qml's
// settle-both pattern guards against). These track which of the two have
// been observed for the action currently in flight so finishAction() is
// only ever called once both have arrived, with the real stdout JSON as
// the authoritative result.
// actionProc's `exited` and its stdout `streamFinished` carry half the
// result each -- the exit and the JSON -- and Quickshell documents no order
// between them. So neither one finalizes on its own: these track which have
// been observed for the action currently in flight, and settleAction() runs
// finishAction() once, on whichever arrives last, with the collected stdout
// as the authoritative result.
property bool actionExited: false
property bool actionStdoutDone: false
property string actionStdoutText: ""
@@ -151,9 +151,8 @@ Singleton {
}
// Called from both actionProc.onExited and its stdout streamFinished.
// Only finalizes once both signals have arrived for the in-flight action,
// since their firing order is not guaranteed -- see the actionExited /
// actionStdoutDone comment above.
// Only finalizes once both signals have arrived for the in-flight action --
// see the actionExited / actionStdoutDone comment above.
function settleAction(): void {
if (root.actionKind === "")
return;
+26 -1
View File
@@ -265,11 +265,36 @@ Singleton {
if (notification.urgency === NotificationUrgency.Low)
return;
// The freedesktop sound hints. This is the fix for the double chime:
// an application that plays its own sound sets suppress-sound so the
// notification server stays quiet, and Panama ignoring it meant one
// notification made two noises a beat apart.
//
// The other two say what to play instead of the theme bell --
// sound-file is an absolute path the application supplies, sound-name
// is a theme sound resolved through the same chain the bell uses.
const hints = notification.hints ?? {};
if (hints["suppress-sound"] === true)
return;
const soundFile = String(hints["sound-file"] ?? "");
const soundName = String(hints["sound-name"] ?? "");
const candidates = soundFile.startsWith("/")
? [soundFile]
: (soundName !== ""
? SoundFeedback.soundCandidates(soundName)
: SoundFeedback.bellCandidates);
// A hint naming something unresolvable is a request for that sound, not
// a request for the bell -- substituting would be a lie about which
// notification arrived.
if (candidates.length === 0)
return;
const now = Date.now();
if (bell.running || now - root.lastBellAt < 1000)
return;
root.lastBellAt = now;
bell.command = SoundFeedback.bellCommand;
bell.command = SoundFeedback.playCommand(candidates);
bell.running = true;
}
@@ -42,7 +42,7 @@ Singleton {
"wallpaper": "appearance",
"lockAppearance": "appearance",
"dock": "dock",
"focus": "workspaces",
"focus": "notifications",
"display": "displays",
"nightLight": "displays",
"idle": "power",
+15 -1
View File
@@ -38,9 +38,19 @@ Singleton {
// that instead of the live daemon.
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_CARDS_FIXTURE") || ""
// Assigning `running = true` to a Process that is already running is a
// no-op, not a queue, so a refresh that arrived mid-read was simply lost --
// and a profile switch's own re-read is exactly the refresh most likely to
// land on top of one, leaving the list showing the profile the card no
// longer has. Remembered here and re-run from lister.onExited instead.
property bool refreshPending: false
function refresh(): void {
if (lister.running)
if (lister.running) {
root.refreshPending = true;
return;
}
root.refreshPending = false;
lister.command = root.fixturePath !== ""
? ["cat", root.fixturePath]
: ["pactl", "-f", "json", "list", "cards"];
@@ -149,6 +159,10 @@ Singleton {
root.lastError = "Device profiles could not be read.";
root.loaded = true;
}
// Deferred a turn so this listing's stdout is parsed before the
// next one starts filling the same collector.
if (root.refreshPending)
Qt.callLater(() => root.refresh());
}
}
@@ -87,9 +87,19 @@ Singleton {
return output ? root.absentSink : root.absentSource;
}
// Assigning `running = true` to a Process that is already running is a
// no-op, not a queue. The default sink and source change together, so the
// two Pipewire signals below arrive back to back and the second read was
// always the one dropped -- which is how a ghost row survived the device
// coming back. Remembered here and re-run from reader.onExited instead.
property bool refreshPending: false
function refresh(): void {
if (reader.running)
if (reader.running) {
root.refreshPending = true;
return;
}
root.refreshPending = false;
reader.command = root.fixturePath !== ""
? ["cat", root.fixturePath]
: ["pw-metadata", "-n", "default", "0"];
@@ -173,6 +183,10 @@ Singleton {
: "PipeWire's remembered default devices could not be read.";
if (code !== 0)
root.loaded = true;
// Deferred a turn so this read's stdout is parsed before the next
// one starts filling the same collector.
if (root.refreshPending)
Qt.callLater(() => root.refresh());
}
}
@@ -43,19 +43,37 @@ Singleton {
// free of file probing on a hot path.
readonly property string homeDir: Quickshell.env("HOME") || ""
readonly property var bellCandidates: [
root.homeDir !== ""
? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/bell.oga` : "",
`/usr/share/sounds/${root.soundTheme}/stereo/bell.oga`,
"/usr/share/sounds/freedesktop/stereo/bell.oga"
].filter((path, index, all) => path !== "" && all.indexOf(path) === index)
// The candidate paths for one XDG sound name, in preference order. Shared
// with Notifs.qml, which resolves a notification's own `sound-name` hint
// through exactly this chain rather than a second copy of it.
function soundCandidates(name: string): var {
const sound = String(name ?? "").trim();
// A sound name is one entry in a theme directory, never a path. Anything
// that could climb out of it is not a sound name, and these strings
// arrive from other applications.
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(sound))
return [];
return [
root.homeDir !== ""
? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/${sound}.oga` : "",
`/usr/share/sounds/${root.soundTheme}/stereo/${sound}.oga`,
`/usr/share/sounds/freedesktop/stereo/${sound}.oga`
].filter((path, index, all) => path !== "" && all.indexOf(path) === index);
}
// The argv that plays the current theme's bell once, or nothing at all if
// no candidate exists. Shared with Notifs.qml, which plays the same bell
// for Panama's own notification popups.
readonly property var bellCommand: ["sh", "-c",
'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0',
"qs-sound-feedback"].concat(root.bellCandidates)
readonly property var bellCandidates: root.soundCandidates("bell")
// The argv that plays the first candidate that exists, or nothing at all if
// none does.
function playCommand(candidates: var): var {
return ["sh", "-c",
'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0',
"qs-sound-feedback"].concat(Array.isArray(candidates) ? candidates : []);
}
// The current theme's bell. Shared with Notifs.qml, which plays it for
// Panama's own notification popups.
readonly property var bellCommand: root.playCommand(root.bellCandidates)
function previewAlert(): void {
if (preview.running)
@@ -238,27 +238,29 @@ function normalizeStoredProfile(value) {
return result;
}
// The id is the identity here, and every custom id is custom-* namespaced, so
// it can never collide with a shipped one by accident; a repeat inside the
// stored list is a genuine duplicate and is dropped.
//
// A NAME is only a label, and a colliding one is renamed rather than dropped.
// Dropping was data loss on a delay: this list is also what gets written back
// to the preference, so a custom theme whose name a later Panama release
// happened to ship was silently deleted at the user's next theme edit.
function validCustomProfiles(values, shipped) {
if (!Array.isArray(values))
return [];
var catalog = shippedList(shipped);
var ids = {};
var names = {};
shippedList(shipped).forEach(function(profile) {
ids[profile.id] = true;
names[profile.name.toLowerCase()] = true;
});
catalog.forEach(function(profile) { ids[profile.id] = true; });
var result = [];
values.forEach(function(value) {
var profile = normalizeStoredProfile(value);
if (!profile)
return;
var foldedName = profile.name.toLowerCase();
if (ids[profile.id] || names[foldedName])
if (!profile || ids[profile.id])
return;
ids[profile.id] = true;
names[foldedName] = true;
profile.name = uniqueName(profile.name, catalog.concat(result));
result.push(profile);
});
return result;
@@ -537,7 +539,11 @@ function resaturatePalette(palette, factor) {
var next = clamp(hsv.s * (1 + (scale - 1) * strength), 0, 100);
result[key] = hsvToHex(hsv.h, next, hsv.v);
});
return result;
// Validated on the way out like every other palette this module builds: a
// factor that is not a number survives clamp() as NaN and turns every token
// into a string no palette reader can use. Returning null makes that a
// rejection the caller can see instead of a corrupt theme it stores.
return normalizePalette(result);
}
// Terminal colors for a custom theme that has none of its own: semantic
@@ -45,11 +45,17 @@ Singleton {
// The remembered theme for a scheme, falling back to the catalog default.
function themeForScheme(target: string): string {
const key = target === "light" ? "themeLight" : "themeDark";
const fallback = target === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark;
const scheme = target === "light" ? "light" : "dark";
const key = scheme === "light" ? "themeLight" : "themeDark";
const fallback = scheme === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark;
const stored = String(DesktopPreferences.get(key) || "");
return ThemeProfileModel.findProfile(root.customProfiles, stored, root.shippedThemes)
? stored : fallback;
const profile = ThemeProfileModel.findProfile(
root.customProfiles, stored, root.shippedThemes);
// Existing is not enough: the slot has to hold a theme OF that scheme.
// A custom theme edited from dark to light stayed in the dark slot, so
// asking for dark selected a light theme, which flipped the scheme back
// to light -- the light/dark toggle simply stopped working.
return profile && profile.scheme === scheme ? stored : fallback;
}
function commitActive(profile: var): bool {
@@ -64,7 +70,11 @@ Singleton {
profile.scheme === "light" ? "themeLight" : "themeDark", profile.id);
SystemSettings.commitPreference("accentName",
ThemeProfileModel.nearestCuratedName(profile.scheme, profile.accent));
root.applyProfileEffects(profile);
// Effects are the theme's own snapshot of blur, shadow and motion.
// Restoring them for a theme that did not actually become active left
// the desktop wearing half of a theme nobody selected.
if (schemeAccepted && profileAccepted)
root.applyProfileEffects(profile);
return schemeAccepted && profileAccepted;
}
@@ -126,6 +136,11 @@ Singleton {
// The editor's saturation slider. factor 1.0 is neutral.
function setSaturation(factor: real): bool {
// NaN survives the model's clamp and comes back out as a palette of
// unreadable tokens. Reject it here, where there is still a caller to
// tell about it.
if (!isFinite(factor))
return false;
const palette = ThemeProfileModel.resaturatePalette(root.activePalette, factor);
if (!palette)
return false;
@@ -11,8 +11,8 @@ pragma Singleton
//
// hyprpaper and mpvpaper both claim the background layer and stacking within a
// layer is creation order — a race. So while a video is active hyprpaper's
// service is stopped, and stopping the video starts it again; Wallpaper.qml
// reapplies the still policy once it returns.
// service is stopped, and stopping the video starts it again; this service then
// reapplies the still policy through Wallpaper.qml once it returns.
//
// mpvpaper 1.9 (Terra) is the floor: it carries the libmpv fence-leak
// workaround. Known upstream sharp edges — a hotplug segfault and a
@@ -125,6 +125,9 @@ Singleton {
root.lastError = "";
root.path = video;
root.manuallyPaused = false;
// A new video starts its own patience; the previous file's crashes are
// not evidence about this one.
root.crashStreak = 0;
hyprpaperControl.command = ["systemctl", "--user", "stop", "hyprpaper.service"];
hyprpaperControl.running = true;
frameProc.command = ["sh", "-c",
@@ -141,6 +144,19 @@ Singleton {
root.restoreConsumed = true;
root.path = "";
root.manuallyPaused = false;
root.crashStreak = 0;
root.teardownPlayers();
// The stored path is the restore path, the picker's "current" ring and
// what Wallpaper.setSingle reads back. Leaving it set after a deliberate
// stop meant the next login started playing the video again.
DesktopPreferences.set("videoWallpaperPath", "");
stillHandback.restart();
}
// Everything both stop() and the crash bail-out have to do: no player left
// running, no timer armed to start another, and hyprpaper handed back the
// layer it owns.
function teardownPlayers(): void {
playerRespawn.stop();
spawnDelay.stop();
for (const player of root.players) {
@@ -151,7 +167,22 @@ Singleton {
reaper.running = true;
hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
hyprpaperControl.running = true;
Qt.callLater(() => Wallpaper.refreshActive());
}
// hyprpaper's service needs a beat to be back on the bus before it can be
// asked to draw anything — the same settle Wallpaper.setSingle uses for its
// own video→still handoff. When that handoff is the reason we are stopping,
// it owns the policy (it holds the new one, which is not yet persisted) and
// this stays out of the way.
Timer {
id: stillHandback
interval: 300
onTriggered: {
if (root.active || Wallpaper.pendingStillPolicy !== null)
return;
if (!Wallpaper.applyCurrentPolicy(false))
Wallpaper.refreshActive();
}
}
// Players are created per current output set each (re)spawn, so hotplug
@@ -181,15 +212,32 @@ Singleton {
reaper.running = true;
}
// pkill returns when the signal is delivered, not when the process is gone,
// and mpvpaper takes a moment to tear its GL context down. So the reaper
// waits for the corpses rather than the shell guessing at how long that
// takes: TERM, then poll pgrep in 50ms steps, escalating to KILL halfway
// through and giving up after two seconds so a wedged process cannot hold
// the wallpaper hostage.
Process {
id: reaper
command: ["pkill", "-x", "mpvpaper"]
command: ["sh", "-c", `
pkill -x mpvpaper 2>/dev/null || true
step=0
while [ "$step" -lt 40 ]; do
pgrep -x mpvpaper >/dev/null 2>&1 || exit 0
[ "$step" -eq 20 ] && pkill -9 -x mpvpaper 2>/dev/null
sleep 0.05
step=$((step + 1))
done
`]
onExited: spawnDelay.restart()
}
// The reaper already waited for the old players to die, so this is only the
// hand-back to the event loop, not a guess at how long a kill takes.
Timer {
id: spawnDelay
interval: 300
interval: 50
onTriggered: {
if (!root.active)
return;
@@ -201,10 +249,37 @@ Singleton {
spawned.push(player);
}
root.players = spawned;
root.playersStartedAt = Date.now();
root.crashCounted = false;
pauseSync.restart();
}
}
// A crash loop has to end somewhere. mpvpaper dying within three seconds of
// being spawned is not the hotplug segfault the supervisor exists for — it
// is a file it cannot decode or a VAAPI stack that is not there — and
// respawning forever flashes the desktop black once a second for as long as
// the session lasts, with no error anywhere to explain it.
property real playersStartedAt: 0
property int crashStreak: 0
// One count per spawned set: on two monitors a single crash fires two
// onExited, and counting each would trip the limit half a cycle early.
property bool crashCounted: false
readonly property int crashStreakLimit: 3
readonly property int crashWindowMs: 3000
function giveUp(): void {
root.path = "";
root.manuallyPaused = false;
root.crashStreak = 0;
// Nothing may start it again this session, not even a preference write
// landing in tryRestore's lap.
root.restoreConsumed = true;
root.teardownPlayers();
root.lastError = "Video wallpaper kept crashing — check the file and VAAPI decode";
stillHandback.restart();
}
Component {
id: playerComponent
@@ -213,12 +288,31 @@ Singleton {
property string output: ""
property bool retiring: false
onExited: {
// The object is spent either way: a Process cannot be restarted
// and every respawn builds a fresh one per output, so holding
// this one leaked a Process per crash cycle.
root.players = root.players.filter(candidate => candidate !== player);
Qt.callLater(() => player.destroy());
// A retiring player died because the supervisor killed it —
// reacting to that is how the reap loop once ate its young.
if (player.retiring || !root.active)
return;
// A dead player while a video is meant to be active is a
// crash (mpvpaper has a known hotplug segfault): respawn the
// whole set after a beat rather than reasoning per-output.
// A retiring player died because the supervisor killed it —
// reacting to that is how the reap loop once ate its young.
if (!player.retiring && root.active && !playerRespawn.running)
if (!root.crashCounted) {
root.crashCounted = true;
root.crashStreak = Date.now() - root.playersStartedAt < root.crashWindowMs
? root.crashStreak + 1
: 0;
}
if (root.crashStreak >= root.crashStreakLimit) {
root.giveUp();
return;
}
if (!playerRespawn.running)
playerRespawn.restart();
}
}
@@ -289,7 +383,11 @@ Singleton {
// The service restores its own video, reactively: at cold start the
// preferences file and the mpvpaper probe both land asynchronously, so a
// one-shot timer (the still pipeline's approach) raced them and lost.
// Once per session — a user's stop() is not to be overridden.
// Once per session, and consumed by stop() and giveUp() as well as by the
// restore itself: a preference write is all it takes to re-enter tryRestore,
// so neither a video the user turned off nor one that crashed out may come
// back under them. stop() also clears the stored path, so there is nothing
// left to restore at the next login either.
property bool restoreConsumed: false
// Harness seam, mirroring Wallpaper.startupRestoreEnabled: a test instance
// must never start playing the user's real wallpaper.
+82 -9
View File
@@ -97,8 +97,18 @@ Singleton {
if (root.transaction === null)
return;
if (exitCode !== 0) {
root.lastError = "Hyprpaper did not apply that background.";
root.transaction = null;
// Coming back from a video, hyprpaper's service is being
// started again underneath us and may not have its socket yet.
// That is a "not ready", not a "no", so the handoff is retried
// a few times before it counts as a failure.
if (root.pendingStillPolicy !== null
&& root.stillHandoffAttempts < root.stillHandoffLimit) {
stillAfterVideo.restart();
return;
}
root.lastError = "Hyprpaper did not apply that background.";
root.abandonStillHandoff();
root.schedulePendingHotplug();
return;
}
@@ -116,6 +126,34 @@ Singleton {
onExited: (exitCode, exitStatus) => root.finishVerification(exitCode)
}
// `busy` is the gate on every apply path, on refreshActive and on the
// slideshow timer, and a transaction is the only thing that clears it. So a
// hyprctl that never returns does not fail a wallpaper change — it takes the
// whole wallpaper surface out of service for the rest of the session, with
// no error to say why. Nothing here is a ten-second operation.
Timer {
id: transactionWatchdog
interval: 10000
onTriggered: {
if (root.transaction === null)
return;
root.transaction = null;
root.abandonStillHandoff();
// Killing the stuck child is part of the release: `busy` counts the
// processes too, so leaving one running would keep the latch shut.
applyProcess.running = false;
verifyProcess.running = false;
root.lastError = "Hyprpaper stopped responding while setting that background.";
}
}
onTransactionChanged: {
if (root.transaction === null)
transactionWatchdog.stop();
else
transactionWatchdog.restart();
}
Component.onCompleted: {
root.rescan();
root.refreshActive();
@@ -253,11 +291,17 @@ Singleton {
const normalized = root.normalizePolicy(policy);
if (normalized === null) {
root.lastError = "That wallpaper policy is not valid.";
// Nothing will reach schedulePendingHotplug from here, and a flag
// left standing turns the next unrelated apply into a surprise
// hotplug reapply. The signature change that armed it will arm it
// again if a display really did move.
root.pendingHotplugReapply = false;
return false;
}
const outputs = root.outputNames();
if (outputs.length === 0) {
root.lastError = "No display to set a wallpaper on.";
root.pendingHotplugReapply = false;
return false;
}
const expected = WallpaperPolicy.effectiveMap(
@@ -266,6 +310,7 @@ Singleton {
if (Object.keys(expected).length !== outputs.length
|| Object.values(expected).some(path => path === "")) {
root.lastError = "That wallpaper policy is not valid.";
root.pendingHotplugReapply = false;
return false;
}
root.lastError = "";
@@ -318,6 +363,7 @@ Singleton {
if (!matches) {
root.lastError = "Hyprpaper did not confirm that background.";
root.transaction = null;
root.abandonStillHandoff();
root.schedulePendingHotplug();
return;
}
@@ -325,6 +371,7 @@ Singleton {
const completed = root.transaction;
root.activeByOutput = observed;
root.transaction = null;
root.abandonStillHandoff();
root.lastError = "";
if (completed.automatic) {
root.slideshowPath = completed.policy.slideshowPath;
@@ -413,28 +460,53 @@ Singleton {
policy.mode = "single";
policy.globalPath = effectivePath;
policy.slideshowPath = effectivePath;
// Returning from a video: stop mpvpaper first, then give hyprpaper's
// service a beat to come back before the transaction talks to it.
// Returning from a video: stop mpvpaper first, then offer the policy to
// hyprpaper once its service is back, retrying while it is still coming
// up rather than betting on one long wait (see attemptStillHandoff).
if (VideoWallpaper.active) {
VideoWallpaper.stop();
root.pendingStillPolicy = policy;
root.stillHandoffAttempts = 0;
stillAfterVideo.restart();
return true;
}
return root.applyPolicy(policy, true, false);
}
// The still policy waiting for hyprpaper to come back, and how many times it
// has been offered to it. A single blind wait used to stand in for this: it
// was long enough to be felt on every switch and still short enough to lose
// whenever systemd was busy, and losing meant the desktop kept the video's
// last frame with an error in Settings and no wallpaper behind it.
property var pendingStillPolicy: null
property int stillHandoffAttempts: 0
readonly property int stillHandoffLimit: 3
Timer {
id: stillAfterVideo
interval: 900
onTriggered: {
if (root.pendingStillPolicy) {
root.applyPolicy(root.pendingStillPolicy, true, false);
root.pendingStillPolicy = null;
}
interval: 300
onTriggered: root.attemptStillHandoff()
}
function attemptStillHandoff(): void {
if (root.pendingStillPolicy === null)
return;
// The previous attempt's verification may still be winding down.
if (root.busy) {
stillAfterVideo.restart();
return;
}
root.stillHandoffAttempts += 1;
if (!root.applyPolicy(root.pendingStillPolicy, true, false)) {
// Rejected before hyprpaper was ever asked — retrying an invalid
// policy would only fail identically three more times.
root.abandonStillHandoff();
}
}
function abandonStillHandoff(): void {
root.pendingStillPolicy = null;
root.stillHandoffAttempts = 0;
}
function setMode(mode: string): bool {
@@ -458,6 +530,7 @@ Singleton {
DesktopPreferences.set("videoWallpaperPath", "");
VideoWallpaper.stop();
root.pendingStillPolicy = policy;
root.stillHandoffAttempts = 0;
stillAfterVideo.restart();
return true;
}