Autostart entries showed "Enabled" or "Disabled" as plain text. The row did toggle on click the whole time, so this is an affordance rather than a missing capability -- but a control that reads as static text is one nobody knows they have. It is a switch now, with removal alongside it behind a confirmation: disabling writes Hidden=true and can be undone, deleting the file cannot. remove-autostart is confined to files the autostart directory owns. It resolves the path and compares the parent, so a name like "../../.bashrc" cannot escape, and it refuses symlinks rather than following them -- deleting through one would remove whatever it points at, which is somewhere else and not ours. Each refusal was tested against a fixture directory, including a symlink aimed at /etc/hostname, which survived. Sharing says who is signed in from another machine: user, origin and since when. An empty list on this machine proves nothing, so the parser was checked against sample `who` output -- it picks out remote sessions and leaves out local seats and the :0 display, which would otherwise report the person at the keyboard as a remote login. Media sharing was "Available" and nothing else: rygel installed, rygel.service disabled, no way to change that from here. It is a switch now, and it says what it does before you touch it rather than afterwards -- turning it on publishes media folders to every device on the network with no password in front of them. Per-application camera and microphone permissions come from the portal's permission store, which is where an application that asked through the portal has its answer recorded. The page states the limit plainly instead of implying a protection that does not exist: a program installed outside the portal opens the device directly and nothing here stands in its way. Anything that is not an explicit "yes" is treated as withheld, because guessing generously about a camera is the wrong way to be wrong. The first version of the write silently did nothing -- SetPermission takes an array of strings and was being handed one string -- and the test did not notice, because it discarded the helper's output and only checked that state was unchanged afterwards, which was trivially true. The contract now requires the value to move, and was proven to fail by putting that exact bug back. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
151 lines
5.7 KiB
QML
151 lines
5.7 KiB
QML
pragma Singleton
|
|
|
|
// Freedesktop default handlers and session autostart entries.
|
|
//
|
|
// The helper owns parsing and atomic desktop-file writes. This singleton keeps
|
|
// the QML side typed and reactive, and every external command crosses Process
|
|
// as an argument array.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property var handlers: ({})
|
|
property var autostartEntries: []
|
|
property var luaAutostartEntries: []
|
|
property string lastError: ""
|
|
|
|
// For the UI, which wants one answer to "is anything happening".
|
|
//
|
|
// Guards inside this file do NOT use it. `busy` is a binding, and a binding
|
|
// hands back its cached value until the change notification that feeds it
|
|
// has been delivered. Inside a process's own onExited handler that has not
|
|
// happened yet, so `busy` still reads true there -- which silently turned
|
|
// the refresh after every successful write into a no-op. The write landed
|
|
// and the settings page never noticed, which looks exactly like a settings
|
|
// page that cannot change anything. Guards below read the Process objects
|
|
// directly, where the value is current.
|
|
readonly property bool busy: snapshotProcess.running || mutationProcess.running
|
|
readonly property string helper: Quickshell.shellDir + "/scripts/panama-default-apps"
|
|
// Derived from the snapshot, never restated. This was a hardcoded list of
|
|
// seven, and when the helper and the page grew documents, text and archives
|
|
// it stayed at seven -- so choosing a PDF viewer set lastError and did
|
|
// nothing, which reads exactly like a settings page that does not work.
|
|
//
|
|
// The snapshot already reports one handler per role the helper supports, so
|
|
// that IS the list. An empty one means no snapshot has landed yet; the
|
|
// helper validates the role itself and reports a failure, so there is
|
|
// nothing for this guard to add before then.
|
|
readonly property var supportedRoles: Object.keys(root.handlers ?? ({}))
|
|
|
|
Process {
|
|
id: snapshotProcess
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.applySnapshot(this.text)
|
|
}
|
|
onExited: (exitCode, exitStatus) => {
|
|
if (exitCode !== 0)
|
|
root.lastError = "Default applications could not be read. Try refreshing."
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: mutationProcess
|
|
|
|
onExited: (exitCode, exitStatus) => {
|
|
if (exitCode !== 0) {
|
|
root.lastError = "That application setting could not be changed."
|
|
return;
|
|
}
|
|
root.refresh();
|
|
}
|
|
}
|
|
|
|
function applySnapshot(text: string): void {
|
|
try {
|
|
const payload = JSON.parse(text);
|
|
root.handlers = payload.handlers ?? ({});
|
|
root.autostartEntries = payload.autostartEntries ?? [];
|
|
root.luaAutostartEntries = payload.luaAutostartEntries ?? [];
|
|
root.lastError = "";
|
|
} catch (error) {
|
|
root.lastError = "Default applications returned an unreadable response."
|
|
}
|
|
}
|
|
|
|
function refresh(): void {
|
|
// Only a snapshot already in flight is a reason not to start another.
|
|
// A mutation finishing is the single best reason TO refresh.
|
|
if (snapshotProcess.running)
|
|
return;
|
|
root.lastError = "";
|
|
snapshotProcess.exec([root.helper, "snapshot"]);
|
|
}
|
|
|
|
function knownDesktopId(desktopId: string): bool {
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$/.test(desktopId))
|
|
return false;
|
|
const entries = DesktopEntries.applications.values;
|
|
return entries.some(entry => {
|
|
const entryId = String(entry.id ?? "");
|
|
return entryId === desktopId || entryId + ".desktop" === desktopId;
|
|
});
|
|
}
|
|
|
|
function setDefault(role: string, desktopId: string): void {
|
|
if (mutationProcess.running)
|
|
return;
|
|
const roleIsKnown = root.supportedRoles.length === 0
|
|
|| root.supportedRoles.includes(role);
|
|
if (!roleIsKnown || !root.knownDesktopId(desktopId)) {
|
|
root.lastError = "Choose an application from the available list."
|
|
return;
|
|
}
|
|
root.lastError = "";
|
|
mutationProcess.exec([root.helper, "set-default", role, desktopId]);
|
|
}
|
|
|
|
function setAutostart(desktopId: string, enabled: bool): void {
|
|
if (mutationProcess.running)
|
|
return;
|
|
const known = root.autostartEntries.some(entry => entry.id === desktopId);
|
|
if (!known) {
|
|
root.lastError = "That user autostart entry is no longer available."
|
|
return;
|
|
}
|
|
root.lastError = "";
|
|
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
|
|
}
|
|
|
|
// Deletes the entry rather than hiding it. Disabling writes Hidden=true and
|
|
// is reversible; this is not, so the page confirms before calling it.
|
|
function removeAutostart(desktopId: string): void {
|
|
if (mutationProcess.running)
|
|
return;
|
|
const known = root.autostartEntries.some(entry => entry.id === desktopId);
|
|
if (!known) {
|
|
root.lastError = "That user autostart entry is no longer available."
|
|
return;
|
|
}
|
|
root.lastError = "";
|
|
mutationProcess.exec([root.helper, "remove-autostart", desktopId]);
|
|
}
|
|
|
|
function addAutostart(desktopId: string): void {
|
|
if (mutationProcess.running)
|
|
return;
|
|
if (!root.knownDesktopId(desktopId)) {
|
|
root.lastError = "Choose an installed application.";
|
|
return;
|
|
}
|
|
root.lastError = "";
|
|
mutationProcess.exec([root.helper, "add-autostart", desktopId]);
|
|
}
|
|
|
|
Component.onCompleted: root.refresh()
|
|
}
|