diff --git a/config/dot/quickshell/scripts/calendar-agenda b/config/dot/quickshell/scripts/calendar-agenda index b22e8ca..5d79894 100755 --- a/config/dot/quickshell/scripts/calendar-agenda +++ b/config/dot/quickshell/scripts/calendar-agenda @@ -255,7 +255,11 @@ def watch(start: int, end: int) -> int: return 1 loop = GLib.MainLoop() - registry = EDataServer.SourceRegistry.new_sync(None) + try: + registry = EDataServer.SourceRegistry.new_sync(None) + except Exception: + _write_snapshot(_unavailable_snapshot()) + return 1 debounce_source = 0 subscriptions: list[int] = [] diff --git a/config/dot/quickshell/services/Brightness.qml b/config/dot/quickshell/services/Brightness.qml index c6db15b..fe9a9d3 100644 --- a/config/dot/quickshell/services/Brightness.qml +++ b/config/dot/quickshell/services/Brightness.qml @@ -138,6 +138,9 @@ Singleton { Process { id: writer onExited: { + reader.exited = false; + reader.streamFinished = false; + reader.settled = false; reader.command = [root.helperPath, "get", String(root.writingBus)]; reader.running = true; } @@ -145,19 +148,44 @@ Singleton { Process { id: reader + + property string outputText: "" + property bool exited: false + property bool streamFinished: false + property bool settled: false + stdout: StdioCollector { onStreamFinished: { - const actual = parseInt(this.text.trim()); - if (!isNaN(actual)) { - root.displays = root.displays.map(display => - display.bus === root.writingBus - ? Object.assign({}, display, { value: actual }) - : display); - } - root.writingBus = -1; - // Anything queued while this write was in flight goes now. - root.pump(); + reader.outputText = this.text; + reader.streamFinished = true; + root.settleReader(); } } + + // `running` can still read true at the moment onStreamFinished fires -- + // the same exited/streamFinished ordering hazard HomeAssistantConfig.qml + // guards against -- so pump() must not be re-entered from here directly. + // Settle on whichever of exited/streamFinished arrives last instead. + onExited: (code, status) => { + reader.exited = true; + root.settleReader(); + } + } + + function settleReader(): void { + if (reader.settled || !reader.exited || !reader.streamFinished) + return; + reader.settled = true; + + const actual = parseInt(reader.outputText.trim()); + if (!isNaN(actual)) { + root.displays = root.displays.map(display => + display.bus === root.writingBus + ? Object.assign({}, display, { value: actual }) + : display); + } + root.writingBus = -1; + // Anything queued while this write was in flight goes now. + root.pump(); } } diff --git a/config/dot/quickshell/services/CalendarAgenda.qml b/config/dot/quickshell/services/CalendarAgenda.qml index 0d49db2..6be3323 100644 --- a/config/dot/quickshell/services/CalendarAgenda.qml +++ b/config/dot/quickshell/services/CalendarAgenda.qml @@ -32,6 +32,13 @@ Singleton { property int fixtureNow: 0 property bool watchWanted: false + // Counts watch attempts that exit without ever emitting a snapshot, so a + // helper that is missing or crashes on launch (e.g. no evolution-data-server) + // is reported instead of retried forever in silence. + property int consecutiveWatchFailures: 0 + property bool watchProducedSnapshot: false + readonly property int maxConsecutiveWatchFailures: 3 + readonly property int nowEpoch: fixtureNow > 0 ? fixtureNow : Math.floor(clock.date.getTime() / 1000) readonly property var nextEvent: { const candidates = root.events.filter(event => !event.allDay && Number(event.end) > root.nowEpoch); @@ -135,14 +142,20 @@ Singleton { function _restartWatch(): void { if (root.fixtureMode || root.rangeStart <= 0 || root.rangeEnd <= root.rangeStart) return; - root.phase = root.events.length > 0 ? root.phase : "loading"; + // Once the helper has been declared unavailable, don't flash back to + // "loading" on every retry -- only a real snapshot should clear it. + if (root.phase !== "unavailable") + root.phase = root.events.length > 0 ? root.phase : "loading"; root.watchWanted = false; + root.watchProducedSnapshot = false; startTimer.restart(); } function consumeSnapshot(data: string): void { if (root.fixtureMode || data.trim() === "") return; + root.watchProducedSnapshot = true; + root.consecutiveWatchFailures = 0; try { const snapshot = JSON.parse(data); if (snapshot.ok !== true) { @@ -263,6 +276,7 @@ Singleton { root.errors = []; root.selectedDate = new Date(); root.phase = "loading"; + root.consecutiveWatchFailures = 0; root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth()); root._restartWatch(); } @@ -275,6 +289,16 @@ Singleton { onRead: data => root.consumeSnapshot(data) } onExited: (code, status) => { + if (!root.watchProducedSnapshot) { + root.consecutiveWatchFailures += 1; + // The helper died before ever emitting a snapshot, repeatedly -- + // stop pretending this is still loading and surface the same + // "unavailable" state probe()/collect_snapshot() report. + if (root.consecutiveWatchFailures >= root.maxConsecutiveWatchFailures) { + root.phase = "unavailable"; + root.errors = [{ code: "eds-unavailable" }]; + } + } if (root.watchWanted && !root.fixtureMode) restartTimer.restart(); } diff --git a/config/dot/quickshell/services/Capture.qml b/config/dot/quickshell/services/Capture.qml index 558e20c..48606b6 100644 --- a/config/dot/quickshell/services/Capture.qml +++ b/config/dot/quickshell/services/Capture.qml @@ -402,20 +402,35 @@ esac' id: recProc onExited: (code, status) => { recTimer.stop(); + const path = root.recordingPath; // SIGINT gives exit code 2 (or 130 through a shell); both mean the - // user pressed stop and the file was finalised normally. - if (root.recordingPath !== "") { - StatusEvents.publish({ - key: "capture-recording", - glyph: "\u{F044A}", - title: "Screen recording saved", - detail: root.recordingPath.split("/").pop(), - tone: "ok", - priority: StatusEvents.importantPriority, - actionId: "open-path", - actionData: root.recordingPath - }); - Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", root.recordingPath]); + // user pressed stop and the file was finalised normally. Any other + // code means wf-recorder died or errored before finalising, so the + // file may be missing or truncated -- never report success then. + const clean = code === 2 || code === 130; + if (path !== "") { + if (clean) { + StatusEvents.publish({ + key: "capture-recording", + glyph: "\u{F044A}", + title: "Screen recording saved", + detail: path.split("/").pop(), + tone: "ok", + priority: StatusEvents.importantPriority, + actionId: "open-path", + actionData: path + }); + Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", path]); + } else { + StatusEvents.publish({ + key: "capture-recording", + glyph: "\u{F044A}", + title: "Screen recording failed", + detail: "wf-recorder exited unexpectedly (code " + code + ")", + tone: "warn", + priority: StatusEvents.importantPriority + }); + } } root.recordingPath = ""; root.recordingSeconds = 0; diff --git a/config/dot/quickshell/services/Clipboard.qml b/config/dot/quickshell/services/Clipboard.qml index 7bfa5bf..3b50cd3 100644 --- a/config/dot/quickshell/services/Clipboard.qml +++ b/config/dot/quickshell/services/Clipboard.qml @@ -55,6 +55,18 @@ Singleton { // installed. Distinct from an empty history. property bool available: true + // query's `exited` and its stdout `streamFinished` are not guaranteed to + // fire in a particular order (the same signal-ordering hazard + // HomeAssistantConfig.qml's settle-both pattern guards against). These + // track which of the two have arrived for the query currently in flight + // so the result is only finalized once both are known -- otherwise a + // failed query's empty stdout could be parsed as "empty history" before + // the non-zero exit code is seen, or vice versa. + property bool _queryExited: false + property int _queryExitCode: 0 + property bool _queryStdoutDone: false + property string _queryStdoutText: "" + // Wall-clock seconds at the moment `entries` was filled. Relative times are // rendered against this rather than against a live clock, so a row's label // cannot change while the user is reading it and nothing has to tick. @@ -69,6 +81,8 @@ Singleton { if (query.running) return; root.loading = true; + root._queryExited = false; + root._queryStdoutDone = false; query.running = true; } @@ -146,16 +160,18 @@ Singleton { command: ["sqlite3", "-readonly", "-json", root.dbPath, root.sql] stdout: StdioCollector { - onStreamFinished: root._parse(this.text) + onStreamFinished: { + root._queryStdoutDone = true; + root._queryStdoutText = this.text; + root._settleQuery(); + } } onExited: exitCode => { root.loading = false; - if (exitCode !== 0) { - root.available = false; - root.entries = []; - root.refreshed(); - } + root._queryExited = true; + root._queryExitCode = exitCode; + root._settleQuery(); } } @@ -169,6 +185,29 @@ Singleton { } } + // Called from both query.onExited and its stdout streamFinished. Only + // finalizes once both signals have arrived, since their firing order is + // not guaranteed -- see the _queryExited / _queryStdoutDone comment + // above. A non-zero exit always means the history is unavailable, + // regardless of what (if anything) stdout produced; only a clean exit + // reaches _parse, where empty stdout is legitimately "no history yet". + function _settleQuery(): void { + if (!root._queryExited || !root._queryStdoutDone) + return; + const exitCode = root._queryExitCode; + const text = root._queryStdoutText; + root._queryExited = false; + root._queryStdoutDone = false; + + if (exitCode !== 0) { + root.available = false; + root.entries = []; + root.refreshed(); + return; + } + root._parse(text); + } + function _parse(text: string): void { root.available = true; root.queriedAt = Date.now() / 1000; diff --git a/config/dot/quickshell/services/Connectivity.qml b/config/dot/quickshell/services/Connectivity.qml index fc63542..ed22139 100644 --- a/config/dot/quickshell/services/Connectivity.qml +++ b/config/dot/quickshell/services/Connectivity.qml @@ -146,4 +146,11 @@ Singleton { onWifiDeviceChanged: root.syncScanners() onWifiEnabledChanged: root.syncScanners() onAdapterChanged: root.syncScanners() + + // adapter.enabled has no property on root to bind onXChanged to, so it + // needs its own Connections -- the Bluetooth equivalent of onWifiEnabledChanged. + Connections { + target: root.adapter + function onEnabledChanged(): void { root.syncScanners(); } + } } diff --git a/config/dot/quickshell/services/Geocoding.qml b/config/dot/quickshell/services/Geocoding.qml index 5f2d229..15d908d 100644 --- a/config/dot/quickshell/services/Geocoding.qml +++ b/config/dot/quickshell/services/Geocoding.qml @@ -40,6 +40,12 @@ Singleton { root.searching = false; if (exitCode !== 0) root.lastError = "Could not reach the location service."; + + // Typing kept going while this fetch was in flight -- rather than + // leaving the newer query stranded until another keystroke, go + // fetch it now. run() no-ops if pending is now too short. + if (root.pending !== root.lastQuery) + root.run(); } } diff --git a/config/dot/quickshell/services/Health.qml b/config/dot/quickshell/services/Health.qml index d79b450..b36ec4f 100644 --- a/config/dot/quickshell/services/Health.qml +++ b/config/dot/quickshell/services/Health.qml @@ -95,7 +95,16 @@ Singleton { property string payload: "" - onStarted: copyProcess.write(copyProcess.payload) + stdinEnabled: true + onStarted: { + copyProcess.write(copyProcess.payload); + // wl-copy reads stdin until EOF before it exits; leaving the + // channel open (Process.write alone never closes it) would hang + // it forever waiting for more input. Disabling stdin closes the + // write side -- see HomeAssistantConfig.qml's writeProc for the + // same stdinEnabled pattern. + copyProcess.stdinEnabled = false; + } onExited: (exitCode, exitStatus) => { root.lastCopyResult = exitCode === 0 ? "Report copied." @@ -339,6 +348,10 @@ Singleton { return false; copyProcess.payload = JSON.stringify(root.snapshot, null, 2); root.lastCopyResult = ""; + // Re-arm stdin: the previous run closed it (see copyProcess.onStarted) + // and a disabled channel stays closed even after being set back to + // true mid-run, so each new run needs it explicitly re-enabled. + copyProcess.stdinEnabled = true; copyProcess.exec(["wl-copy"]); return true; } diff --git a/config/dot/quickshell/services/KdeConnect.qml b/config/dot/quickshell/services/KdeConnect.qml index fdb66be..5507f85 100644 --- a/config/dot/quickshell/services/KdeConnect.qml +++ b/config/dot/quickshell/services/KdeConnect.qml @@ -25,6 +25,16 @@ 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. + property bool actionExited: false + property bool actionStdoutDone: false + property string actionStdoutText: "" + readonly property var preferredPhone: { const phones = root.devices.filter(device => device.type === "phone" && device.paired); return phones.find(device => device.reachable) ?? phones[0] ?? null; @@ -66,6 +76,8 @@ Singleton { root.transferActive = true; root.transferFileName = path.split("/").pop(); root.transferDeviceName = root.preferredPhone.name; + root.actionExited = false; + root.actionStdoutDone = false; actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path]; actionProc.running = true; } @@ -83,6 +95,8 @@ Singleton { return; root.actionKind = kind; root.actionPath = ""; + root.actionExited = false; + root.actionStdoutDone = false; actionProc.command = [root.helperPath, command, root.preferredPhone.id]; actionProc.running = true; } @@ -95,6 +109,22 @@ Singleton { root.transferActive = false; root.transferFileName = ""; root.transferDeviceName = ""; + root.actionExited = false; + root.actionStdoutDone = false; + } + + // 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. + function settleAction(): void { + if (root.actionKind === "") + return; + if (!root.actionExited || !root.actionStdoutDone) + return; + root.actionExited = false; + root.actionStdoutDone = false; + root.finishAction(root.actionStdoutText); } function finishAction(text: string): void { @@ -198,11 +228,15 @@ Singleton { Process { id: actionProc stdout: StdioCollector { - onStreamFinished: root.finishAction(this.text) + onStreamFinished: { + root.actionStdoutDone = true; + root.actionStdoutText = this.text; + root.settleAction(); + } } onExited: (code, status) => { - if (root.actionKind !== "") - root.finishAction(""); + root.actionExited = true; + root.settleAction(); } } diff --git a/config/dot/quickshell/services/Notifs.qml b/config/dot/quickshell/services/Notifs.qml index 368ef77..f457b99 100644 --- a/config/dot/quickshell/services/Notifs.qml +++ b/config/dot/quickshell/services/Notifs.qml @@ -4,7 +4,7 @@ pragma Singleton // The freedesktop notification server, plus the two lists the UI renders: // // popups — what Toasts.qml is currently showing (transient, timed) -// history — GNOME's message tray, what NotificationCenter.qml shows +// history — GNOME's message tray, what NotificationList.qml shows // // A notification lives exactly as long as `tracked` is true, so history holds // the *live* objects rather than copies: that keeps actions and inline replies @@ -80,6 +80,21 @@ Singleton { return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : ""; } + // Freedesktop timeout resolution, shared by the toast countdown (Toast.qml) + // and the no-display expiry a transient notification gets while Do Not + // Disturb is on (below). Critical urgency and an explicit expireTimeout + // override policy; -1 ("server decides") falls back to it. 0 means "never + // auto-expire" per spec. + function notificationTimeoutMs(notification: var): int { + if (notification.urgency === NotificationUrgency.Critical) + return Settings.notificationTimeoutCriticalMs; + if (notification.expireTimeout === 0) + return 0; + if (notification.expireTimeout > 0) + return Math.round(notification.expireTimeout * 1000); + return Settings.notificationTimeoutMs; + } + readonly property bool hasNotifications: root.history.length > 0 function notificationAppId(notification: var): string { @@ -156,7 +171,7 @@ Singleton { } // history grouped by app, in most-recent-app-first order — the shape - // NotificationCenter.qml renders directly. + // NotificationList.qml renders directly. readonly property var groups: { const out = []; const byApp = {}; @@ -219,8 +234,44 @@ Singleton { root.unreadCount += 1; } - if (!root.doNotDisturb) + if (!root.doNotDisturb) { root.popups = [notification].concat(root.popups); + } else if (notification.transient) { + // Never shown, and (being transient) never filed in history + // either — nothing will otherwise dismiss() it, so schedule + // the same release its popup timeout would have given it. + root.scheduleTransientExpiry(notification); + } + } + + // Runs a DND-hidden transient notification through the same lifetime it + // would have gotten as a visible popup (Toast.qml's countdown), just + // without ever showing it, so it still gets released instead of staying + // tracked forever. + function scheduleTransientExpiry(notification: var): void { + const ms = root.notificationTimeoutMs(notification); + if (ms <= 0) + return; + + const timer = Qt.createQmlObject("import QtQuick; Timer { repeat: false }", root); + timer.interval = ms; + timer.triggered.connect(() => { + timer.destroy(); + root.releaseTransient(notification); + }); + + // Closed some other way first (app-side close, dismissAll()) — cancel + // the pending timer instead of firing a stale dismiss() later. + notification.closed.connect(() => timer.destroy()); + timer.running = true; + } + + // Transient notifications are never filed in history, so nothing else + // holds a reference once their lifetime ends — release tracked state + // directly. Shared by the DND-hidden expiry above and dismissAll() below. + function releaseTransient(n: var): void { + if (n.transient) + n.dismiss(); } // ── Mutation ──────────────────────────────────────────────────────────── @@ -243,10 +294,7 @@ Singleton { if (next.length === root.popups.length) return; root.popups = next; - - // Transients were never in history, so nothing else holds them. - if (n.transient) - n.dismiss(); + root.releaseTransient(n); } function dismiss(n: Notification): void { @@ -257,10 +305,19 @@ Singleton { // Copy first: dismiss() re-enters through forget() and rewrites both // lists while we iterate. const all = root.history.slice(); + + // Transient notifications are never filed in history — whether + // currently shown as a popup or hidden by Do Not Disturb with a + // pending scheduleTransientExpiry() timer — so releasing them needs + // its own pass over the server's full tracked set. + const transients = root.active.values.filter(n => n.transient); + root.history = []; root.popups = []; for (const n of all) n.dismiss(); + for (const n of transients) + root.releaseTransient(n); root.unreadCount = 0; } diff --git a/config/dot/quickshell/services/SoundFeedback.qml b/config/dot/quickshell/services/SoundFeedback.qml index 1a1f142..d8a17cb 100644 --- a/config/dot/quickshell/services/SoundFeedback.qml +++ b/config/dot/quickshell/services/SoundFeedback.qml @@ -35,13 +35,32 @@ Singleton { function setEventSounds(enabled: bool): void { root.eventSounds = enabled; - eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(enabled)]; - eventWrite.running = true; + root._writeEventSounds(); } function setInputFeedback(enabled: bool): void { root.inputFeedback = enabled; - inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(enabled)]; + root._writeInputFeedback(); + } + + // Assigning `running = true` to a Process that is already running is a + // no-op, not a queue -- so a toggle that lands mid-write would otherwise + // be dropped silently. eventWrite.onExited re-checks root.eventSounds + // against what was actually written and calls this again if they still + // disagree. + function _writeEventSounds(): void { + if (eventWrite.running) + return; + eventWrite.writtenValue = root.eventSounds; + eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(root.eventSounds)]; + eventWrite.running = true; + } + + function _writeInputFeedback(): void { + if (inputWrite.running) + return; + inputWrite.writtenValue = root.inputFeedback; + inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(root.inputFeedback)]; inputWrite.running = true; } @@ -71,6 +90,7 @@ Singleton { Process { id: eventWrite + property bool writtenValue: true onExited: (code, status) => { if (code !== 0) { root.lastError = "Event sound preferences could not be changed."; @@ -78,11 +98,14 @@ Singleton { } else { root.lastError = ""; } + if (root.eventSounds !== eventWrite.writtenValue) + root._writeEventSounds(); } } Process { id: inputWrite + property bool writtenValue: false onExited: (code, status) => { if (code !== 0) { root.lastError = "Input feedback preferences could not be changed."; @@ -90,6 +113,8 @@ Singleton { } else { root.lastError = ""; } + if (root.inputFeedback !== inputWrite.writtenValue) + root._writeInputFeedback(); } } diff --git a/config/dot/quickshell/services/SystemLocale.qml b/config/dot/quickshell/services/SystemLocale.qml index 6a5b7b7..773cbe3 100644 --- a/config/dot/quickshell/services/SystemLocale.qml +++ b/config/dot/quickshell/services/SystemLocale.qml @@ -46,9 +46,25 @@ Singleton { list.running = true; } + // A picker click while a change is still applying is queued rather than + // fired directly: assigning `running = true` to an already-running + // Process is a no-op, so a second set() here would otherwise be silently + // dropped and apply.onExited would then adopt the second value as if it + // had actually been applied. requestedValue always holds the latest + // request; apply.onExited re-fires with it once the in-flight apply + // settles, mirroring Brightness.qml's pending-write queue. + property string requestedValue: "" + function set(value: string): void { if (value === root.current) return; + root.requestedValue = value; + if (!apply.running) + root._applyValue(value); + } + + function _applyValue(value: string): void { + root.requestedValue = ""; apply.command = [root.helperPath, "set", value]; apply.pendingValue = value; apply.running = true; @@ -94,6 +110,16 @@ Singleton { } else { root.lastError = "The system did not accept that language. It may have needed a password."; } + + // A set() call that arrived while this apply was running only + // queued itself in requestedValue (see the comment above). Fire + // it now if it still names something other than what was just + // applied, so the UI never settles on a locale the system was + // never actually asked for. + if (root.requestedValue !== "" && root.requestedValue !== apply.pendingValue) + root._applyValue(root.requestedValue); + else + root.requestedValue = ""; } } }