Tier 0: render what the services already decided, honestly
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -12,7 +12,13 @@ import qs.services
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
visible: !Notifs.doNotDisturb && Notifs.popups.length > 0
|
||||
// Whether a notification is allowed to be a banner is decided once, in
|
||||
// Notifs.handleNotification: a focus mode's allow-list and the
|
||||
// critical-breakthrough switch are both exceptions to Do Not Disturb, and
|
||||
// anything they let past is already in `popups`. Re-testing doNotDisturb
|
||||
// here would override that three-way decision and leave an allowed app
|
||||
// chiming at an empty screen.
|
||||
visible: Notifs.popups.length > 0
|
||||
color: "transparent"
|
||||
|
||||
anchors.top: true
|
||||
|
||||
@@ -6,6 +6,7 @@ import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import Quickshell.Bluetooth
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -16,7 +17,15 @@ Item {
|
||||
property alias maxHeight: list.maxHeight
|
||||
|
||||
readonly property var adapter: Bluetooth.defaultAdapter
|
||||
property bool discoveryOwned: false
|
||||
|
||||
// Discovery is held, not switched. This picker and the settings page both
|
||||
// list the same adapter, and each writing adapter.discovering from its own
|
||||
// visibility flag meant the last one to change its mind decided for both --
|
||||
// closing the settings page stopped discovery under this panel, which then
|
||||
// said "Searching…" over a radio that had stopped. Connectivity counts the
|
||||
// holds, owns the BlueZ write, and never stops a scan it did not start;
|
||||
// nothing here touches the adapter.
|
||||
readonly property string scanHold: "quicksettings-bluetooth"
|
||||
|
||||
implicitHeight: list.implicitHeight
|
||||
|
||||
@@ -32,29 +41,14 @@ Item {
|
||||
return list;
|
||||
}
|
||||
|
||||
function syncDiscovery(): void {
|
||||
if (!root.adapter)
|
||||
return;
|
||||
const shouldDiscover = root.active && root.adapter.enabled;
|
||||
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.syncDiscovery()
|
||||
onAdapterChanged: {
|
||||
onActiveChanged: {
|
||||
if (root.active)
|
||||
root.syncDiscovery();
|
||||
Connectivity.acquireDiscovery(root.scanHold);
|
||||
else
|
||||
Connectivity.releaseDiscovery(root.scanHold);
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
if (root.adapter && root.discoveryOwned && root.adapter.discovering)
|
||||
root.adapter.discovering = false;
|
||||
}
|
||||
Component.onDestruction: Connectivity.releaseDiscovery(root.scanHold)
|
||||
|
||||
function stateText(device): string {
|
||||
if (device.pairing)
|
||||
|
||||
@@ -9,6 +9,7 @@ import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import Quickshell.Networking
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Item {
|
||||
@@ -45,18 +46,22 @@ Item {
|
||||
return list;
|
||||
}
|
||||
|
||||
function syncScanner(): void {
|
||||
if (root.device)
|
||||
root.device.scannerEnabled = root.active;
|
||||
// The scan is held, not switched. This picker and the settings page both
|
||||
// list the same radio, and each writing scannerEnabled from its own
|
||||
// visibility flag meant the last one to change its mind decided for both --
|
||||
// closing the settings page stopped the scan under this panel, which then
|
||||
// said "Scanning…" over a radio that had stopped. Connectivity counts the
|
||||
// holds; nothing here touches the device.
|
||||
readonly property string scanHold: "quicksettings-wifi"
|
||||
|
||||
onActiveChanged: {
|
||||
if (root.active)
|
||||
Connectivity.acquireWifiScan(root.scanHold);
|
||||
else
|
||||
Connectivity.releaseWifiScan(root.scanHold);
|
||||
}
|
||||
|
||||
onActiveChanged: root.syncScanner()
|
||||
onDeviceChanged: root.syncScanner()
|
||||
Component.onCompleted: root.syncScanner()
|
||||
Component.onDestruction: {
|
||||
if (root.device)
|
||||
root.device.scannerEnabled = false;
|
||||
}
|
||||
Component.onDestruction: Connectivity.releaseWifiScan(root.scanHold)
|
||||
|
||||
function signalIcon(strength: real): string {
|
||||
if (strength >= 0.8)
|
||||
|
||||
@@ -48,6 +48,48 @@ SettingsPage {
|
||||
property bool importOpen: false
|
||||
property string importPath: ""
|
||||
|
||||
// The proxy dropdown and its address, page-local until they add up to a
|
||||
// whole setting.
|
||||
//
|
||||
// A manual proxy is a host AND a port -- GNOME ignores a proxy whose host
|
||||
// is empty or whose port is 0 -- so engaging "manual" before an address
|
||||
// exists produces a mode that says it is proxying and is not. The dropdown
|
||||
// therefore leads the applied mode: picking Manual reveals the fields, and
|
||||
// the proxy is written on the edit that completes the pair, whichever of
|
||||
// the two that is. Committing on the first field instead would have to
|
||||
// guess at the other one; requiring the other to be stored first, which is
|
||||
// what this page used to do, meant neither could ever be first.
|
||||
//
|
||||
// The three start as bindings and stay bound until the first edit, which is
|
||||
// what carries the helper's opening read into a page that was built before
|
||||
// it answered. After that they are the user's, and nothing re-seeds them:
|
||||
// a reply landing mid-edit must not move the dropdown or empty the field
|
||||
// somebody is typing into.
|
||||
property string proxyChoice: NetworkTools.proxyMode
|
||||
property string draftProxyHost: NetworkTools.proxyHost
|
||||
property string draftProxyPort: NetworkTools.proxyPort
|
||||
|
||||
// "host", "port", "both" or "" -- which half of the pair is still missing.
|
||||
readonly property string proxyMissing: {
|
||||
const host = root.draftProxyHost.trim() === "";
|
||||
const port = root.draftProxyPort.trim() === "";
|
||||
if (host && port)
|
||||
return "both";
|
||||
if (host)
|
||||
return "host";
|
||||
if (port)
|
||||
return "port";
|
||||
return "";
|
||||
}
|
||||
|
||||
// Called after either field is edited. Writes only a whole address, so a
|
||||
// half-filled form leaves the proxy exactly as it was.
|
||||
function commitProxyManual(): void {
|
||||
if (root.proxyMissing !== "")
|
||||
return;
|
||||
NetworkTools.setProxyManual(root.draftProxyHost.trim(), root.draftProxyPort.trim());
|
||||
}
|
||||
|
||||
readonly property string wiredConnection:
|
||||
String(Connectivity.wiredDevice?.network?.name ?? "")
|
||||
|
||||
@@ -410,9 +452,18 @@ SettingsPage {
|
||||
OptionPickerRow {
|
||||
width: parent.width
|
||||
label: "Network proxy"
|
||||
detail: NetworkTools.proxyMode === "none"
|
||||
? "Applications that honour the system proxy use this. Not every application does."
|
||||
: "In use: " + NetworkTools.proxySummary
|
||||
detail: {
|
||||
if (root.proxyChoice === "manual" && NetworkTools.proxyMode !== "manual") {
|
||||
if (root.proxyMissing === "both")
|
||||
return "Not applied yet — fill in the host and the port below.";
|
||||
if (root.proxyMissing !== "")
|
||||
return "Not applied yet — the " + root.proxyMissing + " below is still empty.";
|
||||
return "Not applied yet.";
|
||||
}
|
||||
if (NetworkTools.proxyMode === "none")
|
||||
return "Applications that honour the system proxy use this. Not every application does.";
|
||||
return "In use: " + NetworkTools.proxySummary;
|
||||
}
|
||||
enabled: !NetworkTools.busy
|
||||
options: [
|
||||
{
|
||||
@@ -431,48 +482,60 @@ SettingsPage {
|
||||
detail: "A configuration URL decides, per address"
|
||||
}
|
||||
]
|
||||
current: NetworkTools.proxyMode
|
||||
onPicked: value => NetworkTools.setProxyMode(String(value))
|
||||
current: root.proxyChoice
|
||||
// Off and Automatic mean something the moment they are picked;
|
||||
// Manual does not, so it only opens the fields. See proxyChoice.
|
||||
onPicked: value => {
|
||||
root.proxyChoice = String(value);
|
||||
if (root.proxyChoice === "manual")
|
||||
root.commitProxyManual();
|
||||
else
|
||||
NetworkTools.setProxyMode(root.proxyChoice);
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: NetworkTools.proxyMode === "manual"
|
||||
visible: root.proxyChoice === "manual"
|
||||
|
||||
// Host and port are written together, because the proxy is only
|
||||
// usable as a pair -- so each field commits with whatever the other
|
||||
// one currently holds, and neither writes a half-configuration.
|
||||
// usable as a pair. Each field edits the page's draft; the pair is
|
||||
// written once both halves are there.
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Proxy host"
|
||||
detail: "The machine applications should go through"
|
||||
detail: root.proxyMissing === "host"
|
||||
? "Still empty — the proxy applies once this and the port are both set"
|
||||
: "The machine applications should go through"
|
||||
placeholder: "proxy.example.com"
|
||||
text: NetworkTools.proxyHost
|
||||
text: root.draftProxyHost
|
||||
enabled: !NetworkTools.busy
|
||||
onAccepted: value => {
|
||||
if (value.trim() !== "" && NetworkTools.proxyPort !== "")
|
||||
NetworkTools.setProxyManual(value.trim(), NetworkTools.proxyPort);
|
||||
root.draftProxyHost = value.trim();
|
||||
root.commitProxyManual();
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Port"
|
||||
detail: "The port that proxy listens on"
|
||||
detail: root.proxyMissing === "port"
|
||||
? "Still empty — the proxy applies once this and the host are both set"
|
||||
: "The port that proxy listens on"
|
||||
placeholder: "8080"
|
||||
text: NetworkTools.proxyPort
|
||||
text: root.draftProxyPort
|
||||
enabled: !NetworkTools.busy
|
||||
divider: false
|
||||
onAccepted: value => {
|
||||
if (value.trim() !== "" && NetworkTools.proxyHost !== "")
|
||||
NetworkTools.setProxyManual(NetworkTools.proxyHost, value.trim());
|
||||
root.draftProxyPort = value.trim();
|
||||
root.commitProxyManual();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
visible: NetworkTools.proxyMode === "auto"
|
||||
visible: root.proxyChoice === "auto"
|
||||
label: "Configuration URL"
|
||||
detail: "The .pac file whoever runs the network published"
|
||||
placeholder: "http://example.com/proxy.pac"
|
||||
|
||||
@@ -78,17 +78,74 @@ SettingsPage {
|
||||
// runs exactly as the hotkey runs it, and the words arrive here. The helper
|
||||
// is addressed through the path the Dictation service already publishes
|
||||
// rather than one spelled again in this file.
|
||||
|
||||
// True only once the helper has confirmed a recording actually started.
|
||||
//
|
||||
// It used to be set on the press, which made it a claim rather than a fact:
|
||||
// `panama-dictate start` refuses with {"ok":false,"error":"already-recording"}
|
||||
// when the hotkey is already holding the microphone, and the field said
|
||||
// "Listening…" over that refusal. Worse, the button then read "Stop and
|
||||
// type it" -- so the next press stopped the hotkey's recording and typed
|
||||
// somebody else's words into this test field.
|
||||
property bool listening: false
|
||||
|
||||
// Why the last press did not do what it said, in this page's words.
|
||||
property string testError: ""
|
||||
|
||||
// The helper answers with a code, not a sentence, so the words live here.
|
||||
// Anything unrecognised is shown as itself rather than swallowed.
|
||||
readonly property var dictateReasons: ({
|
||||
"already-recording": "Something is already listening — the dictation hotkey, most likely. Let that finish first.",
|
||||
"not-recording": "Nothing was listening, so there was nothing to type.",
|
||||
"not-downloaded": "The speech model has not been downloaded yet.",
|
||||
"no-image": "The speech server is not installed yet.",
|
||||
"server-unavailable": "The speech server did not answer.",
|
||||
"transcribe-failed": "That could not be transcribed.",
|
||||
"too-short": "That was too short to transcribe.",
|
||||
"no-speech": "Nothing was said.",
|
||||
"deliver-failed": "The words could not be typed into the field.",
|
||||
"unknown-command": "The dictation helper did not understand that."
|
||||
})
|
||||
|
||||
Process {
|
||||
id: dictateRun
|
||||
|
||||
// Which action this run was, so its reply can be read against it.
|
||||
property string action: ""
|
||||
|
||||
stdout: StdioCollector { onStreamFinished: root.absorbDictate(this.text) }
|
||||
onExited: (exitCode, exitStatus) => Dictation.refresh()
|
||||
}
|
||||
|
||||
// The helper's reply decides what happened. A start that was refused leaves
|
||||
// this page exactly as it was, with the refusal on screen.
|
||||
function absorbDictate(text: string): void {
|
||||
let reply = null;
|
||||
try {
|
||||
reply = JSON.parse(text);
|
||||
} catch (error) {
|
||||
reply = null;
|
||||
}
|
||||
const ok = reply?.ok === true;
|
||||
root.listening = dictateRun.action === "start" && ok;
|
||||
if (ok) {
|
||||
root.testError = "";
|
||||
return;
|
||||
}
|
||||
const code = String(reply?.error ?? "");
|
||||
if (code === "")
|
||||
root.testError = "The dictation helper did not answer.";
|
||||
else
|
||||
root.testError = root.dictateReasons[code] !== undefined
|
||||
? String(root.dictateReasons[code])
|
||||
: code;
|
||||
}
|
||||
|
||||
function runDictate(action: string): void {
|
||||
if (dictateRun.running)
|
||||
return;
|
||||
root.testError = "";
|
||||
dictateRun.action = action;
|
||||
dictateRun.command = [Dictation.helper, action];
|
||||
dictateRun.running = true;
|
||||
}
|
||||
@@ -181,7 +238,11 @@ SettingsPage {
|
||||
|
||||
SettingRow {
|
||||
label: "Try it"
|
||||
detail: "Speak a sentence and it is typed into the field here, rather than into whatever you were working on"
|
||||
// The refusal takes the detail's place while there is one, so a
|
||||
// press that did nothing says so where the press happened.
|
||||
detail: root.testError !== ""
|
||||
? root.testError
|
||||
: "Speak a sentence and it is typed into the field here, rather than into whatever you were working on"
|
||||
controlWidth: 340
|
||||
|
||||
Row {
|
||||
@@ -226,19 +287,27 @@ SettingsPage {
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.listening ? "Stop and type it" : "Test dictation"
|
||||
// Four states, because stopping is not instant: the helper
|
||||
// transcribes before it types, and a button still reading
|
||||
// "Stop and type it" through those seconds invites a second
|
||||
// press for a stop that has already been asked for.
|
||||
text: {
|
||||
if (dictateRun.running)
|
||||
return root.listening ? "Transcribing…" : "Starting…";
|
||||
return root.listening ? "Stop and type it" : "Test dictation";
|
||||
}
|
||||
tone: root.listening ? "accent" : "normal"
|
||||
enabled: !dictateRun.running
|
||||
onClicked: {
|
||||
// Focus first, and keep it: the helper types with wtype
|
||||
// into whatever holds keyboard focus when it finishes.
|
||||
heard.forceActiveFocus();
|
||||
if (root.listening) {
|
||||
root.listening = false;
|
||||
root.runDictate("stop");
|
||||
return;
|
||||
}
|
||||
heard.text = "";
|
||||
root.listening = true;
|
||||
// `listening` is set by the reply, not by this press.
|
||||
root.runDictate("start");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,25 @@ SettingsPage {
|
||||
// and the stored entry for vrrMode, which it does not report. Reading it
|
||||
// from the service rather than rebuilding it here is what stops the page
|
||||
// from showing one thing while an apply carries another.
|
||||
//
|
||||
// With one exception, and it is the whole reason this is not a one-liner.
|
||||
// While the Keep-or-revert banner is up, nothing has been stored yet --
|
||||
// confirm() is what writes -- so currentLayout() still answers vrrMode from
|
||||
// the PREVIOUS stored record. The variable-refresh picker therefore snapped
|
||||
// back to its old value the instant the change was applied, while the
|
||||
// banner beside it asked whether to keep a change the page had just stopped
|
||||
// showing. For the length of that window the requested layout is what the
|
||||
// pickers must render: it is what was asked for, and it is what keeping
|
||||
// will store.
|
||||
readonly property var record: {
|
||||
const name = root.monitor ? root.monitor.name : "";
|
||||
if (name === "" || Displays.monitors.length === 0)
|
||||
return null;
|
||||
if (Displays.awaitingConfirmation && Displays.pendingRequestedLayout) {
|
||||
const requested = Displays.pendingRequestedLayout.find(entry => entry.name === name);
|
||||
if (requested)
|
||||
return requested;
|
||||
}
|
||||
return Displays.currentLayout().find(entry => entry.name === name) ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -236,10 +236,11 @@ SettingsPage {
|
||||
text: "Close it"
|
||||
tone: "danger"
|
||||
enabled: !Firewall.busy
|
||||
// Every range in one call. The label says "the range",
|
||||
// and a range is a tcp rule and a udp rule.
|
||||
onClicked: {
|
||||
root.confirmingRange = false;
|
||||
for (const spec of Firewall.openRanges)
|
||||
Firewall.removePort(String(spec));
|
||||
Firewall.removePorts(Firewall.openRanges);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,41 +8,36 @@ SettingsCard {
|
||||
objectName: "health-summary"
|
||||
implicitHeight: 126
|
||||
|
||||
readonly property int observationCount: Health.summary.warnings + Health.summary.errors
|
||||
readonly property string heroTitle: {
|
||||
if (Health.diagnosticUnavailable)
|
||||
return "Health check unavailable";
|
||||
if (Health.checks.length === 0)
|
||||
return "Checking the desktop";
|
||||
if (Health.status === "error")
|
||||
return "Action required";
|
||||
if (Health.status === "warning")
|
||||
return "Needs attention";
|
||||
return "Healthy";
|
||||
}
|
||||
// The verdict itself is Health's, not this card's — see Health.headlineState
|
||||
// for why it can only be decided in one place. This card renders it and
|
||||
// adds the long-form detail underneath.
|
||||
readonly property int observationCount: Health.observationCount
|
||||
readonly property string heroTitle: Health.headline
|
||||
readonly property string heroDetail: {
|
||||
if (Health.diagnosticUnavailable)
|
||||
switch (Health.headlineState) {
|
||||
case "unavailable":
|
||||
return Health.lastError || "The latest health check could not be completed.";
|
||||
if (Health.checks.length === 0)
|
||||
case "checking":
|
||||
return "Checking the desktop services, tools, and integrations this app owns.";
|
||||
if (Health.status === "error")
|
||||
case "error":
|
||||
return root.observationCount === 1
|
||||
? "One part of the desktop needs action."
|
||||
: `${root.observationCount} parts of the desktop need action.`;
|
||||
if (Health.status === "warning")
|
||||
case "warning":
|
||||
return root.observationCount === 1
|
||||
? "Your desktop is working. One feature needs a decision."
|
||||
: `Your desktop is working. ${root.observationCount} features need a decision.`;
|
||||
return "Desktop services and tools are working normally.";
|
||||
default:
|
||||
return "Desktop services and tools are working normally.";
|
||||
}
|
||||
}
|
||||
readonly property color statusColor: {
|
||||
if (Health.diagnosticUnavailable || Health.status === "error")
|
||||
return Theme.danger;
|
||||
if (Health.status === "warning")
|
||||
return Theme.warn;
|
||||
if (Health.checks.length === 0)
|
||||
return Theme.fgMuted;
|
||||
return Theme.ok;
|
||||
switch (Health.tone) {
|
||||
case "danger": return Theme.danger;
|
||||
case "warn": return Theme.warn;
|
||||
case "muted": return Theme.fgMuted;
|
||||
default: return Theme.ok;
|
||||
}
|
||||
}
|
||||
|
||||
function scanTime(): string {
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
// PrivacyLiveTile {
|
||||
// glyph: "\u{F0100}"
|
||||
// label: "Camera"
|
||||
// measured: PrivacyState.initialized
|
||||
// active: PrivacyState.cameraActive
|
||||
// app: PrivacyState.cameraApp
|
||||
// }
|
||||
//
|
||||
// Three states, not two. `active` is false both when nothing is using the
|
||||
// camera and when nobody has looked -- PrivacyState's probe is pw-dump, which
|
||||
// is absent on a machine without PipeWire's tools and leaves every flag at its
|
||||
// default false -- so `measured` says which of the two "Idle" would have meant.
|
||||
//
|
||||
// Deliberately still. An indicator that pulses or fades is reporting the same
|
||||
// fact over and over at sixty frames a second, and this one sits on a settings
|
||||
// page that may be open for a long time -- the border and the warn-toned line
|
||||
@@ -22,14 +28,25 @@ Rectangle {
|
||||
property string glyph: ""
|
||||
property string label: ""
|
||||
property bool active: false
|
||||
// Whether anything has actually been read. False until the first probe
|
||||
// lands, and false forever if the probe cannot run at all.
|
||||
property bool measured: false
|
||||
// The application name PipeWire reported, which is often empty even while
|
||||
// a stream is plainly running.
|
||||
property string app: ""
|
||||
|
||||
// Not `state`: Item already owns that name.
|
||||
readonly property string useLine: root.active
|
||||
? "In use by " + (root.app !== "" ? root.app : "an application")
|
||||
: "Idle"
|
||||
readonly property string useLine: {
|
||||
if (root.active)
|
||||
return "In use by " + (root.app !== "" ? root.app : "an application");
|
||||
return root.measured ? "Idle" : "Not measured";
|
||||
}
|
||||
|
||||
// The neutral third state is neutral in the colour too: warn means in use,
|
||||
// dim means idle, and "not measured" must not read as either.
|
||||
readonly property color toneColor: root.active
|
||||
? Theme.warn
|
||||
: (root.measured ? Theme.fgDim : Theme.fgMuted)
|
||||
|
||||
implicitHeight: 56
|
||||
radius: Theme.cardRadius
|
||||
@@ -46,7 +63,7 @@ Rectangle {
|
||||
anchors.leftMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.glyph
|
||||
color: root.active ? Theme.warn : Theme.fgDim
|
||||
color: root.toneColor
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 17
|
||||
}
|
||||
@@ -72,7 +89,7 @@ Rectangle {
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.useLine
|
||||
color: root.active ? Theme.warn : Theme.fgDim
|
||||
color: root.toneColor
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
elide: Text.ElideRight
|
||||
|
||||
@@ -138,9 +138,11 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Camera, microphone & screen"
|
||||
subtitle: Permissions.available
|
||||
? "What is in use now, and the answers applications gave the desktop portal when they asked."
|
||||
: (Permissions.lastError || "The desktop portal's permission store is not running, so the grants below cannot be read.")
|
||||
subtitle: !Permissions.scanned
|
||||
? "Reading what is in use now, and what the desktop portal has recorded."
|
||||
: (Permissions.available
|
||||
? "What is in use now, and the answers applications gave the desktop portal when they asked."
|
||||
: (Permissions.lastError || "The desktop portal's permission store is not running, so the grants below cannot be read."))
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
@@ -151,6 +153,9 @@ SettingsPage {
|
||||
width: (parent.width - 20) / 3
|
||||
glyph: "\u{F0100}"
|
||||
label: "Camera"
|
||||
// False until pw-dump has answered once, and false forever if
|
||||
// it cannot run: an unmeasured tile must not read as "Idle".
|
||||
measured: PrivacyState.initialized
|
||||
active: PrivacyState.cameraActive
|
||||
app: PrivacyState.cameraApp
|
||||
}
|
||||
@@ -159,6 +164,7 @@ SettingsPage {
|
||||
width: (parent.width - 20) / 3
|
||||
glyph: "\u{F036C}"
|
||||
label: "Microphone"
|
||||
measured: PrivacyState.initialized
|
||||
active: PrivacyState.microphoneActive
|
||||
app: PrivacyState.microphoneApp
|
||||
}
|
||||
@@ -167,6 +173,7 @@ SettingsPage {
|
||||
width: (parent.width - 20) / 3
|
||||
glyph: "\u{F0379}"
|
||||
label: "Screen sharing"
|
||||
measured: PrivacyState.initialized
|
||||
active: PrivacyState.screenSharingActive
|
||||
app: PrivacyState.screenSharingApp
|
||||
}
|
||||
@@ -200,11 +207,22 @@ SettingsPage {
|
||||
count: root.askedCount(section.rows.length)
|
||||
}
|
||||
|
||||
// An empty table means "nothing has asked" only once the store
|
||||
// has been read and answered. Before that, and when the
|
||||
// permission store is not running at all, every table is empty
|
||||
// for the same reason -- and "no app has asked for the camera"
|
||||
// is then a measurement nobody took.
|
||||
TextRow {
|
||||
width: section.width
|
||||
visible: section.rows.length === 0
|
||||
label: "Nothing has asked yet"
|
||||
detail: String(section.modelData.empty)
|
||||
label: !Permissions.scanned
|
||||
? "Not read yet"
|
||||
: (Permissions.available ? "Nothing has asked yet" : "Not measured")
|
||||
detail: !Permissions.scanned
|
||||
? "Reading what the desktop portal has recorded."
|
||||
: (Permissions.available
|
||||
? String(section.modelData.empty)
|
||||
: "The desktop portal's permission store is not answering, so what has asked for this cannot be read.")
|
||||
divider: false
|
||||
}
|
||||
|
||||
@@ -714,10 +732,21 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Device security"
|
||||
subtitle: DeviceSecurity.attentionCount === 0
|
||||
? "Everything below is in its recommended state."
|
||||
: DeviceSecurity.attentionCount + " item"
|
||||
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention."
|
||||
// Three answers, not two. "Everything is in its recommended state" is
|
||||
// computed from an empty fact list exactly as happily as from a clean
|
||||
// one, so a helper that failed to run would be reported as a machine
|
||||
// with nothing wrong with it. No facts means nothing was checked.
|
||||
subtitle: {
|
||||
if (!DeviceSecurity.scanned)
|
||||
return "Reading the firmware, the kernel and the filesystem…";
|
||||
if (DeviceSecurity.facts.length === 0)
|
||||
return "Could not check — " + (DeviceSecurity.lastError
|
||||
|| "the security helper did not answer.");
|
||||
if (DeviceSecurity.attentionCount === 0)
|
||||
return "Everything below is in its recommended state.";
|
||||
return DeviceSecurity.attentionCount + " item"
|
||||
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention.";
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: DeviceSecurity.facts
|
||||
|
||||
@@ -284,26 +284,21 @@ Rectangle {
|
||||
border.width: activeFocus ? 2 : 0
|
||||
border.color: Theme.accent
|
||||
|
||||
// Health owns the verdict; this footer only renders it. It used to
|
||||
// decide for itself, in the opposite order to the Health page's hero,
|
||||
// and the two then disagreed out loud whenever a health check failed
|
||||
// after a successful one -- see Health.headlineState.
|
||||
function footerText(): string {
|
||||
if (Health.checks.length === 0)
|
||||
return Health.diagnosticUnavailable ? "Health check unavailable" : "Checking the desktop";
|
||||
if (Health.status === "error")
|
||||
return "Desktop needs attention";
|
||||
if (Health.status === "warning") {
|
||||
const count = Health.summary.warnings + Health.summary.errors;
|
||||
return count + (count === 1 ? " health observation" : " health observations");
|
||||
}
|
||||
return "Desktop is healthy";
|
||||
return Health.headline;
|
||||
}
|
||||
|
||||
function footerColor(): color {
|
||||
if (Health.checks.length === 0)
|
||||
return Health.diagnosticUnavailable ? Theme.danger : Theme.fgMuted;
|
||||
if (Health.status === "error")
|
||||
return Theme.danger;
|
||||
if (Health.status === "warning")
|
||||
return Theme.warn;
|
||||
return Theme.ok;
|
||||
switch (Health.tone) {
|
||||
case "danger": return Theme.danger;
|
||||
case "warn": return Theme.warn;
|
||||
case "muted": return Theme.fgMuted;
|
||||
default: return Theme.ok;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
|
||||
@@ -88,4 +88,18 @@ Flow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A chip that lights and stays silent is the point of this strip; a chip
|
||||
// that lights and stays silent BECAUSE pw-play is not installed is not, and
|
||||
// the two are indistinguishable without this line. Full width, so the Flow
|
||||
// gives it a row of its own beneath the chips.
|
||||
Text {
|
||||
width: root.width
|
||||
visible: SoundTest.lastError !== ""
|
||||
text: SoundTest.lastError
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,12 @@ SettingsPage {
|
||||
// level meter above says something is arriving, not that it is you.
|
||||
ActionRow {
|
||||
label: "Test your microphone"
|
||||
detail: "Record a few seconds and play it back through the selected output"
|
||||
// A test that fails does it by being silent, which is the same
|
||||
// thing a broken microphone does. The reason takes the detail's
|
||||
// place so the two can be told apart.
|
||||
detail: SoundTest.lastError !== ""
|
||||
? SoundTest.lastError
|
||||
: "Record a few seconds and play it back through the selected output"
|
||||
divider: captureRow.visible
|
||||
action: {
|
||||
if (SoundTest.micTestState === "recording")
|
||||
|
||||
@@ -343,12 +343,23 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Reset"
|
||||
subtitle: "Restores the appearance and theme, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
|
||||
// Every claim here is checked against PreferenceSchema.defaults(), since
|
||||
// a reset is exactly the schema store going back to it. The dock's
|
||||
// pinned applications ARE a schema key (dockPinned), so the shipped
|
||||
// sixteen come back — this sentence used to promise the opposite. Saved
|
||||
// themes (themeProfiles) are a schema key too, and go with them. Files
|
||||
// and Bluetooth pairings are not in the schema and not in the Home
|
||||
// store, and backups live in their own state directory, so all three
|
||||
// genuinely survive.
|
||||
subtitle: "Restores the appearance and theme, the dock and the applications pinned to it, the clock, focus, and display policy, removes any themes you saved, and clears your Home accessory arrangement. Your files, paired devices, and settings backups are not changed."
|
||||
|
||||
SettingRow {
|
||||
id: resetRow
|
||||
|
||||
readonly property bool armed: root.confirming === "reset"
|
||||
// Stays armed while the safety backup is still being written, so
|
||||
// the confirmation does not collapse into a page that looks
|
||||
// untouched during the one window where nothing has happened yet.
|
||||
readonly property bool armed: root.confirming === "reset" || SystemSettings.resetPending
|
||||
|
||||
label: "Restore defaults"
|
||||
detail: resetRow.armed
|
||||
@@ -364,13 +375,18 @@ SettingsPage {
|
||||
|
||||
SettingsButton {
|
||||
text: resetRow.armed ? "Cancel" : "Reset…"
|
||||
enabled: !SystemSettings.resetPending
|
||||
onClicked: root.arm("reset")
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: resetRow.armed
|
||||
text: "Restore defaults"
|
||||
// Nothing is wiped until the backup lands, so the button
|
||||
// stays occupied for as long as that takes rather than
|
||||
// looking like it did nothing.
|
||||
text: SystemSettings.resetPending ? "Backing up…" : "Restore defaults"
|
||||
tone: "danger"
|
||||
enabled: !SystemSettings.resetPending && !SettingsBackup.busy
|
||||
onClicked: {
|
||||
root.confirming = "";
|
||||
SystemSettings.restoreDefaults();
|
||||
|
||||
@@ -54,9 +54,16 @@ SettingsPage {
|
||||
readonly property string headline: {
|
||||
if (Updates.checking)
|
||||
return "Checking every source…";
|
||||
// "nothing security-critical" is a measurement, not a default. When the
|
||||
// advisory query did not answer, the page says so instead of saying the
|
||||
// reassuring thing it would otherwise have said.
|
||||
const security = Updates.securityCount > 0
|
||||
? Updates.securityCount + " carrying a security advisory"
|
||||
: (Updates.total > 0 ? "nothing security-critical" : "");
|
||||
: (Updates.total > 0
|
||||
? (Updates.securityKnown
|
||||
? "nothing security-critical"
|
||||
: "security advisories could not be read")
|
||||
: "");
|
||||
return root.joined([
|
||||
Updates.summary(), security,
|
||||
Updates.lastCheckedText().replace("Checked", "checked")
|
||||
@@ -72,12 +79,17 @@ SettingsPage {
|
||||
|
||||
function toggleChangelog(source: string, name: string): void {
|
||||
const key = source + ":" + name;
|
||||
root.expandedPackage = root.expandedPackage === key ? "" : key;
|
||||
const opening = root.expandedPackage !== key;
|
||||
root.expandedPackage = opening ? key : "";
|
||||
// The fetch starts here, on the press, not as a side effect of the
|
||||
// text rendering -- see Updates.changelogFor's comment for the
|
||||
// binding loop that taught us the difference.
|
||||
if (opening)
|
||||
Updates.requestChangelog(source, name);
|
||||
}
|
||||
|
||||
// Asking for a changelog is what starts fetching one -- the page requests
|
||||
// it by rendering it, and Updates keeps the answer for the life of the
|
||||
// shell. Null means the fetch is still out.
|
||||
// Pure read: Updates keeps the answer for the life of the shell, and
|
||||
// null means the fetch is still out.
|
||||
function changelogText(source: string, name: string): string {
|
||||
const record = Updates.changelogFor(source, name);
|
||||
if (record === null || record === undefined)
|
||||
@@ -140,7 +152,12 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "System packages"
|
||||
subtitle: Updates.dnf?.available === false
|
||||
// The failed case comes before the empty one: dnf reporting nothing and
|
||||
// dnf being unable to report are the same empty list, and only one of
|
||||
// them means there is nothing waiting.
|
||||
subtitle: Updates.sourceError("dnf") !== ""
|
||||
? Updates.sourceError("dnf")
|
||||
: Updates.dnf?.available === false
|
||||
? "dnf is not available on this machine."
|
||||
: (root.packages.length === 0
|
||||
? "Nothing waiting."
|
||||
@@ -269,15 +286,25 @@ SettingsPage {
|
||||
SettingsCard {
|
||||
title: "Applications & firmware"
|
||||
|
||||
// A check that failed is not a machine that is current. "Current" is
|
||||
// only ever said about a list that was actually read.
|
||||
TextRow {
|
||||
visible: Updates.flatpak?.available === false
|
||||
visible: Updates.sourceError("flatpak") !== ""
|
||||
label: "Flatpak"
|
||||
detail: Updates.sourceError("flatpak")
|
||||
value: "Not checked"
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Updates.sourceError("flatpak") === "" && Updates.flatpak?.available === false
|
||||
label: "Flatpak"
|
||||
detail: "Flatpak is not installed."
|
||||
value: "Unavailable"
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Updates.flatpak?.available !== false && root.applications.length === 0
|
||||
visible: Updates.sourceError("flatpak") === ""
|
||||
&& Updates.flatpak?.available !== false && root.applications.length === 0
|
||||
label: "Flatpak"
|
||||
detail: "Everything current — each application gets its own row when an update appears"
|
||||
value: "Current"
|
||||
@@ -350,7 +377,15 @@ SettingsPage {
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Updates.firmware?.available === false
|
||||
visible: Updates.sourceError("firmware") !== ""
|
||||
label: "Firmware"
|
||||
detail: Updates.sourceError("firmware")
|
||||
value: "Not checked"
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Updates.sourceError("firmware") === "" && Updates.firmware?.available === false
|
||||
label: "Firmware"
|
||||
detail: "Firmware updating is not available on this machine."
|
||||
value: "Unavailable"
|
||||
@@ -358,7 +393,8 @@ SettingsPage {
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Updates.firmware?.available !== false && root.firmwareDevices.length === 0
|
||||
visible: Updates.sourceError("firmware") === ""
|
||||
&& Updates.firmware?.available !== false && root.firmwareDevices.length === 0
|
||||
label: "Firmware"
|
||||
detail: "No firmware updates are offered for this hardware"
|
||||
value: "Current"
|
||||
|
||||
@@ -637,6 +637,21 @@ SettingsPage {
|
||||
readonly property bool lastAdministrator:
|
||||
otherBlock.modelData.administrator && UserAccounts.administratorCount <= 1
|
||||
|
||||
// The real path, from accountsservice, rather than the home root
|
||||
// joined to the account name. Every sentence below used to
|
||||
// construct that guess, the armed confirmation for deleting the
|
||||
// files among them — so a home that had been moved was named
|
||||
// confidently and wrongly exactly where being wrong costs the
|
||||
// most. When no path is reported, the sentences name the
|
||||
// directory in words rather than inventing one.
|
||||
readonly property string homePath:
|
||||
UserAccounts.homeDirectory(otherBlock.modelData)
|
||||
readonly property bool homeKnown: otherBlock.homePath !== ""
|
||||
readonly property string homePhrase: otherBlock.homeKnown
|
||||
? otherBlock.homePath : "their home directory"
|
||||
readonly property string homeSubject: otherBlock.homeKnown
|
||||
? otherBlock.homePath : "Their home directory"
|
||||
|
||||
SettingRow {
|
||||
width: otherBlock.width
|
||||
label: UserAccounts.displayName(otherBlock.modelData)
|
||||
@@ -706,12 +721,12 @@ SettingsPage {
|
||||
OptionPickerRow {
|
||||
width: otherBlock.width - 14
|
||||
label: "Their files"
|
||||
detail: "What deleting the account does to /home/" + otherBlock.userName
|
||||
detail: "What deleting the account does to " + otherBlock.homePhrase
|
||||
options: [
|
||||
{
|
||||
value: "keep",
|
||||
label: "Keep the files",
|
||||
detail: "/home/" + otherBlock.userName
|
||||
detail: otherBlock.homeSubject
|
||||
+ " stays exactly where it is, and you can hand it to someone later"
|
||||
},
|
||||
{
|
||||
@@ -734,9 +749,9 @@ SettingsPage {
|
||||
if (!otherBlock.confirming)
|
||||
return "The account stops existing. What happens to their files is the choice above.";
|
||||
if (root.removalDisposition === "remove")
|
||||
return "This cannot be undone — /home/" + otherBlock.userName
|
||||
return "This cannot be undone — " + otherBlock.homePhrase
|
||||
+ " and everything in it is deleted along with the account.";
|
||||
return "Their files stay in /home/" + otherBlock.userName
|
||||
return "Their files stay in " + otherBlock.homePhrase
|
||||
+ ", so only the account itself goes.";
|
||||
}
|
||||
controlWidth: 260
|
||||
|
||||
@@ -15,7 +15,7 @@ Changes go through firewall-cmd, which is polkit-aware, so they prompt.
|
||||
panama-firewall snapshot
|
||||
panama-firewall zone-info ZONE
|
||||
panama-firewall add-service NAME | remove-service NAME
|
||||
panama-firewall add-port PORT/PROTO | remove-port PORT/PROTO
|
||||
panama-firewall add-port PORT/PROTO... | remove-port PORT/PROTO...
|
||||
panama-firewall set-zone INTERFACE ZONE
|
||||
panama-firewall set-default-zone ZONE
|
||||
"""
|
||||
@@ -401,10 +401,17 @@ def main(arguments: list[str]) -> int:
|
||||
name = require(SERVICE, arguments[1], "That is not a service name.")
|
||||
verb = "--add-service" if arguments[0] == "add-service" else "--remove-service"
|
||||
change(active_zone(), f"{verb}={name}")
|
||||
elif len(arguments) == 2 and arguments[0] in ("add-port", "remove-port"):
|
||||
spec = require(PORT_SPEC, arguments[1], "That is not a port.")
|
||||
elif len(arguments) >= 2 and arguments[0] in ("add-port", "remove-port"):
|
||||
# Several specs in one invocation, because one rule as a user sees
|
||||
# it is often two as firewalld stores it: the range Fedora opens is
|
||||
# a tcp range AND a udp range, and "close the range" that closed
|
||||
# only the tcp half would be a lie the page had already told.
|
||||
# Validated all-or-nothing first, so a bad spec at the end cannot
|
||||
# leave the firewall half-changed.
|
||||
specs = [require(PORT_SPEC, argument, "That is not a port.")
|
||||
for argument in arguments[1:]]
|
||||
verb = "--add-port" if arguments[0] == "add-port" else "--remove-port"
|
||||
change(active_zone(), f"{verb}={spec}")
|
||||
change(active_zone(), *[f"{verb}={spec}" for spec in specs])
|
||||
elif len(arguments) == 3 and arguments[0] == "set-zone":
|
||||
interface = require(INTERFACE, arguments[1], "That is not a network interface.")
|
||||
zone = require(ZONE, arguments[2], "That is not a zone.")
|
||||
@@ -416,7 +423,7 @@ def main(arguments: list[str]) -> int:
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-firewall snapshot | zone-info ZONE | add-service NAME | "
|
||||
"remove-service NAME | add-port PORT/PROTO | remove-port PORT/PROTO | "
|
||||
"remove-service NAME | add-port PORT/PROTO... | remove-port PORT/PROTO... | "
|
||||
"set-zone INTERFACE ZONE | set-default-zone ZONE")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
|
||||
@@ -143,32 +143,56 @@ def dnf_download_sizes() -> dict[str, int]:
|
||||
|
||||
|
||||
def dnf_updates() -> dict:
|
||||
"""Pending packages, or the reason there is no count.
|
||||
|
||||
A failure used to be indistinguishable from an empty list: any exit code
|
||||
outside 0/100 left `packages` empty and reported nothing, so a dnf that
|
||||
could not reach a repository produced a confident "Up to date". Not knowing
|
||||
and knowing there is nothing are different answers and are reported as
|
||||
different answers.
|
||||
"""
|
||||
if not shutil.which("dnf5"):
|
||||
return {"available": False, "count": 0, "packages": [], "securityCount": 0}
|
||||
return {"available": False, "count": 0, "packages": [], "securityCount": 0,
|
||||
"securityKnown": True, "error": ""}
|
||||
|
||||
result = run(["dnf5", "check-upgrade", "--json"], timeout=180)
|
||||
packages = []
|
||||
# dnf5 exits 100 when upgrades exist, 0 when none do. Both are success.
|
||||
if result.returncode in (0, 100):
|
||||
try:
|
||||
payload = json.loads(result.stdout or "{}")
|
||||
for entry in payload.get("upgrades", []):
|
||||
packages.append({
|
||||
"name": str(entry.get("name", "")),
|
||||
"version": str(entry.get("evr", "")),
|
||||
"repository": str(entry.get("repository", "")),
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# dnf5 exits 100 when upgrades exist, 0 when none do. Both are success;
|
||||
# anything else is dnf saying it could not answer, most often a repository
|
||||
# it could not reach.
|
||||
if result.returncode not in (0, 100):
|
||||
return {"available": True, "count": 0, "packages": [], "securityCount": 0,
|
||||
"securityKnown": False,
|
||||
"error": _refusal(result, "The package list could not be read.")}
|
||||
try:
|
||||
payload = json.loads(result.stdout or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return {"available": True, "count": 0, "packages": [], "securityCount": 0,
|
||||
"securityKnown": False,
|
||||
"error": "dnf answered with something that was not a package list."}
|
||||
|
||||
packages = []
|
||||
for entry in payload.get("upgrades", []):
|
||||
packages.append({
|
||||
"name": str(entry.get("name", "")),
|
||||
"version": str(entry.get("evr", "")),
|
||||
"repository": str(entry.get("repository", "")),
|
||||
})
|
||||
|
||||
# A separate question with a separate failure. This count only ever shrinks
|
||||
# the alarm -- the page says "nothing security-critical" when it is zero --
|
||||
# so an advisory query that failed and returned zero would be reassurance
|
||||
# nobody measured. Whether it is known is reported alongside it.
|
||||
security = 0
|
||||
security_known = True
|
||||
advisory = run(["dnf5", "check-upgrade",
|
||||
f"--advisory-severities={SECURITY_SEVERITIES}", "--json"], timeout=180)
|
||||
if advisory.returncode in (0, 100):
|
||||
try:
|
||||
security = len(json.loads(advisory.stdout or "{}").get("upgrades", []))
|
||||
except json.JSONDecodeError:
|
||||
security = 0
|
||||
security_known = False
|
||||
else:
|
||||
security_known = False
|
||||
|
||||
sizes = dnf_download_sizes() if packages else {}
|
||||
for package in packages:
|
||||
@@ -177,7 +201,7 @@ def dnf_updates() -> dict:
|
||||
|
||||
packages.sort(key=lambda item: item["name"])
|
||||
source = {"available": True, "count": len(packages), "packages": packages,
|
||||
"securityCount": security}
|
||||
"securityCount": security, "securityKnown": security_known, "error": ""}
|
||||
# Only when every pending package was priced. A partial total reads as the
|
||||
# whole download and would understate it, which is the direction that
|
||||
# surprises somebody on a metered connection.
|
||||
@@ -202,23 +226,28 @@ def human_bytes(text: str) -> int:
|
||||
|
||||
def flatpak_updates() -> dict:
|
||||
if not shutil.which("flatpak"):
|
||||
return {"available": False, "count": 0, "applications": []}
|
||||
return {"available": False, "count": 0, "applications": [], "error": ""}
|
||||
result = run(["flatpak", "remote-ls", "--updates",
|
||||
"--columns=application,version,origin,download-size"], timeout=120)
|
||||
# An unreachable remote exits non-zero and prints nothing usable. Reading
|
||||
# that as an empty list would report every application as current.
|
||||
if result.returncode != 0:
|
||||
return {"available": True, "count": 0, "applications": [],
|
||||
"error": _refusal(result, "The application list could not be read.")}
|
||||
applications = []
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.splitlines():
|
||||
parts = [part.strip() for part in line.split("\t")]
|
||||
if not parts or not parts[0]:
|
||||
continue
|
||||
application = {"id": parts[0],
|
||||
"version": parts[1] if len(parts) > 1 else "",
|
||||
"origin": parts[2] if len(parts) > 2 else ""}
|
||||
size = human_bytes(parts[3]) if len(parts) > 3 else 0
|
||||
if size:
|
||||
application["bytes"] = size
|
||||
applications.append(application)
|
||||
source = {"available": True, "count": len(applications), "applications": applications}
|
||||
for line in result.stdout.splitlines():
|
||||
parts = [part.strip() for part in line.split("\t")]
|
||||
if not parts or not parts[0]:
|
||||
continue
|
||||
application = {"id": parts[0],
|
||||
"version": parts[1] if len(parts) > 1 else "",
|
||||
"origin": parts[2] if len(parts) > 2 else ""}
|
||||
size = human_bytes(parts[3]) if len(parts) > 3 else 0
|
||||
if size:
|
||||
application["bytes"] = size
|
||||
applications.append(application)
|
||||
source = {"available": True, "count": len(applications),
|
||||
"applications": applications, "error": ""}
|
||||
if applications and all("bytes" in application for application in applications):
|
||||
source["downloadBytes"] = sum(application["bytes"] for application in applications)
|
||||
return source
|
||||
@@ -240,32 +269,54 @@ def strip_markup(text: str) -> str:
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
||||
|
||||
# fwupd reports "nothing to do" through the same Error channel it uses for
|
||||
# failures. These are not failures, and calling them one would put a red row on
|
||||
# a machine whose firmware is simply current.
|
||||
FWUPD_NOTHING_PENDING = ("no updates available", "no updatable devices",
|
||||
"no supported devices")
|
||||
|
||||
|
||||
def firmware_updates() -> dict:
|
||||
if not shutil.which("fwupdmgr"):
|
||||
return {"available": False, "count": 0, "devices": []}
|
||||
return {"available": False, "count": 0, "devices": [], "error": ""}
|
||||
result = run(["fwupdmgr", "get-updates", "--json"], timeout=120)
|
||||
devices = []
|
||||
# The exit code is not the signal here: `fwupdmgr --json` exits 0 even when
|
||||
# it failed and reports the failure inside the payload instead (verified
|
||||
# against fwupd on this machine). An answer is a payload carrying Devices or
|
||||
# Error; anything else -- including the empty output a crashed fwupdmgr
|
||||
# leaves -- is not an answer. This used to be `result.stdout or "{}"`, which
|
||||
# turned "printed nothing" into "no firmware updates".
|
||||
try:
|
||||
payload = json.loads(result.stdout or "{}")
|
||||
for device in payload.get("Devices", []):
|
||||
releases = device.get("Releases", [])
|
||||
entry = {
|
||||
"name": str(device.get("Name", "Unknown device")),
|
||||
"version": str(device.get("Version", "")),
|
||||
"target": str(releases[0].get("Version", "")) if releases else "",
|
||||
# Firmware that needs a reboot to flash is worth saying up front.
|
||||
"needsReboot": "needs-reboot" in json.dumps(device.get("Flags", [])),
|
||||
}
|
||||
# fwupd already has the vendor's release notes in hand, so they are
|
||||
# kept here rather than re-fetched: the changelog for firmware costs
|
||||
# nothing beyond the scan that found the update.
|
||||
notes = strip_markup(str(releases[0].get("Description", ""))) if releases else ""
|
||||
if notes:
|
||||
entry["changelog"] = notes[:CHANGELOG_LIMIT]
|
||||
devices.append(entry)
|
||||
payload = json.loads(result.stdout or "")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"available": True, "count": len(devices), "devices": devices}
|
||||
payload = None
|
||||
if not isinstance(payload, dict) or ("Devices" not in payload and "Error" not in payload):
|
||||
return {"available": True, "count": 0, "devices": [],
|
||||
"error": _refusal(result, "The firmware list could not be read.")}
|
||||
failure = payload.get("Error")
|
||||
if isinstance(failure, dict):
|
||||
message = str(failure.get("Message", "")).strip()
|
||||
if message and message.lower() not in FWUPD_NOTHING_PENDING:
|
||||
return {"available": True, "count": 0, "devices": [], "error": message[:200]}
|
||||
|
||||
devices = []
|
||||
for device in payload.get("Devices", []):
|
||||
releases = device.get("Releases", [])
|
||||
entry = {
|
||||
"name": str(device.get("Name", "Unknown device")),
|
||||
"version": str(device.get("Version", "")),
|
||||
"target": str(releases[0].get("Version", "")) if releases else "",
|
||||
# Firmware that needs a reboot to flash is worth saying up front.
|
||||
"needsReboot": "needs-reboot" in json.dumps(device.get("Flags", [])),
|
||||
}
|
||||
# fwupd already has the vendor's release notes in hand, so they are
|
||||
# kept here rather than re-fetched: the changelog for firmware costs
|
||||
# nothing beyond the scan that found the update.
|
||||
notes = strip_markup(str(releases[0].get("Description", ""))) if releases else ""
|
||||
if notes:
|
||||
entry["changelog"] = notes[:CHANGELOG_LIMIT]
|
||||
devices.append(entry)
|
||||
return {"available": True, "count": len(devices), "devices": devices, "error": ""}
|
||||
|
||||
|
||||
def automatic_state() -> dict:
|
||||
@@ -282,26 +333,75 @@ def automatic_state() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def probe_source(probe, empty: dict) -> dict:
|
||||
"""One source's answer, or its own failure -- never the other two's.
|
||||
|
||||
The three fail independently, which is the whole reason they are counted
|
||||
separately. A dnf that times out raises out of `run`, and left unhandled it
|
||||
would abort the entire check and blank the firmware and application lists
|
||||
along with it.
|
||||
"""
|
||||
try:
|
||||
return probe()
|
||||
except BoundaryError as error:
|
||||
return {**empty, "available": True, "count": 0, "error": str(error)}
|
||||
|
||||
|
||||
# What each source looks like with nothing in it. Shared by the failure path and
|
||||
# by a cache written before a field existed, so both produce the same shape.
|
||||
EMPTY_SOURCES = {
|
||||
"dnf": {"packages": [], "securityCount": 0, "securityKnown": False},
|
||||
"flatpak": {"applications": []},
|
||||
"firmware": {"devices": []},
|
||||
}
|
||||
|
||||
|
||||
def check() -> dict:
|
||||
previous = read_cache()
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"dnf": dnf_updates(),
|
||||
"flatpak": flatpak_updates(),
|
||||
"firmware": firmware_updates(),
|
||||
"checkedAt": int(time.time()),
|
||||
"dnf": probe_source(dnf_updates, EMPTY_SOURCES["dnf"]),
|
||||
"flatpak": probe_source(flatpak_updates, EMPTY_SOURCES["flatpak"]),
|
||||
"firmware": probe_source(firmware_updates, EMPTY_SOURCES["firmware"]),
|
||||
}
|
||||
for name, source in payload.items():
|
||||
# A source stamps its own clock only when it answered. A check that
|
||||
# failed must not make the count beside it look freshly verified --
|
||||
# that is exactly how a failed check came to read as "Up to date".
|
||||
source["checkedAt"] = (
|
||||
int(previous.get(name, {}).get("checkedAt", 0)) if source.get("error") else now
|
||||
)
|
||||
failed = any(source.get("error") for source in payload.values())
|
||||
# The overall stamp is the last time every source answered. Carried forward
|
||||
# rather than refreshed on a partial failure, so "Checked 2 minutes ago"
|
||||
# never describes a check that did not complete.
|
||||
payload["checkedAt"] = int(previous.get("checkedAt", 0)) if failed else now
|
||||
write_cache(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def cached_source(cached: dict, name: str) -> dict:
|
||||
"""A stored source, filled out to the shape this version expects.
|
||||
|
||||
A cache written before per-source errors and stamps existed carries neither
|
||||
field; defaulting them here means an old cache reads as "nothing recorded"
|
||||
instead of missing a key at the surface.
|
||||
"""
|
||||
empty = {"available": True, "count": 0, "error": "", "checkedAt": 0,
|
||||
**EMPTY_SOURCES[name]}
|
||||
source = cached.get(name)
|
||||
return {**empty, **source} if isinstance(source, dict) else empty
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
cached = read_cache()
|
||||
empty = {"available": True, "count": 0}
|
||||
return {
|
||||
"dnf": cached.get("dnf", {**empty, "packages": [], "securityCount": 0}),
|
||||
"flatpak": cached.get("flatpak", {**empty, "applications": []}),
|
||||
"firmware": cached.get("firmware", {**empty, "devices": []}),
|
||||
"dnf": cached_source(cached, "dnf"),
|
||||
"flatpak": cached_source(cached, "flatpak"),
|
||||
"firmware": cached_source(cached, "firmware"),
|
||||
# 0 means never checked, which the page says rather than showing a
|
||||
# confident "0 updates" it has no basis for.
|
||||
# confident "0 updates" it has no basis for. It only ever holds the time
|
||||
# of a check where every source answered.
|
||||
"checkedAt": int(cached.get("checkedAt", 0)),
|
||||
"kernel": kernel_state(),
|
||||
"automatic": automatic_state(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,6 +12,11 @@ ShellRoot {
|
||||
property var appliedBatches: []
|
||||
property bool displayBlocked: false
|
||||
|
||||
// The held-open safety snapshot: `pendingSnapshotDone` is the completion
|
||||
// callback restoreDefaults handed over, waiting for the contract to answer.
|
||||
property var pendingSnapshotDone: null
|
||||
property bool snapshotRefusesToStart: false
|
||||
|
||||
function recordReset(name: string): void {
|
||||
const next = root.resetCalls.slice();
|
||||
next.push(name);
|
||||
@@ -43,10 +48,16 @@ ShellRoot {
|
||||
root.recordReset("display.block:" + blocked);
|
||||
root.displayBlocked = blocked;
|
||||
};
|
||||
// Recorded so the contract can prove the snapshot happens BEFORE the
|
||||
// stores are cleared, not merely that it happens.
|
||||
SystemSettings.takeSafetySnapshot = function() {
|
||||
root.recordReset("snapshot");
|
||||
// The real snapshot is another process reading settings.json, so the
|
||||
// stub is held open rather than answering inline: the contract drives
|
||||
// its completion by hand and can therefore prove that nothing is wiped
|
||||
// in the window between "snapshot started" and "snapshot finished",
|
||||
// and that a failed snapshot leaves the stores alone.
|
||||
SystemSettings.takeSafetySnapshot = function(done) {
|
||||
root.recordReset("snapshot.start");
|
||||
if (root.snapshotRefusesToStart)
|
||||
return false;
|
||||
root.pendingSnapshotDone = done;
|
||||
return true;
|
||||
};
|
||||
SystemSettings.reloadKeybinds = function() { root.recordReset("keybinds.reload"); };
|
||||
@@ -107,6 +118,33 @@ ShellRoot {
|
||||
return SystemSettings.restoreDefaults();
|
||||
}
|
||||
|
||||
// Whether the next safety snapshot is allowed to start at all -- the
|
||||
// "a backup is already running" case, which must refuse the reset
|
||||
// outright rather than proceed without one.
|
||||
function snapshotCanStart(allowed: bool): void {
|
||||
root.snapshotRefusesToStart = !allowed;
|
||||
}
|
||||
|
||||
// Answer the snapshot that restoreDefaults is waiting on. Everything
|
||||
// the reset does must happen after this and not before.
|
||||
function finishSnapshot(success: bool): bool {
|
||||
const done = root.pendingSnapshotDone;
|
||||
if (!done)
|
||||
return false;
|
||||
root.pendingSnapshotDone = null;
|
||||
root.recordReset("snapshot.finish:" + success);
|
||||
done(success);
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetPending(): bool {
|
||||
return SystemSettings.resetPending;
|
||||
}
|
||||
|
||||
function clearError(): void {
|
||||
SystemSettings.lastError = "";
|
||||
}
|
||||
|
||||
function resetState(): string {
|
||||
return JSON.stringify({
|
||||
calls: root.resetCalls,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Vision review — synthesis of three lenses
|
||||
|
||||
Three parallel read-only reviews of the completed settings redesign (honesty audit,
|
||||
completeness vs GNOME/macOS/KDE, product coherence), 2026-08-24, HEAD `be0e552`. Full
|
||||
agent reports live in the session transcript; this file is the ranked synthesis. Companion
|
||||
to the /code-review ultra run of the same day.
|
||||
|
||||
## The one-sentence verdicts
|
||||
|
||||
- **Honesty**: clears the bar on breadth and intent; thinnest at the last mile — the honesty
|
||||
is computed in the services and then dropped by one line of glue at the surface.
|
||||
- **Completeness**: "has thoroughly won the argument that it is a complete settings app; has
|
||||
not yet made the argument that it is a *Hyprland* settings app."
|
||||
- **Coherence**: 8/10 as one product; the deficit is component adoption, not design
|
||||
disagreement — the phases wrote down every shared abstraction and then copy-pasted it.
|
||||
|
||||
## Tier 0 — bugs to fix now (shipped-broken behavior)
|
||||
|
||||
1. **DND exceptions are dead** — `Toasts.qml:15` re-gates on `!Notifs.doNotDisturb`,
|
||||
overriding the service's three-way decision. Allowed apps chime + flash and never show.
|
||||
Fix: `visible: Notifs.popups.length > 0`.
|
||||
2. **Restore-defaults safety snapshot races the wipe** — `takeSafetySnapshot()` returns at
|
||||
launch; the reset rewrites settings.json before the async helper reads it. The "undoable"
|
||||
promise records the post-reset defaults. Fix: completion signal, reset inside it, abort on
|
||||
snapshot failure.
|
||||
3. **Reset confirm lies about dock pins** — "Pinned applications … are not changed" while
|
||||
`resetDesktopDefaults` restores the shipped 16-app dock. Fix the sentence.
|
||||
4. **Manual proxy deadlock** — host and port each refuse to commit until the other is set;
|
||||
neither can ever be first, and manual mode is already engaged. Fix: page-local drafts,
|
||||
commit when both present.
|
||||
5. **"Close the range" closes one of two protocols** — the mutation loop discards every
|
||||
iteration after the first; Fedora's zone has tcp AND udp ranges. Fix: multi-spec verb or
|
||||
pending-mutation queue.
|
||||
6. **Reassurance without measurement** ×3 on Privacy — device-security "everything
|
||||
recommended" over a parse failure; "No app has asked" over an unread portal; live tiles
|
||||
"Idle" with pw-dump absent. The `scanned`/`available`/`initialized` flags exist unread.
|
||||
Same class: failed update check renders "Up to date" (`panama-updates` exit-code
|
||||
swallow); health sidebar footer green beside a red unavailable hero (flag precedence).
|
||||
7. **User-deletion consent names a guessed path** — `"/home/" + userName` in the armed
|
||||
confirm; the helper reports the real HomeDirectory unread.
|
||||
8. **VRR picker snaps back mid-confirmation** — reads the persisted store instead of
|
||||
`pendingRequestedLayout` during the keep-or-revert window.
|
||||
9. **Quicksettings scan killed by the settings service** — `Connectivity.active` false
|
||||
re-syncs `scannerEnabled` off under an open panel stuck at "Searching…". Refcount the
|
||||
hold.
|
||||
10. **Dictation try-it can type someone else's recording** — "Listening…" set even on
|
||||
`already-recording`; mic test has no error surface at all.
|
||||
|
||||
## Tier 1 — the safety layer (one pass, one file first)
|
||||
|
||||
- `SettingsButton` carries the two-press contract in a comment and enforces nothing: add
|
||||
keyboard focus/keys/ring + make the seven one-press destructive buttons two-stage
|
||||
(BluetoothPanel Forget, FocusModeRow Delete, ThemeSaveRow Delete, HomeFavoriteCard Remove,
|
||||
UsersPage avatar Remove, SshKeysPage agent Remove, DisplayPanelHeader Forget) — then the
|
||||
nine more from the honesty audit's finding 11 (theme cards, override reset, history clear,
|
||||
RDP creds, BT quicksettings trash glyph, print-job cancel, display Forget, default-zone
|
||||
change; snapshot-delete and powermenu name their consequence).
|
||||
- Promote three patterns to components so they can't be forgotten: **ConfirmAction**
|
||||
(two-stage, one-armed-at-a-time, Keep/Verb…/Verb it), **ErrorRow** (in-card placement,
|
||||
"<Subject> needs attention"), **"not measured, because—"** state for status surfaces.
|
||||
Add the idiom contract the coherence review specifies so drift fails the build.
|
||||
|
||||
## Tier 2 — identity features (the "Hyprland settings app" argument)
|
||||
|
||||
1. **Custom keyboard shortcuts** (schema `customBinds` → keybinds.lua, ShortcutCapture
|
||||
reused) — build the user-config-through-schema mechanism properly;
|
||||
2. **Window rules editor** rides the same mechanism (float/workspace/size/opacity scope
|
||||
first; match captured from the live window);
|
||||
3. **Gesture bindings** complete the trio.
|
||||
4. **Search tokenization** + the named holes (log out, wired, metered, gestures…) + route
|
||||
through `openSettingsSection` so results land on the right tab (mechanism exists, one
|
||||
caller).
|
||||
5. **Kill the last dead doors**: GNOME "system" umbrella button on Health (contract can't
|
||||
see it — add `[system]=about` to OWNED), the Wellbeing handoff (inert without
|
||||
gnome-shell) → replace with native screen time/break reminders; the phantom "per-device
|
||||
input stays with GNOME" comment.
|
||||
6. **Network round-out**: static IP/DNS/metered/saved networks/hidden SSID (new
|
||||
panama-network verbs).
|
||||
7. Smaller: DND quick-settings tile, color filters via `decoration:screen_shader`,
|
||||
Bluetooth device management depth (trust/rename/adapter, D-Bus already exposes it),
|
||||
keyboard layout list (evdev.lst already parsed), first-run offering import + theme,
|
||||
Displays rescan row, removable-drive mount, launcher toggle verbs (dark mode first),
|
||||
per-app hub drill-in, uncapped lists (known_hosts worst; MyHome entities double-render;
|
||||
quicksettings Wi-Fi/BT lists whose settings twins carry the bounding comments).
|
||||
|
||||
## Tier 3 — coherence convergence (four passes, first two ≈ a day)
|
||||
|
||||
Pass 1 the safety layer (above). Pass 2 adopt existing components (SectionLabel ×9,
|
||||
StatusBadge rename + 6 hand-rolls, SettingsChip ×4, chip height 26, guard the 12 unguarded
|
||||
refresh-on-open). Pass 3 the two extractions + idiom contract. Pass 4 copy & docs: search
|
||||
section-routing, 12 README errors, 5 manual errors + 14 unmentioned categories, one spelling
|
||||
each of colour/app, detail-punctuation rule, 29 essay-details into SettingsNote.
|
||||
|
||||
## Recorded honest state (not findings)
|
||||
|
||||
Tier-2 deferrals all verified recorded in-tree (mono audio, sticky keys, rollback, PPDs,
|
||||
Samba, hibernate, etc.). All 190 schema keys have genuine readers. Zero dead components,
|
||||
complete qmldir, uniform disclosure/lede/button conventions. The manual's structure is
|
||||
contract-pinned; its content is not (that gap is Tier 3 Pass 4).
|
||||
@@ -1,32 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Bluetooth discovery holds the radio, so who turns it on and off is a contract
|
||||
# rather than a detail.
|
||||
#
|
||||
# The pins below started life against modules/quicksettings/BluetoothList.qml,
|
||||
# which computed the desired state and wrote BlueZ itself. That was correct
|
||||
# until a second surface -- the settings page, through Connectivity.qml -- began
|
||||
# doing the same thing from its own visibility flag, at which point the last
|
||||
# writer decided for both: closing the settings page stopped discovery under an
|
||||
# open quick-settings panel, which then sat on "Searching…" over a radio that
|
||||
# had stopped.
|
||||
#
|
||||
# The fix moved the BlueZ write into Connectivity.qml behind named holds, so the
|
||||
# same five guarantees are checked there, plus a sixth that keeps them there:
|
||||
#
|
||||
# 1. the desired state is calculated from state, not toggled;
|
||||
# 2. a redundant start is not issued;
|
||||
# 3. discovery this desktop did not start is never stopped;
|
||||
# 4. merely constructing an inactive picker does not write BlueZ state;
|
||||
# 5. an adapter appearing does not start discovery nobody asked for;
|
||||
# 6. a surface releases its hold when it is destroyed.
|
||||
#
|
||||
# Read-only. It changes no Bluetooth state.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
bluetooth="$repo_dir/config/dot/quickshell/modules/quicksettings/BluetoothList.qml"
|
||||
service="$repo_dir/config/dot/quickshell/services/Connectivity.qml"
|
||||
|
||||
rg -q 'const shouldDiscover = root\.active && root\.adapter\.enabled' "$bluetooth" || {
|
||||
for path in "$bluetooth" "$service"; do
|
||||
[[ -r "$path" ]] || {
|
||||
printf 'bluetooth discovery contract: missing %s\n' "$path" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
rg -q 'const shouldDiscover = root\.discoveryWanted' "$service" || {
|
||||
printf 'bluetooth discovery contract: desired state is not calculated idempotently\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -q 'shouldDiscover && !root\.adapter\.discovering' "$bluetooth" || {
|
||||
rg -q 'shouldDiscover && !root\.adapter\.discovering' "$service" || {
|
||||
printf 'bluetooth discovery contract: redundant BlueZ starts are not guarded\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -q '!shouldDiscover && root\.discoveryOwned && root\.adapter\.discovering' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: the picker may stop discovery it does not own\n' >&2
|
||||
rg -q '!shouldDiscover && root\.discoveryOwned && root\.adapter\.discovering' "$service" || {
|
||||
printf 'bluetooth discovery contract: the service may stop discovery it does not own\n' >&2
|
||||
exit 1
|
||||
}
|
||||
! rg -q 'Component\.onCompleted: root\.syncDiscovery' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: inactive construction still writes BlueZ state\n' >&2
|
||||
|
||||
# The holds themselves. Without both halves the refcount is decorative and the
|
||||
# two surfaces are back to overwriting each other.
|
||||
rg -q 'function acquireDiscovery' "$service" || {
|
||||
printf 'bluetooth discovery contract: nothing can take a hold on discovery\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -Uq 'onAdapterChanged:[^{\n]*\{[^}]*if \(root\.active\)' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: adapter changes are not gated by an open panel\n' >&2
|
||||
rg -q 'function releaseDiscovery' "$service" || {
|
||||
printf 'bluetooth discovery contract: a hold on discovery cannot be released\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -Uq 'Component\.onDestruction:[^{\n]*\{[^}]*root\.discoveryOwned' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: owned discovery is not released on destruction\n' >&2
|
||||
rg -q 'discoveryWanted: root\.discoveryHolders\.length > 0' "$service" || {
|
||||
printf 'bluetooth discovery contract: discovery does not follow the outstanding holds\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 6. One writer. A picker that still wrote adapter.discovering would reopen the
|
||||
# exact fight the holds exist to settle, and it would do it silently.
|
||||
! rg -q 'discovering *=' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: the picker writes BlueZ discovery directly, behind the holds\n' >&2
|
||||
exit 1
|
||||
}
|
||||
! rg -q 'Component\.onCompleted: root\.(syncDiscovery|acquireDiscovery)' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: inactive construction still takes a discovery hold\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -Uq 'onActiveChanged:[^{\n]*\{[^}]*Connectivity\.acquireDiscovery' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: discovery is not gated by an open panel\n' >&2
|
||||
exit 1
|
||||
}
|
||||
rg -q 'Component\.onDestruction: Connectivity\.releaseDiscovery' "$bluetooth" || {
|
||||
printf 'bluetooth discovery contract: the hold is not released on destruction\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
|
||||
|
||||
fail() {
|
||||
@@ -46,6 +47,20 @@ done
|
||||
rg -q 'vrrMode\s*(!==\s*-1|>\s*-1|>=\s*0)' "$service" \
|
||||
|| fail 'nothing in the service decides when the vrr key is omitted, so following the global policy would be written as an override'
|
||||
|
||||
# ── The pickers show what you chose, for as long as the choice is undecided ──
|
||||
#
|
||||
# confirm() is what writes, so during the keep-or-revert window nothing is
|
||||
# stored yet and currentLayout() answers vrrMode from the PREVIOUS stored
|
||||
# record -- the one field the compositor cannot report. The variable-refresh
|
||||
# picker therefore snapped back to its old value the instant the change was
|
||||
# applied, while the banner beside it asked whether to keep the change it had
|
||||
# just stopped showing. The requested layout is what the page must render until
|
||||
# the window closes.
|
||||
rg -Fq 'Displays.awaitingConfirmation && Displays.pendingRequestedLayout' "$page" \
|
||||
|| fail 'the displays page ignores the requested layout during confirmation, so a picker snaps back while the Keep banner is still up'
|
||||
rg -Fq 'Displays.pendingRequestedLayout.find(entry => entry.name === name)' "$page" \
|
||||
|| fail 'the displays page does not read the selected output out of the requested layout'
|
||||
|
||||
rg -Fq 'function applyLayoutFixture(' "$harness" \
|
||||
|| fail 'the fixture cannot exercise complete layout transactions'
|
||||
rg -Fq 'function injectReadback(' "$harness" \
|
||||
|
||||
@@ -306,6 +306,8 @@ process_identity_matches "$harness_pid" "$harness_start_time" \
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.status == "warning" and .acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"] and .diagnosticUnavailable == false' \
|
||||
>/dev/null <<<"$state" || fail "valid warning snapshot was not accepted intact: $state"
|
||||
jq -e '.headlineState == "warning" and .headline == "Needs attention" and .tone == "warn"' \
|
||||
>/dev/null <<<"$state" || fail "a warning snapshot did not produce the shared warning verdict: $state"
|
||||
|
||||
[[ "$(run ipc call health-test accept "$updates_snapshot" 0)" == "true" ]] \
|
||||
|| fail 'a check pointing at Software Update was rejected, so the whole report would go blank rather than one button being dead'
|
||||
@@ -373,6 +375,18 @@ state="$(run ipc call health-test status)"
|
||||
jq -e '.diagnosticUnavailable == true and .checks == ["integration.calendar", "panama.caffeine"]' \
|
||||
>/dev/null <<<"$state" || fail "malformed snapshot discarded the last valid checks: $state"
|
||||
|
||||
# The split-brain state, named. A rejected snapshot on top of a good one leaves
|
||||
# BOTH conditions true: the checks are still there, and the diagnostic is
|
||||
# unavailable. The Health hero asked "unavailable?" first and the sidebar footer
|
||||
# asked "any checks?" first, so the same desktop was a red "Health check
|
||||
# unavailable" and a green "Desktop is healthy" at the same time, six inches
|
||||
# apart. There is one verdict now, and unavailable wins it -- it is the only
|
||||
# state that says the other four are not known to be true.
|
||||
jq -e '.headlineState == "unavailable" and .headline == "Health check unavailable"
|
||||
and .tone == "danger"' \
|
||||
>/dev/null <<<"$state" \
|
||||
|| fail "a rejected snapshot over a good one did not produce one unavailable verdict: $state"
|
||||
|
||||
before_generation="$(jq -r .generation <<<"$state")"
|
||||
run ipc call health-test queue >/dev/null
|
||||
state="$(run ipc call health-test status)"
|
||||
|
||||
@@ -51,6 +51,23 @@ rg -Fq 'onTapped: root.pageRequested("services")' "$settings_dir/SettingsSidebar
|
||||
|| fail 'health footer does not open the stable services route'
|
||||
rg -Fq 'height: 54' "$settings_dir/SettingsSidebar.qml" \
|
||||
|| fail 'health footer lost its 54px target'
|
||||
|
||||
# ── One verdict, two surfaces ────────────────────────────────────────────────
|
||||
# The sidebar footer and the Health hero sit six inches apart and describe the
|
||||
# same desktop. They each used to decide the headline for themselves, in
|
||||
# different orders, so a health check that failed after a successful one showed
|
||||
# a red "unavailable" hero beside a green "Desktop is healthy" footer. Neither
|
||||
# surface may test Health.status or Health.checks.length to work out the
|
||||
# headline again; both render Health.headline and Health.tone.
|
||||
rg -Fq 'return Health.headline;' "$settings_dir/SettingsSidebar.qml" \
|
||||
|| fail 'the sidebar footer does not render the shared health headline'
|
||||
rg -Fq 'Health.headline' "$settings_dir/HealthSummary.qml" \
|
||||
|| fail 'the health hero does not render the shared health headline'
|
||||
for health_surface in SettingsSidebar HealthSummary; do
|
||||
if rg -q 'Health\.(status|checks\.length) ===' "$settings_dir/$health_surface.qml"; then
|
||||
fail "$health_surface re-derives the health headline instead of rendering Health.headline"
|
||||
fi
|
||||
done
|
||||
rg -Fq 'onClicked: Health.copyReport()' "$settings_dir/HealthSummary.qml" \
|
||||
|| fail 'Copy Report does not use the redacted Health report path'
|
||||
rg -Fq 'text: "Checking…"' "$settings_dir/HealthSummary.qml" \
|
||||
|
||||
@@ -26,6 +26,23 @@ settings="$repo_dir/config/dot/quickshell/config/Settings.qml"
|
||||
[[ -f "$schema" ]] || fail 'preference schema is missing'
|
||||
[[ -f "$settings" ]] || fail 'settings singleton is missing'
|
||||
|
||||
# ── The gate is decided once ────────────────────────────────────────────────
|
||||
#
|
||||
# handleNotification weighs Do Not Disturb against its two exceptions and puts
|
||||
# what survives into `popups`. The banner window then re-tested
|
||||
# !Notifs.doNotDisturb on its `visible` binding, which is not a narrowing of
|
||||
# that decision -- it is a replacement of it. An application on a focus mode's
|
||||
# allow list, or a critical notification with breakthrough switched on, was
|
||||
# admitted by the service, chimed, and then rendered onto a window that was
|
||||
# hidden. The exceptions existed, were configurable, and did nothing.
|
||||
toasts="$repo_dir/config/dot/quickshell/modules/notifications/Toasts.qml"
|
||||
[[ -f "$toasts" ]] || fail 'the banner window is missing'
|
||||
rg -Fq 'visible: Notifs.popups.length > 0' "$toasts" \
|
||||
|| fail 'the banner window does not show exactly what the service admitted'
|
||||
if rg -q 'visible:.*doNotDisturb' "$toasts"; then
|
||||
fail 'the banner window re-gates on Do Not Disturb, overriding the allow-list and breakthrough exceptions the service already applied'
|
||||
fi
|
||||
|
||||
# The expanded row body lives in its own component now. It is part of the same
|
||||
# surface as the page, so the per-application assertions read both rather than
|
||||
# only the file that happens to hold the card today -- otherwise pulling a row
|
||||
|
||||
@@ -14,12 +14,22 @@
|
||||
# store would leave a customized Home accessory arrangement in place while
|
||||
# claiming to have restored Panama's defaults. That is worse than having no
|
||||
# reset at all, because it is silent.
|
||||
#
|
||||
# It is also the only irreversible action Panama offers, and the confirm
|
||||
# promises it is undoable because a backup is taken first. That promise is
|
||||
# only worth something if the backup contains the settings being replaced.
|
||||
# The backup is written by a separate process reading settings.json, so
|
||||
# "the call returned" is not "the file is safe": this contract holds the
|
||||
# snapshot open and proves that NOTHING is wiped until it completes, and
|
||||
# that a snapshot which fails -- or cannot start -- aborts the reset
|
||||
# entirely rather than proceeding without an undo.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
|
||||
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
|
||||
settings_backup="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml"
|
||||
wallpaper_service="$repo_dir/config/dot/quickshell/services/Wallpaper.qml"
|
||||
|
||||
# Preferences are committed to $XDG_CONFIG_HOME, and the Home store lives under
|
||||
@@ -52,6 +62,11 @@ rg -Fq 'const effectivePath = path === "" ? root.shippedPath : path;' "$wallpape
|
||||
rg -Fq 'storedPath: rawGlobal === root.shippedPath ? "" : rawGlobal' "$wallpaper_service" \
|
||||
|| fail 'the shipped wallpaper cannot remain represented by the default empty preference'
|
||||
|
||||
# The completion signal is the whole mechanism: without it the caller has no way
|
||||
# to know the helper finished, and the only thing left to wait for is nothing.
|
||||
rg -Fq 'signal saveFinished(bool success)' "$settings_backup" \
|
||||
|| fail 'SettingsBackup.save() reports no completion, so a caller cannot wait for the backup'
|
||||
|
||||
qs_for_harness() {
|
||||
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
|
||||
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" "$@"
|
||||
@@ -131,19 +146,89 @@ jq -e '.count == 1 and .initialized == true' <<<"$home_before" >/dev/null \
|
||||
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "900" ]] \
|
||||
|| fail 'the dock fixture did not apply'
|
||||
|
||||
# Everything below leans on the fixtures still being in place, so each aborted
|
||||
# case re-asserts that they are.
|
||||
fixtures_intact() {
|
||||
local why="$1" home dock
|
||||
home="$(qs_for_harness ipc call settings-system-test homeState)"
|
||||
dock="$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)"
|
||||
jq -e '.count == 1 and .initialized == true' <<<"$home" >/dev/null \
|
||||
|| fail "$why (Home store was cleared: $home)"
|
||||
[[ "$dock" == "900" ]] || fail "$why (schema store was cleared: dockHideDelayMs=$dock)"
|
||||
[[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" != '{}' ]] \
|
||||
|| fail "$why (display records were cleared)"
|
||||
}
|
||||
|
||||
last_error() {
|
||||
qs_for_harness ipc call settings-system-test status | jq -r .lastError
|
||||
}
|
||||
|
||||
# ── A snapshot that cannot even start refuses the reset ──────────────────────
|
||||
# The user was promised an undo. Without one, the honest answer is to do
|
||||
# nothing and say so, not to wipe the stores anyway.
|
||||
qs_for_harness ipc call settings-system-test clearError >/dev/null
|
||||
qs_for_harness ipc call settings-system-test snapshotCanStart false >/dev/null
|
||||
[[ "$(qs_for_harness ipc call settings-system-test restoreDefaults)" == "false" ]] \
|
||||
|| fail 'reset proceeded although no safety backup could be started'
|
||||
sleep 0.5
|
||||
fixtures_intact 'a reset with no safety backup still wiped the stores'
|
||||
refused_state="$(qs_for_harness ipc call settings-system-test resetState)"
|
||||
jq -e '.calls == ["snapshot.start"] and .displayBlocked == false' <<<"$refused_state" >/dev/null \
|
||||
|| fail "a refused reset still began its work: $refused_state"
|
||||
[[ -n "$(last_error)" ]] || fail 'a refused reset said nothing about why nothing happened'
|
||||
[[ "$(qs_for_harness ipc call settings-system-test resetPending)" == "false" ]] \
|
||||
|| fail 'a refused reset left the page believing a reset is under way'
|
||||
qs_for_harness ipc call settings-system-test snapshotCanStart true >/dev/null
|
||||
|
||||
# ── Nothing is wiped while the snapshot is still being written ───────────────
|
||||
# This is the race the old code lost: takeSafetySnapshot() returned at launch
|
||||
# and the reset rewrote settings.json in the same frame, so the helper read back
|
||||
# the defaults it had just been handed and called them the user's settings.
|
||||
qs_for_harness ipc call settings-system-test clearError >/dev/null
|
||||
[[ "$(qs_for_harness ipc call settings-system-test restoreDefaults)" == "true" ]] \
|
||||
|| fail 'restoreDefaults refused a safe reset'
|
||||
sleep 0.5
|
||||
[[ "$(qs_for_harness ipc call settings-system-test resetPending)" == "true" ]] \
|
||||
|| fail 'the reset did not stay pending while the safety backup was being written'
|
||||
fixtures_intact 'the stores were wiped while the safety backup was still being written'
|
||||
inflight_state="$(qs_for_harness ipc call settings-system-test resetState)"
|
||||
jq -e '.calls == ["snapshot.start"] and .displayBlocked == false' <<<"$inflight_state" >/dev/null \
|
||||
|| fail "the reset began before its safety backup finished: $inflight_state"
|
||||
|
||||
# ── A snapshot that fails aborts the reset ──────────────────────────────────
|
||||
[[ "$(qs_for_harness ipc call settings-system-test finishSnapshot false)" == "true" ]] \
|
||||
|| fail 'no snapshot was waiting to be failed'
|
||||
sleep 0.5
|
||||
fixtures_intact 'a failed safety backup still let the reset wipe the stores'
|
||||
failed_state="$(qs_for_harness ipc call settings-system-test resetState)"
|
||||
jq -e '.calls == ["snapshot.start", "snapshot.finish:false"] and .displayBlocked == false' \
|
||||
<<<"$failed_state" >/dev/null \
|
||||
|| fail "a failed safety backup did not abort the reset: $failed_state"
|
||||
[[ -n "$(last_error)" ]] || fail 'a reset aborted by a failed backup said nothing'
|
||||
[[ "$(qs_for_harness ipc call settings-system-test resetPending)" == "false" ]] \
|
||||
|| fail 'an aborted reset stayed pending, so the page can never offer the reset again'
|
||||
|
||||
# ── The reset happens in the snapshot's success path, and only there ─────────
|
||||
qs_for_harness ipc call settings-system-test clearError >/dev/null
|
||||
[[ "$(qs_for_harness ipc call settings-system-test restoreDefaults)" == "true" ]] \
|
||||
|| fail 'restoreDefaults refused a safe reset'
|
||||
sleep 0.3
|
||||
fixtures_intact 'the stores were wiped while the safety backup was still being written'
|
||||
[[ "$(qs_for_harness ipc call settings-system-test finishSnapshot true)" == "true" ]] \
|
||||
|| fail 'no snapshot was waiting to be completed'
|
||||
sleep 0.6
|
||||
|
||||
reset_state="$(qs_for_harness ipc call settings-system-test resetState)"
|
||||
# The snapshot must come FIRST. Restoring defaults is the only irreversible
|
||||
# action Panama offers, and a snapshot taken after the stores were cleared would
|
||||
# faithfully record the wiped state as if it were the user's.
|
||||
jq -e '.calls[0] == "snapshot"' <<<"$reset_state" >/dev/null \
|
||||
|| fail "reset did not snapshot before wiping the stores: $reset_state"
|
||||
# The snapshot must COMPLETE first. Restoring defaults is the only irreversible
|
||||
# action Panama offers, and a snapshot that read the stores after they were
|
||||
# cleared would faithfully record the wiped state as if it were the user's.
|
||||
jq -e '.calls[0] == "snapshot.start" and .calls[1] == "snapshot.finish:true"' \
|
||||
<<<"$reset_state" >/dev/null \
|
||||
|| fail "reset did not wait for the snapshot to finish before wiping the stores: $reset_state"
|
||||
|
||||
jq -e '.calls == [
|
||||
"snapshot",
|
||||
"snapshot.start",
|
||||
"snapshot.finish:true",
|
||||
"display.block:true",
|
||||
"keybinds.reload",
|
||||
"wallpaper.set:",
|
||||
|
||||
@@ -283,6 +283,83 @@ grep -Fq 'dnf-automatic is not installed' "$helper" \
|
||||
grep -q 'def set_auto_dnf' "$helper" \
|
||||
|| fail 'automatic package updates are reported but cannot be turned on'
|
||||
|
||||
# ── 9. A check that failed is not an up-to-date machine ─────────────────────
|
||||
#
|
||||
# Every source used to answer a failure with an empty list and no error: dnf
|
||||
# outside exit 0/100, flatpak against an unreachable remote, fwupdmgr printing
|
||||
# nothing (its --json exits 0 on failure and says so in the payload). The page
|
||||
# then added three zeroes together, said "Up to date", and stamped the clock --
|
||||
# the reassuring wrong answer, on the one page whose whole job is security
|
||||
# fixes.
|
||||
#
|
||||
# Proved with the same stubs the changelog section uses: every tool present and
|
||||
# every tool failing.
|
||||
check_work="$(mktemp -d /tmp/panama-updates-check.XXXXXX)"
|
||||
trap 'rm -rf "$changelog_work" "$check_work"' EXIT
|
||||
mkdir -p "$check_work/cache/panama"
|
||||
|
||||
run_check() {
|
||||
PATH="$changelog_bin:$PATH" XDG_CACHE_HOME="$check_work/cache" "$helper" "$@"
|
||||
}
|
||||
|
||||
failed_check="$(run_check check)" || fail 'check crashed instead of reporting the failure'
|
||||
for source in dnf flatpak firmware; do
|
||||
[[ -n "$(jq -r ".$source.error // \"\"" <<<"$failed_check")" ]] \
|
||||
|| fail "$source reported no error after failing, so its empty list reads as nothing to do: $failed_check"
|
||||
[[ "$(jq -r ".$source.count" <<<"$failed_check")" == "0" ]] \
|
||||
|| fail "$source invented a count out of a failed check: $failed_check"
|
||||
[[ "$(jq -r ".$source.checkedAt" <<<"$failed_check")" == "0" ]] \
|
||||
|| fail "$source stamped its clock on a check that failed: $failed_check"
|
||||
done
|
||||
[[ "$(jq -r .checkedAt <<<"$failed_check")" == "0" ]] \
|
||||
|| fail "a check where nothing answered still stamped the overall clock: $failed_check"
|
||||
[[ "$(jq -r '.dnf.securityKnown' <<<"$failed_check")" == "false" ]] \
|
||||
|| fail "a failed advisory query still claims the security count is known: $failed_check"
|
||||
|
||||
# A clean stamp already on record is CARRIED, not refreshed. "Checked 2 minutes
|
||||
# ago" beside a stale count is the same lie wearing a timestamp.
|
||||
printf '%s' '{"checkedAt":1000,"dnf":{"available":true,"count":0,"packages":[],"securityCount":0,"securityKnown":true,"error":"","checkedAt":1000},"flatpak":{"available":true,"count":0,"applications":[],"error":"","checkedAt":1000},"firmware":{"available":true,"count":0,"devices":[],"error":"","checkedAt":1000}}' \
|
||||
>"$check_work/cache/panama/updates.json"
|
||||
stale_check="$(run_check check)" || fail 'check crashed over an existing cache'
|
||||
[[ "$(jq -r .checkedAt <<<"$stale_check")" == "1000" ]] \
|
||||
|| fail "a failed check moved the overall clock forward: $stale_check"
|
||||
[[ "$(jq -r '.dnf.checkedAt' <<<"$stale_check")" == "1000" ]] \
|
||||
|| fail "a failed source moved its own clock forward: $stale_check"
|
||||
|
||||
# An old cache with none of these fields must still read, rather than losing
|
||||
# them at the surface where a missing error looks exactly like no error.
|
||||
printf '%s' '{"checkedAt":1000,"dnf":{"available":true,"count":0,"packages":[],"securityCount":0}}' \
|
||||
>"$check_work/cache/panama/updates.json"
|
||||
legacy="$(run_check snapshot)" || fail 'snapshot crashed over a cache written before per-source errors'
|
||||
jq -e '(.dnf | has("error") and has("checkedAt"))
|
||||
and (.flatpak | has("error") and has("checkedAt"))
|
||||
and (.firmware | has("error") and has("checkedAt"))' <<<"$legacy" >/dev/null \
|
||||
|| fail "an older cache lost the per-source honesty fields: $legacy"
|
||||
|
||||
# The service must refuse "Up to date" while a source is unknown, and must ask
|
||||
# that question BEFORE it asks whether the total is zero -- a failed source
|
||||
# contributes zero, which is what made the two indistinguishable.
|
||||
grep -Fq 'if (root.anySourceFailed) {' "$service" \
|
||||
|| fail 'the summary does not consider a source that could not answer'
|
||||
python3 - "$service" <<'PY' || fail 'the summary can still say "Up to date" over a source that never answered'
|
||||
import sys
|
||||
|
||||
body = open(sys.argv[1], encoding="utf-8").read()
|
||||
start = body.index("function summary()")
|
||||
end = body.index("\n }", start)
|
||||
summary = body[start:end]
|
||||
if "anySourceFailed" not in summary:
|
||||
raise SystemExit('summary() does not test anySourceFailed')
|
||||
if summary.index("anySourceFailed") > summary.index('"Up to date"'):
|
||||
raise SystemExit('summary() says "Up to date" before it asks whether a source failed')
|
||||
PY
|
||||
grep -Fq 'Updates.sourceError("flatpak") === ""' "$page" \
|
||||
|| fail 'the applications row says "Current" without asking whether the list was read'
|
||||
grep -Fq 'Updates.sourceError("firmware") === ""' "$page" \
|
||||
|| fail 'the firmware row says "Current" without asking whether the list was read'
|
||||
grep -Fq 'securityKnown' "$page" \
|
||||
|| fail 'the headline says "nothing security-critical" without asking whether advisories were read'
|
||||
|
||||
printf 'updates contract: PASS (%s dnf, %s flatpak, %s firmware; reboot needed: %s)\n' \
|
||||
"$(jq -r '.dnf.count // 0' <<<"$state")" \
|
||||
"$(jq -r '.flatpak.count // 0' <<<"$state")" \
|
||||
|
||||
@@ -279,6 +279,27 @@ grep -q 'confirmingRemoval' "$delete_page" \
|
||||
grep -q 'This cannot be undone' "$delete_page" \
|
||||
|| fail 'the page does not say that deleting an account destroys their files'
|
||||
|
||||
# ── The consent names the real directory, or none ───────────────────────────
|
||||
#
|
||||
# Four sentences, the armed confirmation among them, built "/home/" + userName
|
||||
# and presented the result as fact. A home directory is not reliably there: it
|
||||
# can be moved, or live on another mount. accountsservice reports the real one
|
||||
# and the helper already carries it, unread. The one place the app was most
|
||||
# confident was the one place it was guessing, and it was asking for consent to
|
||||
# an irreversible deletion at the time.
|
||||
grep -Fq '"homeDirectory": str(values.get("HomeDirectory") or "")' "$helper" \
|
||||
|| fail 'the helper no longer reports the real home directory'
|
||||
grep -Fq 'function homeDirectory(user: var): string' "$service" \
|
||||
|| fail 'the service does not expose the reported home directory'
|
||||
if grep -q '"/home/"' "$delete_page"; then
|
||||
fail 'the deletion consent constructs a home path instead of reading the reported one'
|
||||
fi
|
||||
grep -Fq 'UserAccounts.homeDirectory(' "$delete_page" \
|
||||
|| fail 'the deletion consent does not read the reported home directory'
|
||||
# And an account with no reported path must be described, not invented.
|
||||
grep -Fq 'their home directory' "$delete_page" \
|
||||
|| fail 'an account with no reported home directory has nothing honest to say about it'
|
||||
|
||||
# The page must not offer to change the type of the only administrator either.
|
||||
type_page="$(file_calling 'administratorCount')"
|
||||
[[ -n "$type_page" ]] || fail 'nothing on the page knows how many administrators there are'
|
||||
|
||||
Reference in New Issue
Block a user