Files
Gabriel Brown 8b59b78d9f 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
2026-08-18 21:23:07 -04:00

192 lines
7.0 KiB
QML

pragma Singleton
// Panel brightness for external monitors, over DDC/CI.
//
// brightnessctl covers laptop panels through the kernel's backlight class. A
// desktop driving a DisplayPort monitor has no such device, so it has no
// brightness control at all -- the only way to dim the screen is the buttons on
// the bezel. DDC/CI is the channel those buttons drive, and monitors expose it
// over the same I2C lines that carry EDID.
//
// Two things shape everything here:
//
// Detection is slow. Probing every I2C bus takes on the order of a second,
// which is far too slow to sit in front of a settings page opening. It runs
// once, on demand, and afterwards each display is addressed by its bus number
// directly.
//
// Writes are slow AND rate-limited by the monitor's firmware. A slider drag
// emits values continuously; sending each one produces a queue the panel
// works through seconds after the user let go, and some monitors drop or
// garble writes that arrive too fast. So `value` updates immediately for the
// UI and the hardware write is debounced, with only the latest value sent.
//
// Displays are keyed by DRM connector name (DP-2) so they line up with what
// Hyprland, the Displays page, and the monitor list already call them.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-brightness"
// [{ bus, connector, model, value }] where value is 0..100.
property var displays: []
property bool scanning: false
// Empty when everything is fine. Carries the helper's explanation
// otherwise -- most usefully the udev command that grants I2C access,
// which is the difference between "brightness is unavailable" and
// "brightness is one command away".
property string lastError: ""
readonly property bool available: root.displays.length > 0
// True once a scan has completed, however it went. Lets the UI tell "not
// looked yet" apart from "looked and found nothing", which otherwise render
// identically and leave a permanently empty panel with no explanation.
property bool scanned: false
// Pending writes, keyed by bus. A monitor being dragged accumulates exactly
// one entry no matter how many values the slider emits.
property var pending: ({})
function refresh(): void {
if (root.scanning)
return;
root.scanning = true;
scan.running = true;
}
function displayFor(connector: string): var {
return root.displays.find(display => display.connector === connector) ?? null;
}
// Sets brightness for one display. The stored value moves at once so the
// slider tracks the pointer; the hardware follows when the drag settles.
function set(bus: int, percent: int): void {
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
root.displays = root.displays.map(display =>
display.bus === bus ? Object.assign({}, display, { value: clamped }) : display);
const next = Object.assign({}, root.pending);
next[String(bus)] = clamped;
root.pending = next;
writeDebounce.restart();
}
Process {
id: scan
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.displays = Array.isArray(parsed.displays) ? parsed.displays : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.displays = [];
root.lastError = "Could not read the brightness helper's output.";
console.warn("Brightness: could not parse helper output:", error);
}
root.scanning = false;
root.scanned = true;
}
}
}
// Long enough that a drag produces one write rather than dozens, short
// enough that a single click still feels immediate.
Timer {
id: writeDebounce
interval: 120
onTriggered: root.pump()
}
// Writes run one at a time, and each is read back.
//
// Serial because DDC/CI is a bus protocol with no arbitration: two ddcutil
// processes talking to the same monitor interleave their exchanges and both
// can come back with garbage. Read back because a write is not a promise --
// panels clamp to their own range, ignore values while waking from standby,
// and drop writes that arrive too quickly. Without the read the slider shows
// what Panama asked for rather than what the monitor did, which is the same
// class of lie as trusting `hyprctl keyword` to have applied something.
property int writingBus: -1
function pump(): void {
if (writer.running || reader.running)
return;
for (const bus in root.pending) {
const value = root.pending[bus];
const remaining = Object.assign({}, root.pending);
delete remaining[bus];
root.pending = remaining;
root.writingBus = parseInt(bus);
writer.command = [root.helperPath, "set", bus, String(value)];
writer.running = true;
return;
}
}
Process {
id: writer
onExited: {
reader.exited = false;
reader.streamFinished = false;
reader.settled = false;
reader.command = [root.helperPath, "get", String(root.writingBus)];
reader.running = true;
}
}
Process {
id: reader
property string outputText: ""
property bool exited: false
property bool streamFinished: false
property bool settled: false
stdout: StdioCollector {
onStreamFinished: {
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();
}
}