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,
|
||||
|
||||
Reference in New Issue
Block a user