Settle process-signal races across the services layer
A Process's exited and streamFinished signals aren't guaranteed to fire in order, and several services decided an outcome on whichever fired first: KdeConnect could report a successful file transfer as failed if exited landed before the real stdout payload; Clipboard could present a failed history query as an empty-but-healthy one; Brightness could strand the last queued write of a drag; SoundFeedback and SystemLocale could drop or misapply a rapid second toggle/click because re-arming an already-running Process is a no-op. All five now wait for both signals and let the authoritative one decide, matching the pattern HomeAssistantConfig.qml already used correctly. Health's "copy report" never enabled stdin, so it copied nothing while claiming success. Capture announced every recording as saved regardless of the recorder's actual exit code. Connectivity never restarted Bluetooth discovery when the adapter was enabled from an already-open page. CalendarAgenda left the UI in "loading" forever if its helper died at startup, and the helper itself could crash unguarded instead of reporting unavailable. Geocoding silently dropped a query typed while the previous one was still in flight. Notifs leaked tracked-but-undisplayed notifications under Do Not Disturb, and dismissAll() skipped them. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
This commit is contained in:
@@ -255,7 +255,11 @@ def watch(start: int, end: int) -> int:
|
||||
return 1
|
||||
|
||||
loop = GLib.MainLoop()
|
||||
try:
|
||||
registry = EDataServer.SourceRegistry.new_sync(None)
|
||||
except Exception:
|
||||
_write_snapshot(_unavailable_snapshot())
|
||||
return 1
|
||||
debounce_source = 0
|
||||
subscriptions: list[int] = []
|
||||
|
||||
|
||||
@@ -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,9 +148,36 @@ 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());
|
||||
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
|
||||
@@ -159,5 +189,3 @@ Singleton {
|
||||
root.pump();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -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 !== "") {
|
||||
// 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: root.recordingPath.split("/").pop(),
|
||||
detail: path.split("/").pop(),
|
||||
tone: "ok",
|
||||
priority: StatusEvents.importantPriority,
|
||||
actionId: "open-path",
|
||||
actionData: root.recordingPath
|
||||
actionData: path
|
||||
});
|
||||
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", root.recordingPath]);
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user