Review everything shipped this weekend, and fix what the reviewers caught
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user