Tier 0: render what the services already decided, honestly

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 00:24:30 -04:00
parent be0e55214b
commit 8b1205e4b8
38 changed files with 1474 additions and 260 deletions
@@ -21,9 +21,65 @@ import QtQuick
Singleton {
id: root
// Set by the page while it is visible; drives both scanners.
// Set by the settings page while it is visible. One of several holds on
// the radios rather than the only one -- see the scan holds below.
property bool active: false
// ── Scan holds ───────────────────────────────────────────────────────────
//
// Two surfaces list the same radios: this page and the quick-settings
// panel. Both used to write scannerEnabled and adapter.discovering from
// their own visibility flag, and the last writer won -- so closing the
// settings page turned scanning off underneath an open quick-settings
// panel, which then sat on "Searching…" forever with nothing searching.
//
// So the radios are held rather than switched. Each surface acquires a
// named hold while it is on screen and releases it when it goes away, and
// scanning runs while ANY hold is outstanding. Names rather than a counter:
// these holders are QML items that can be destroyed with a release already
// in flight, and a counter that drifts either leaves the radio on forever
// or turns it off under somebody. Acquiring a name twice is acquiring it
// once.
property var wifiScanHolders: []
property var discoveryHolders: []
readonly property bool wifiScanWanted: root.wifiScanHolders.length > 0
readonly property bool discoveryWanted: root.discoveryHolders.length > 0
// Whether it was this service that started BlueZ discovering. Something
// else on the machine may already have been -- bluetoothctl, another
// desktop component -- and stopping a scan we did not start is not ours to
// do.
property bool discoveryOwned: false
function acquireWifiScan(holder: string): void {
if (holder === "" || root.wifiScanHolders.indexOf(holder) >= 0)
return;
root.wifiScanHolders = root.wifiScanHolders.concat([holder]);
root.syncScanners();
}
function releaseWifiScan(holder: string): void {
if (root.wifiScanHolders.indexOf(holder) < 0)
return;
root.wifiScanHolders = root.wifiScanHolders.filter(name => name !== holder);
root.syncScanners();
}
function acquireDiscovery(holder: string): void {
if (holder === "" || root.discoveryHolders.indexOf(holder) >= 0)
return;
root.discoveryHolders = root.discoveryHolders.concat([holder]);
root.syncScanners();
}
function releaseDiscovery(holder: string): void {
if (root.discoveryHolders.indexOf(holder) < 0)
return;
root.discoveryHolders = root.discoveryHolders.filter(name => name !== holder);
root.syncScanners();
}
readonly property var wifiDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wifi)
@@ -213,20 +269,39 @@ Singleton {
return "Could not connect";
}
// Scanning follows visibility. NetworkManager keeps scanning as long as it
// is asked to, and Bluetooth discovery is worse -- it holds the radio.
// Scanning follows the outstanding holds, not any one surface's visibility.
// NetworkManager keeps scanning as long as it is asked to, and Bluetooth
// discovery is worse -- it holds the radio.
//
// Computed from state and applied idempotently, so calling this after every
// acquire, release and device change is free: it writes only when the radio
// is not already where the holds say it should be.
function syncScanners(): void {
if (root.wifiDevice)
root.wifiDevice.scannerEnabled = root.active && root.wifiEnabled;
root.wifiDevice.scannerEnabled = root.wifiScanWanted && root.wifiEnabled;
if (root.adapter && root.adapter.enabled) {
const shouldDiscover = root.active;
if (root.adapter.discovering !== shouldDiscover)
root.adapter.discovering = shouldDiscover;
if (!root.adapter || !root.adapter.enabled)
return;
const shouldDiscover = root.discoveryWanted;
if (shouldDiscover && !root.adapter.discovering) {
root.adapter.discovering = true;
root.discoveryOwned = true;
} else if (!shouldDiscover && root.discoveryOwned && root.adapter.discovering) {
root.adapter.discovering = false;
root.discoveryOwned = false;
}
}
onActiveChanged: root.syncScanners()
// The settings page's own hold, expressed through the flag it already sets.
onActiveChanged: {
if (root.active) {
root.acquireWifiScan("settings");
root.acquireDiscovery("settings");
} else {
root.releaseWifiScan("settings");
root.releaseDiscovery("settings");
}
}
onWifiDeviceChanged: root.syncScanners()
onWifiEnabledChanged: root.syncScanners()
onAdapterChanged: root.syncScanners()
@@ -25,30 +25,70 @@ Singleton {
property var facts: []
property bool scanned: false
// Why the last read produced nothing, when it produced nothing.
//
// A security readout with no facts is a read that failed, not a machine
// with nothing to say, and the two are indistinguishable downstream:
// attentionCount is 0 for an empty list exactly as it is for a clean one,
// so a page reading only that would answer "everything is in its
// recommended state" on top of a helper that never ran. The helper builds
// its JSON with jq and prints `[]` when jq is missing, so this is a real
// failure mode and not a hypothetical one.
//
// Read it against an empty `facts`: a read that produced facts AND wrote
// something to stderr is a helper being chatty, not a failed check.
property string lastError: ""
// The facts that are not in their reassuring state. The page leads with the
// count so a machine that is entirely fine says so in one line instead of
// making the user read five rows to find out.
readonly property int attentionCount: root.facts.filter(fact => !fact.ok).length
function refresh(): void {
if (!query.running)
if (!query.running) {
root.lastError = "";
query.running = true;
}
}
// Only fills in a reason nothing else has given, so whichever of stderr,
// the exit code and the parse notices the failure first keeps the say.
function blame(reason: string): void {
if (root.lastError === "")
root.lastError = reason;
}
function absorb(text: string): void {
const answer = text.trim();
if (answer === "") {
root.facts = [];
root.blame("the security helper answered with nothing.");
} else {
try {
const parsed = JSON.parse(answer);
root.facts = Array.isArray(parsed) ? parsed : [];
if (root.facts.length === 0)
root.blame("the security helper reported no facts at all.");
} catch (error) {
root.facts = [];
root.blame("the security helper's answer could not be read.");
console.warn("DeviceSecurity: could not parse helper output:", error);
}
}
root.scanned = true;
}
Process {
id: query
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.facts = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.facts = [];
console.warn("DeviceSecurity: could not parse helper output:", error);
}
root.scanned = true;
}
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.blame(this.text.trim())
}
onExited: (code, status) => {
if (code !== 0)
root.blame("the security helper exited with code " + code + ".");
root.scanned = true;
}
}
}
@@ -154,6 +154,20 @@ Singleton {
function addService(name: string): void { root.run(["add-service", name]); }
function removePort(spec: string): void { root.run(["remove-port", spec]); }
function addPort(spec: string): void { root.run(["add-port", spec]); }
// One press, one change. The open range a page shows as a single rule is
// usually two rules underneath -- Fedora's zone opens the high ports for
// tcp and for udp -- and a loop of removePort() calls would drop every
// iteration after the first on run()'s `mutation.running` guard, leaving
// half the range open under a confirmation that promised all of it. The
// helper takes the whole list, so it is one firewall-cmd invocation, one
// password prompt, and one snapshot afterwards.
function removePorts(specs: var): void {
const list = (specs ?? []).map(spec => String(spec)).filter(spec => spec !== "");
if (list.length === 0)
return;
root.run(["remove-port"].concat(list));
}
function setZone(interfaceName: string, zoneName: string): void {
root.run(["set-zone", interfaceName, zoneName]);
}
+58
View File
@@ -27,6 +27,61 @@ Singleton {
property bool postRepairScanPending: false
readonly property bool actionable: root.status === "warning" || root.status === "error"
// ── The one health verdict ──────────────────────────────────────────────
//
// The Health page's hero and the Settings sidebar's footer each used to
// work this out themselves, and they disagreed about the order of the
// first two questions: the hero asked "is the diagnostic unavailable?"
// first, the footer asked "have we got any checks?" first. A snapshot
// rejected AFTER a good one satisfies both -- checks are still there,
// diagnosticUnavailable is true -- so the same desktop was a red "Health
// check unavailable" in the hero and a green "Desktop is healthy" in the
// footer, at the same time, six inches apart.
//
// Unavailable comes first because it is the only state that says the other
// four are not known to be true. Everything after it describes checks that
// actually ran.
readonly property string headlineState: {
if (root.diagnosticUnavailable)
return "unavailable";
if (root.checks.length === 0)
return "checking";
if (root.status === "error")
return "error";
if (root.status === "warning")
return "warning";
return "healthy";
}
// Rendered verbatim by both surfaces. The words match the ones
// HealthCheckRow puts on an individual check, so "Needs attention" means
// the same thing wherever it appears.
readonly property string headline: {
switch (root.headlineState) {
case "unavailable": return "Health check unavailable";
case "checking": return "Checking the desktop";
case "error": return "Action required";
case "warning": return "Needs attention";
default: return "Desktop is healthy";
}
}
// "danger" | "warn" | "muted" | "ok". Named rather than a colour: Theme is
// a UI concern and this is a service.
readonly property string tone: {
switch (root.headlineState) {
case "unavailable":
case "error": return "danger";
case "warning": return "warn";
case "checking": return "muted";
default: return "ok";
}
}
// How many checks the headline is about. Zero unless something is wrong,
// and shared for the same reason the headline is.
readonly property int observationCount: root.summary.warnings + root.summary.errors
readonly property bool busy: scanProcess.running || repairProcess.running
|| singleCheckProcess.running || root.postRepairScanPending
readonly property string helperPath: Quickshell.env("PANAMA_HEALTH_HELPER")
@@ -569,6 +624,9 @@ Singleton {
summary: root.summary,
busy: root.busy,
diagnosticUnavailable: root.diagnosticUnavailable,
headlineState: root.headlineState,
headline: root.headline,
tone: root.tone,
queuedRefresh: root.queuedRefresh,
generation: root.generation,
acceptedGeneration: root.acceptedGeneration,
+17
View File
@@ -33,8 +33,25 @@ Singleton {
property var popups: []
// Suppresses toasts entirely. Notifications still reach history.
//
// Turning it ON also sweeps the banners already up -- Do Not Disturb that
// leaves the current interruptions running until they time out is a
// switch that takes effect later, which nobody means by it. Swept
// transients get the same scheduled release the DND arrival path gives
// them, so nothing stays tracked forever; everything else is already in
// history.
property bool doNotDisturb: false
onDoNotDisturbChanged: {
if (!root.doNotDisturb || root.popups.length === 0)
return;
for (const notification of root.popups) {
if (notification.transient)
root.scheduleTransientExpiry(notification);
}
root.popups = [];
}
// Cleared when the notification center is opened. The bar binds to this.
property int unreadCount: 0
@@ -25,6 +25,13 @@ Singleton {
property var snapshots: []
property string lastError: ""
// save() and create() are asynchronous: the helper is a separate process,
// and the call returns long before settings.json has been read. Anything
// that must not happen until the snapshot is safely on disk -- the reset in
// SystemSettings.restoreDefaults is the whole reason this exists -- waits
// for this rather than for the call to return.
signal saveFinished(bool success)
// Narrow service boundaries keep restore sequencing explicit and make it
// possible to verify the real handler in an isolated shell without ever
// calling the daily-driver compositor or wallpaper services.
@@ -93,6 +100,9 @@ Singleton {
}
onStarted: actionRun.outputText = ""
onExited: (exitCode, exitStatus) => {
// Read before any branch below can start the next verb and change
// it: only a save reports through saveFinished.
const wasSave = !actionRun.restoring && actionRun.doneAction === "saved";
if (exitCode !== 0) {
root.lastError = actionRun.restoring
? "That snapshot could not be restored."
@@ -104,6 +114,8 @@ Singleton {
root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
}
if (wasSave)
root.saveFinished(false);
return;
}
if (actionRun.restoring) {
@@ -120,6 +132,8 @@ Singleton {
} else
root.lastError = "";
root.refresh();
if (wasSave)
root.saveFinished(true);
}
}
@@ -196,9 +210,21 @@ Singleton {
// Returns false when a snapshot is already running rather than queueing:
// the caller is about to wipe the stores, and a snapshot landing after
// that would record the wiped state as if it were the user's.
SystemSettings.takeSafetySnapshot = function() {
//
// The return value only says the snapshot STARTED. The helper reads
// settings.json in another process, so `done(success)` -- fired from
// saveFinished -- is the only point at which the file on disk is known
// to hold the user's settings rather than whatever the caller is about
// to replace them with. The caller does its irreversible work there.
SystemSettings.takeSafetySnapshot = function(done) {
if (actionRun.running)
return false;
const handler = function(success) {
root.saveFinished.disconnect(handler);
if (done)
done(success);
};
root.saveFinished.connect(handler);
root.save();
return true;
};
+60 -7
View File
@@ -34,6 +34,17 @@ Singleton {
// chip in the channel strip.
property string playingChannel: ""
// Why the last test did not work, "" when it did.
//
// Every failure here is otherwise invisible, and invisible in the worst
// possible way: this page exists to answer "is this the right speaker" and
// "does this microphone work", and both questions are answered by silence.
// A pw-play that is not installed, a sample file that is missing, a capture
// device that never produces a sample -- all three look exactly like a dead
// speaker or a dead microphone from the chair, which is the wrong answer
// to the question the page was opened to ask.
property string lastError: ""
// ── Channels ────────────────────────────────────────────────────────────
//
// The freedesktop theme ships one spoken sample per channel, and the set it
@@ -86,15 +97,22 @@ Singleton {
function playChannel(node: var, channelName: string): void {
const file = root.sampleFor(String(channelName ?? ""));
if (file === "")
if (file === "") {
root.lastError = "No sample is installed for that channel.";
return;
}
root.playSample(node, file, String(channelName));
}
function playSample(node: var, file: string, channelName: string): void {
const target = String(node?.name ?? "");
if (target === "" || player.running)
if (player.running)
return;
const target = String(node?.name ?? "");
if (target === "") {
root.lastError = "PipeWire has no name for that output, so nothing could be played to it.";
return;
}
root.lastError = "";
root.playingChannel = channelName;
player.command = ["pw-play", "--target", target, file];
player.running = true;
@@ -102,7 +120,14 @@ Singleton {
Process {
id: player
onExited: (code, status) => root.playingChannel = ""
onExited: (code, status) => {
root.playingChannel = "";
// A silent speaker and a pw-play that never ran are the same
// experience and different problems.
if (code !== 0)
root.lastError = "The test sound could not be played — pw-play exited with code "
+ code + ". The freedesktop sound theme or pipewire-utils may be missing.";
}
}
// ── Microphone test ─────────────────────────────────────────────────────
@@ -124,9 +149,15 @@ Singleton {
readonly property int micTestRate: 48000
function startMicTest(sourceNode: var, sinkNode: var): void {
const source = String(sourceNode?.name ?? "");
if (source === "" || root.micTestState !== "idle")
if (root.micTestState !== "idle")
return;
const source = String(sourceNode?.name ?? "");
if (source === "") {
root.lastError = "PipeWire has no name for that microphone, so nothing could be recorded from it.";
return;
}
root.lastError = "";
root.micTestTimedOut = false;
// pw-play needs a target too, or the playback lands on the default
// output rather than the one being looked at.
@@ -162,6 +193,10 @@ Singleton {
property string micTestSink: ""
property bool micTestCancelled: false
// Set by the watchdog, read by the exit handler: a recorder killed for
// stalling and a recorder that failed outright both come back non-zero,
// and only one of them means "this microphone produced no sound".
property bool micTestTimedOut: false
Timer {
id: micTestWatchdog
@@ -169,8 +204,10 @@ Singleton {
// capture that has stalled rather than one that is merely slow.
interval: (root.micTestSeconds + 3) * 1000
onTriggered: {
if (recorder.running)
if (recorder.running) {
root.micTestTimedOut = true;
recorder.signal(15);
}
}
}
@@ -180,11 +217,21 @@ Singleton {
micTestWatchdog.stop();
if (root.micTestCancelled) {
root.micTestCancelled = false;
root.micTestTimedOut = false;
root.micTestState = "idle";
return;
}
if (root.micTestTimedOut) {
root.micTestTimedOut = false;
root.micTestState = "idle";
root.lastError = "That microphone produced no sound in "
+ (root.micTestSeconds + 3) + " seconds. It may be muted at the device.";
return;
}
if (code !== 0) {
root.micTestState = "idle";
root.lastError = "Nothing could be recorded — pw-record exited with code "
+ code + ".";
return;
}
root.micTestState = "playing";
@@ -198,8 +245,14 @@ Singleton {
Process {
id: playback
onExited: (code, status) => {
// A cancelled playback was killed on purpose; its non-zero exit is
// not a failure to report.
const cancelled = root.micTestCancelled;
root.micTestCancelled = false;
root.micTestState = "idle";
if (!cancelled && code !== 0)
root.lastError = "The recording was made but could not be played back — "
+ "pw-play exited with code " + code + ".";
}
}
}
@@ -51,7 +51,12 @@ Singleton {
// singleton and a mutual reference between two singletons is an
// initialisation-order problem waiting to happen. Tests override it the
// same way they override the seams above.
property var takeSafetySnapshot: function() { return false; }
//
// Contract: takeSafetySnapshot(done) returns false when the snapshot could
// not even be STARTED, and otherwise calls done(success) once the helper
// has finished. It is asynchronous, so the return value says nothing about
// whether anything is on disk yet.
property var takeSafetySnapshot: function(done) { return false; }
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; }
@@ -532,6 +537,12 @@ Singleton {
return DesktopPreferences.set(key, value);
}
// True from the moment a reset asks for its safety snapshot until that
// snapshot answers. The UI has no undo, so a second request in that window
// is refused rather than allowed to race the first one's wipe, and the page
// keeps the confirmation on screen for as long as it is true.
property bool resetPending: false
// Restores shipped defaults across every store Panama owns, not just the
// schema. Panama keeps user state in more than one file -- the schema store,
// the focus session, and the Home accessory arrangement -- and a reset that
@@ -539,23 +550,50 @@ Singleton {
//
// Compositor-backed values are re-applied afterwards, since resetting the
// stored value does not by itself tell Hyprland anything.
//
// Returns whether the reset was STARTED. The wipe itself happens later, in
// the snapshot's success path -- see performReset below.
function restoreDefaults(): bool {
if (root.displayBusy()) {
root.lastError = "Finish the current display change before restoring defaults.";
return false;
}
if (root.resetPending) {
root.lastError = "A reset is already under way.";
return false;
}
// Snapshot before wiping. Restoring defaults clears every preference
// and the Home accessory store, and there is no undo for it anywhere in
// the app -- so the one automatic snapshot Panama takes is the one taken
// immediately before the only irreversible action it offers.
//
// Deliberately not fatal if it fails: a user who asked to reset should
// get their reset, and a snapshot that could not be written is reported
// rather than allowed to block the thing they asked for.
if (!root.takeSafetySnapshot())
console.warn("SystemSettings: could not snapshot before restoring defaults");
// The snapshot is a separate process reading settings.json. Wiping the
// stores here, as this used to, meant the file was already back to
// shipped defaults by the time the helper opened it: the "undoable"
// promise in the confirm recorded the RESET state, not the user's. So
// the reset now lives entirely inside the completion path, and a
// snapshot that fails takes the reset down with it -- the alternative is
// an irreversible action offered as a reversible one.
root.resetPending = true;
const started = root.takeSafetySnapshot(function(success) {
root.resetPending = false;
if (!success) {
root.lastError = "Your settings could not be backed up, so nothing was reset.";
return;
}
root.lastError = "";
root.performReset();
});
if (!started) {
root.resetPending = false;
root.lastError = "Your settings could not be backed up, so nothing was reset.";
return false;
}
return true;
}
function performReset(): void {
root.setDisplayBlocked(true);
DesktopPreferences.resetDesktopDefaults();
@@ -573,7 +611,6 @@ Singleton {
HomePreferences.resetHomeDefaults();
resettleTimer.restart();
return true;
}
Timer {
+77 -12
View File
@@ -45,9 +45,57 @@ Singleton {
+ Number(root.firmware?.count ?? 0)
readonly property int securityCount: Number(root.dnf?.securityCount ?? 0)
// Whether the advisory query answered at all. It only ever shrinks the
// alarm -- the page says "nothing security-critical" when the count is
// zero -- so a failed query returning zero would be reassurance nobody
// measured.
readonly property bool securityKnown: root.dnf?.securityKnown !== false
readonly property bool rebootNeeded: root.kernel?.rebootNeeded === true
// The last time EVERY source answered. The helper carries this forward
// rather than restamping it on a partial failure, so it never describes a
// check that did not complete.
readonly property bool everChecked: root.checkedAt > 0
// ── What is not known, and why ──────────────────────────────────────────
//
// The three sources fail independently, so they carry their own errors.
// A source that could not answer has an UNKNOWN count, which is not a count
// of zero -- and the whole distance between those two is the distance
// between "Up to date" and a lie. `total` sums what was actually counted,
// so it is only the whole truth when nothing failed.
readonly property var failedSources: {
const failed = [];
for (const source of ["dnf", "flatpak", "firmware"]) {
const record = source === "dnf" ? root.dnf
: source === "flatpak" ? root.flatpak : root.firmware;
if (String(record?.error ?? "") !== "")
failed.push(source);
}
return failed;
}
readonly property bool anySourceFailed: root.failedSources.length > 0
function sourceError(source: string): string {
const record = source === "dnf" ? root.dnf
: source === "flatpak" ? root.flatpak
: source === "firmware" ? root.firmware : null;
return String(record?.error ?? "");
}
// "System packages", or "System packages and Firmware" -- named so the
// headline can say which source it could not reach rather than leaving
// someone to work it out from three cards.
function describeFailedSources(): string {
const names = root.failedSources.map(source => root.sourceLabel(source));
if (names.length <= 1)
return names.join("");
return names.slice(0, -1).join(", ") + " and " + names[names.length - 1];
}
// How much there is to fetch, when every pending item was priced. The
// helper omits the figure for a source it could only partly price, and a
// partial total presented as the whole download understates it -- which is
@@ -117,9 +165,20 @@ Singleton {
}
// A count nobody has verified is not a count. Saying "up to date" on the
// strength of a check that never ran is the one wrong answer that looks
// reassuring.
// strength of a check that never ran -- or one that failed -- is the one
// wrong answer that looks reassuring.
//
// A source that could not answer is checked FIRST and by name. "Up to date"
// is a claim about all three, and it cannot be made while one of them is
// unknown, however many the other two counted.
function summary(): string {
if (root.anySourceFailed) {
const which = root.describeFailedSources();
return root.total > 0
? root.total + " update" + (root.total === 1 ? "" : "s")
+ " found, but " + which + " could not be checked"
: which + " could not be checked";
}
if (!root.everChecked)
return "Not checked yet";
if (root.total === 0)
@@ -225,22 +284,28 @@ Singleton {
readonly property bool loadingChangelog: changelogProcess.running
// Returns the record for an item, or null while one is being fetched.
// Starting the fetch is a side effect on purpose: the page asks for a
// changelog by rendering one, and there is nothing else to ask.
// Reading and requesting are two functions on purpose. The first version
// started the fetch as a side effect of being rendered, and mutating the
// queue from inside a binding evaluation is a binding loop: the queue
// write bumps a property the binding reads, which re-evaluates the
// binding, forty-odd times per page open. Bindings call changelogFor
// (pure); the expand action calls requestChangelog (imperative).
function changelogFor(source: string, name: string): var {
root.changelogRevision;
const key = source + "/" + name;
const known = root.changelogs[key];
if (known !== undefined)
return known;
const known = root.changelogs[source + "/" + name];
return known !== undefined ? known : null;
}
function requestChangelog(source: string, name: string): void {
if (!source || !name)
return null;
if (changelogProcess.key === key
return;
const key = source + "/" + name;
if (root.changelogs[key] !== undefined
|| changelogProcess.key === key
|| root.changelogQueue.some(entry => entry.source + "/" + entry.name === key))
return null;
return;
root.changelogQueue = root.changelogQueue.concat([{ source: source, name: name }]);
root.pumpChangelogs();
return null;
}
function pumpChangelogs(): void {
@@ -70,6 +70,17 @@ Singleton {
return real !== "" ? real : String(user?.userName ?? "");
}
// The home directory accountsservice reports for an account, which is not
// reliably "/home/" + userName: a home can be moved, or live on another
// mount entirely. Empty when the helper reported none -- and empty is the
// caller's cue to name the directory in words rather than to construct a
// path, because a guessed path inside the confirmation for an irreversible
// deletion is the app being most confident about the one thing it does not
// actually know.
function homeDirectory(user: var): string {
return String(user?.homeDirectory ?? "").trim();
}
function refresh(): void {
if (query.running)
return;