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
153 lines
5.6 KiB
QML
153 lines
5.6 KiB
QML
pragma Singleton
|
|
|
|
// What this machine offers to other machines: remote login, remote desktop,
|
|
// and the two services that would provide file and media sharing if they were
|
|
// installed.
|
|
//
|
|
// A service that is absent is reported as absent rather than shown as a switch
|
|
// that would do nothing -- which is the failure mode of the panel this replaces.
|
|
//
|
|
// Turning remote login on is a system-wide change and goes through pkexec, so
|
|
// it prompts. Remote desktop is a user service and does not.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-sharing"
|
|
|
|
property string hostname: ""
|
|
property string prettyHostname: ""
|
|
property var remoteLogin: ({})
|
|
property var remoteDesktop: ({})
|
|
property var fileSharing: ({})
|
|
property var mediaSharing: ({})
|
|
property bool scanned: false
|
|
property string lastError: ""
|
|
|
|
// Guards read the Process objects directly; a derived binding is stale
|
|
// inside the handler that changes it. See DefaultApps.qml.
|
|
readonly property bool busy: query.running || mutation.running
|
|
|
|
// The name someone types to reach this machine.
|
|
readonly property string networkName: root.hostname !== "" ? root.hostname : "this machine"
|
|
|
|
readonly property bool remoteLoginOn: root.remoteLogin?.active === true
|
|
|
|
// People signed in from another machine right now. Empty is the normal
|
|
// case; the list exists so that "someone is on this machine" is something
|
|
// the page can state rather than something you have to go and check.
|
|
readonly property var remoteSessions: Array.isArray(root.remoteLogin?.sessions)
|
|
? root.remoteLogin.sessions
|
|
: []
|
|
readonly property bool remoteDesktopOn: root.remoteDesktop?.active === true
|
|
|
|
// sshd's configuration only sometimes states this. Saying "keys only" when
|
|
// the file is silent would be a security claim that cannot be backed up.
|
|
function passwordLoginSummary(): string {
|
|
const stated = String(root.remoteLogin?.passwordAuthentication ?? "");
|
|
if (stated === "")
|
|
return "Not configured, so the system default applies";
|
|
return stated.toLowerCase() === "no"
|
|
? "Refused; keys only"
|
|
: "Allowed";
|
|
}
|
|
|
|
function refresh(): void {
|
|
if (query.running)
|
|
return;
|
|
query.command = [root.helperPath, "snapshot"];
|
|
query.running = true;
|
|
}
|
|
|
|
function absorb(text: string): void {
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
root.hostname = String(parsed.hostname ?? "");
|
|
root.prettyHostname = String(parsed.prettyHostname ?? "");
|
|
root.remoteLogin = parsed.remoteLogin ?? ({});
|
|
root.remoteDesktop = parsed.remoteDesktop ?? ({});
|
|
root.fileSharing = parsed.fileSharing ?? ({});
|
|
root.mediaSharing = parsed.mediaSharing ?? ({});
|
|
root.lastError = String(parsed.error ?? "");
|
|
} catch (error) {
|
|
root.lastError = "Could not read the sharing helper's answer.";
|
|
console.warn("Sharing: could not parse helper output:", error);
|
|
}
|
|
root.scanned = true;
|
|
}
|
|
|
|
function run(arguments: var): void {
|
|
if (mutation.running)
|
|
return;
|
|
root.lastError = "";
|
|
mutation.command = [root.helperPath].concat(arguments);
|
|
mutation.running = true;
|
|
}
|
|
|
|
function setRemoteLogin(enabled: bool): void {
|
|
root.run(["set-remote-login", enabled ? "true" : "false"]);
|
|
}
|
|
|
|
function setRemoteDesktop(enabled: bool): void {
|
|
root.run(["set-remote-desktop", enabled ? "true" : "false"]);
|
|
}
|
|
|
|
function setHostname(name: string): void {
|
|
root.run(["set-hostname", name]);
|
|
}
|
|
|
|
function setRdpPort(port: string): void {
|
|
root.run(["set-rdp-port", port]);
|
|
}
|
|
|
|
// Rygel serves media to devices over DLNA. Turning it on publishes media
|
|
// directories to every device on the network, so the page says that next to
|
|
// the switch rather than after the fact.
|
|
function setMediaSharing(enabled: bool): void {
|
|
root.run(["set-media-sharing", enabled ? "true" : "false"]);
|
|
}
|
|
|
|
function setRdpViewOnly(viewOnly: bool): void {
|
|
root.run(["set-rdp-view-only", viewOnly ? "true" : "false"]);
|
|
}
|
|
|
|
function clearRdpCredentials(): void {
|
|
root.run(["clear-rdp-credentials"]);
|
|
}
|
|
|
|
// Setting credentials opens a terminal running gnome-remote-desktop's own
|
|
// tool, which prompts for the password itself.
|
|
//
|
|
// That is not a cop-out, it is the only safe path: grdctl takes the
|
|
// password on a terminal and CRASHES without one, and the alternative --
|
|
// passing it as an argument -- would publish it through /proc to every
|
|
// process on this machine. Typed into grdctl directly, it never passes
|
|
// through Panama at all.
|
|
function setRdpCredentials(userName: string): void {
|
|
Quickshell.execDetached(["kitty", "--hold", "-e",
|
|
"grdctl", "rdp", "set-credentials", userName]);
|
|
}
|
|
|
|
Process {
|
|
id: query
|
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: mutation
|
|
// Answers with the fresh state, so the page updates from the change
|
|
// itself rather than asking again afterwards.
|
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
}
|
|
}
|