Tier 0: render what the services already decided, honestly
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user