Make changing a default application actually take effect

Three bugs, all silent, all in the same feature.

The write always worked. What failed was the refresh after it. That
refresh is called from the mutation's own onExited handler and was
guarded on `busy`, a binding over both processes -- and a binding hands
back its cached value until the change notification feeding it has been
delivered, which inside that handler has not happened yet. So `busy`
read true, refresh returned immediately, and the page kept showing the
old application with no error anywhere. Guards now read the Process
objects directly, where the value is current, and a refresh is no longer
blocked by the mutation that asked for it.

The service also kept its own list of which roles it would accept. It
stayed at seven when the helper and the page grew documents, text and
archives, so choosing a PDF viewer set an error and changed nothing.
It is derived from the snapshot now.

And category matching never worked. DesktopEntries returns a QML list,
for which Array.isArray is false, so the code stringified it into
"Network,WebBrowser" and split on ";" alone -- one token matching no
category. Browsers still appeared because their generic name contains
"web browser" and the terms fallback carried the role by itself.
Archives matched nothing at all, so that row could only ever offer the
application it already had.

The harness that should have caught the first bug passed while it was
live: it set each role to the value it already had and asserted no error
appeared, and the bug produces no error. It now changes a role to a
genuinely different application, requires the service to observe the new
value, and changes it back -- with the contract restoring the original
from the outside however the run ends.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-19 11:59:53 -04:00
parent 2528edfddc
commit b743f44c5b
4 changed files with 301 additions and 15 deletions
+134
View File
@@ -0,0 +1,134 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
// Does a change made through the service actually stick, and does the service
// SEE that it stuck?
//
// The first version of this harness set every role to the handler it already
// had and asserted no error appeared. It passed while the feature was broken:
// the write landed, the refresh afterwards silently no-oped, and the settings
// page kept showing the old value with no error anywhere. "No error" is not
// evidence of anything -- the value has to move.
//
// So this changes one role to a genuinely different application, waits for the
// service to report the new value, and changes it back. The contract restores
// the original from the outside regardless of how this ends.
ShellRoot {
id: root
readonly property string role: "browser"
property string original: ""
property string target: ""
property int phase: 0
property var log: []
property bool done: false
function desktopId(entry) {
const id = String(entry?.id ?? "");
return id.endsWith(".desktop") ? id : id + ".desktop";
}
function isBrowser(entry) {
// DesktopEntries returns a QML list, for which Array.isArray is false;
// joining first and splitting on both separators handles either form.
const raw = entry.categories;
const joined = Array.isArray(raw) ? raw.join(";") : String(raw ?? "");
for (const value of joined.split(/[;,]/)) {
if (value.trim().toLowerCase() === "webbrowser")
return true;
}
return false;
}
function finish(outcome) {
if (root.done)
return;
root.done = true;
Quickshell.execDetached(["sh", "-c",
"printf '%s' " + JSON.stringify(JSON.stringify({
outcome: outcome,
original: root.original,
target: root.target,
log: root.log,
error: String(DefaultApps.lastError)
})) + " > " + Quickshell.env("PANAMA_HARNESS_OUT")]);
Qt.callLater(() => Qt.quit());
}
Component.onCompleted: DefaultApps.refresh()
Timer {
interval: 300
repeat: true
running: !root.done
onTriggered: {
const apps = DesktopEntries.applications.values;
const handlers = DefaultApps.handlers ?? ({});
// DesktopEntries populates asynchronously; the service checks a
// desktop id against it, so starting early fails for the wrong
// reason.
if (apps.length === 0 || Object.keys(handlers).length === 0)
return;
if (DefaultApps.busy)
return;
const current = String(handlers[root.role] ?? "");
if (root.phase === 0) {
root.original = current;
const other = apps.filter(entry => root.isBrowser(entry)
&& root.desktopId(entry) !== current);
if (other.length === 0) {
root.finish("skipped-no-second-browser");
return;
}
root.target = root.desktopId(other[0]);
root.log = root.log.concat(["start at " + current]);
root.phase = 1;
DefaultApps.setDefault(root.role, root.target);
return;
}
if (root.phase === 1) {
if (current !== root.target) {
root.log = root.log.concat(["after change the service still reports " + current]);
root.phase = 4;
// Put it back before reporting the failure.
DefaultApps.setDefault(root.role, root.original);
return;
}
root.log = root.log.concat(["service observed " + current]);
root.phase = 2;
DefaultApps.setDefault(root.role, root.original);
return;
}
if (root.phase === 2) {
if (current !== root.original) {
root.log = root.log.concat(["restore not observed, service reports " + current]);
root.finish("restore-not-observed");
return;
}
root.log = root.log.concat(["restored to " + current]);
root.finish("ok");
return;
}
if (root.phase === 4) {
root.finish("change-not-observed");
return;
}
}
}
// A stuck harness fails rather than hangs.
Timer {
interval: 40000
running: true
onTriggered: root.finish("timeout")
}
}