Harden deployment, authentication, firewall and media storage failure paths

This commit is contained in:
rope 2026-09-14 11:50:22 +01:00
parent 64ede7bd05
commit 7145a35927
35 changed files with 2529 additions and 499 deletions

View file

@ -476,7 +476,6 @@ in
singleton Commands 1.0 Commands.qml
singleton MonoIcons 1.0 MonoIcons.qml
Bar 1.0 Bar.qml
Lock 1.0 Lock.qml
Cheatsheet 1.0 Cheatsheet.qml
'';
};
@ -501,7 +500,10 @@ in
// reboot; every colour below is a binding on `pal`, so
// writing this file re-tints the entire shell in place.
property FileView wallFile: FileView {
path: Quickshell.env("HOME") + "/.local/state/quickshell-wallpaper.json"
// The greeter uses the declared default wallpaper and
// never reads or writes a user's runtime theme state.
path: Quickshell.env("FRED_GREETER") === "1" ? ""
: Quickshell.env("HOME") + "/.local/state/quickshell-wallpaper.json"
printErrors: false
// Synchronous: the palette load chains off this, and an
// async hop here means one frame painted in the wrong
@ -511,7 +513,7 @@ in
id: wallState
property string name: "${defaultWall}"
}
onLoadFailed: writeAdapter()
onLoadFailed: if (path !== "") writeAdapter()
}
// Unknown name (wallpaper deleted from the repo since it was
@ -715,324 +717,6 @@ in
'';
};
"quickshell/Lock.qml" = {
onChange = qsRestart;
text = ''
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Services.Pam
import Quickshell.Services.UPower
import Quickshell.Services.Mpris
import QtQuick
import Qt5Compat.GraphicalEffects
// ext-session-lock screen, replacing hyprlock. Set `locked: true`
// and the compositor hands every screen to a LockSurface below
// until PAM says otherwise.
//
// PAM and state live in a Scope OUTSIDE WlSessionLock: its default
// property is the surface *component*, so a PamContext declared
// inside it is never instantiated — `pam` didn't resolve and
// submit() threw, which is why enter/the arrow did nothing.
Scope {
id: root
property alias locked: lock.locked
property string password: ""
property bool failed: false
// True only for the once-per-session lock below, so a later
// manual lock/unlock does not re-launch spotify.
property bool bootLock: false
function submit(pw) {
if (pam.active || pw === "") return;
failed = false;
password = pw;
pam.start();
}
// greetd autologs us in, so lock as soon as the shell comes up:
// the lockscreen is the login prompt. mkdir is the atomic
// once-per-session test — XDG_RUNTIME_DIR dies with the
// session, so quickshell restarts (rebuilds) don't re-lock.
Process {
running: true
command: ["mkdir", Quickshell.env("XDG_RUNTIME_DIR") + "/qs-boot-lock"]
onExited: code => { if (code === 0) { root.bootLock = true; lock.locked = true; } }
}
// Nothing here unlocks the keyring — pam_gnome_keyring already
// did it during the *auth* phase. gkr-pam-module.c's
// pam_sm_authenticate calls unlock_keyring() directly and logs
// "gkr-pam: unlocked login keyring"; the session phase is only
// its fallback for when that fails. So PamContext's auth-only
// run has always been enough, and an explicit
// gnome-keyring-daemon --replace --unlock here was both
// redundant and a trap: the replacement daemon inherits
// quickshell's stdout, holds the pipe open for the life of the
// session, and the Process therefore never reports exiting.
//
// The only real problem was ordering. Spotify asks the Secret
// Service the moment it launches, and from hyprland.start that
// is before the lockscreen has been dismissed — keyring still
// locked, so it lands on gcr's unlock prompt. Start it here,
// after PAM has accepted the password, instead.
Process {
id: spotifyStart
// Via hyprland, not as a child of quickshell: a shell
// restart on rebuild must not take spotify down with it.
command: ["hyprctl", "dispatch", "exec", "spotify"]
}
PamContext {
id: pam
// NixOS ships a "login" pam service; pam_unix verifies the
// user's own password via the unix_chkpwd setuid wrapper.
config: "login"
onPamMessage: {
if (this.responseRequired) this.respond(root.password);
}
onCompleted: result => {
if (result === PamResult.Success) {
lock.locked = false;
// Boot lock only: a later manual lock/unlock must
// not spawn a second spotify.
if (root.bootLock) {
root.bootLock = false;
spotifyStart.running = true;
}
} else root.failed = true;
root.password = "";
}
}
WlSessionLock {
id: lock
WlSessionLockSurface {
id: surface
color: "black"
Image {
id: wall
anchors.fill: parent
source: "file://" + Theme.wallImage
// cache:false — soft reload (qs ipc reload) keeps the process
// alive, and the QML pixmap cache is keyed by URL, so a new
// wallpaper behind the same path never shows without this.
cache: false
fillMode: Image.PreserveAspectCrop
visible: false
}
FastBlur {
anchors.fill: wall
source: wall
radius: 20
}
// Lighter blur means less natural contrast, so the scrim
// is a left-weighted gradient instead of a flat 0.4 wash:
// dark under the text column, near-clear on the right.
Rectangle {
anchors.fill: parent
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "#b0000000" }
GradientStop { position: 0.55; color: "#59000000" }
GradientStop { position: 1.0; color: "#33000000" }
}
}
SystemClock {
id: lockClock
precision: SystemClock.Seconds
}
// Lower-left, not centred: clock/date/password stack left
// aligned against a 10% margin, sitting below the middle.
Column {
anchors.left: parent.left
anchors.leftMargin: Math.round(parent.width * 0.10)
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: Math.round(parent.height * 0.14)
spacing: 6
Text {
text: lockClock.date.toLocaleTimeString(Qt.locale(), "HH:mm")
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 104
font.weight: Font.DemiBold
}
Text {
text: lockClock.date.toLocaleDateString(Qt.locale(), "dddd, d MMMM")
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 18
}
Item { width: 1; height: 14 }
${lib.optionalString isMacbook ''
// Power. UPower.displayDevice is the same DBus source
// the bar reads — no polling, no /sys parsing.
Row {
id: lockBatt
spacing: 8
property var dev: UPower.displayDevice
property int level: dev && dev.ready ? Math.round(dev.percentage * 100) : 0
property bool charging: dev ? dev.state === UPowerDeviceState.Charging : false
property string remaining: {
if (!dev) return "";
let secs = charging ? dev.timeToFull : dev.timeToEmpty;
if (!secs || secs <= 0) return "";
return Math.floor(secs / 3600) + "h " + Math.round((secs % 3600) / 60) + "m";
}
visible: dev && dev.ready
Text {
anchors.verticalCenter: parent.verticalCenter
text: lockBatt.charging ? "battery-charging"
: lockBatt.level >= 75 ? "battery-full"
: lockBatt.level >= 50 ? "battery-medium"
: lockBatt.level >= 20 ? "battery-low"
: "battery"
font.family: Theme.iconFont
font.pixelSize: 17
color: lockBatt.level <= 15 ? Theme.err
: lockBatt.level <= 30 ? Theme.warn : Theme.base04
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: lockBatt.level + "%" + (lockBatt.remaining === "" ? ""
: " · " + lockBatt.remaining + (lockBatt.charging ? " to full" : " left"))
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 15
}
}
''}
// Now playing. Mpris is a global service, so this shows
// whatever kept playing into the lock — nothing is woken.
Row {
id: lockMedia
spacing: 8
property var player: Mpris.players.values.filter(p => p.trackTitle)[0]
visible: !!player
Text {
anchors.verticalCenter: parent.verticalCenter
text: "music"
font.family: Theme.iconFont
font.pixelSize: 17
color: Theme.base04
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: 380
elide: Text.ElideRight
text: lockMedia.player
? lockMedia.player.trackTitle + (lockMedia.player.trackArtist
? " · " + lockMedia.player.trackArtist : "")
: ""
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 15
}
}
Item { width: 1; height: 14 }
// Password pill: dots for typed chars; border tracks
// state (accent while checking, red on failure).
Rectangle {
width: 300
height: 46
radius: Theme.radiusCard
color: Theme.cardBg
border.width: Theme.borderWidth
border.color: pam.active ? Theme.accent
: root.failed ? Theme.err
: Theme.base03
Behavior on border.color { ColorAnimation { duration: Theme.animFade } }
Row {
anchors.centerIn: parent
spacing: 8
Repeater {
model: Math.min(pwInput.text.length, 24)
Rectangle {
width: 8; height: 8; radius: 4
color: Theme.base05
}
}
}
Text {
anchors.centerIn: parent
visible: pwInput.text === "" && root.failed
text: "wrong password"
color: Theme.err
font.family: Theme.fontFamily
font.pixelSize: 13
}
TextInput {
id: pwInput
anchors.fill: parent
opacity: 0
enabled: !pam.active
echoMode: TextInput.Password
focus: true
function go() {
root.submit(text);
text = "";
}
// Explicit key handlers: accepted() doesn't
// fire reliably on the lock surface.
Keys.onReturnPressed: go()
Keys.onEnterPressed: go()
onAccepted: go()
Keys.onEscapePressed: text = ""
}
// Visible confirm button (after TextInput: on top)
Rectangle {
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
width: 32; height: 32; radius: 16
color: confirmMa.containsMouse ? Theme.base02 : "transparent"
Behavior on color { ColorAnimation { duration: Theme.animFade } }
visible: pwInput.text.length > 0
Text {
anchors.centerIn: parent
text: "arrow-right"
font.family: Theme.iconFont
font.pixelSize: 18
color: Theme.accent
}
MouseArea {
id: confirmMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: pwInput.go()
}
}
}
}
Component.onCompleted: pwInput.forceActiveFocus()
}
}
}
'';
};
"quickshell/Cheatsheet.qml" = {
onChange = qsRestart;
text = ''
@ -1265,16 +949,11 @@ in
function reload(): void { Quickshell.reload(false); }
}
// Session lock (replaces hyprlock). `qs ipc call lock lock`
// from hypridle / loginctl lock-session; the session menu
// calls root.lock() directly. Idempotent while locked.
Lock { id: lockScreen }
function lock() { lockScreen.locked = true; }
IpcHandler {
target: "lock"
function lock(): void { root.lock(); }
// Escape hatch from a VT: qs ipc call lock unlock
function unlock(): void { lockScreen.locked = false; }
// The lock runs independently of the bar and its reloads.
function lock() { lockRequest.running = true; }
Process {
id: lockRequest
command: ["fred-lock"]
}
// Screenshot pin: the Shift+Super+S keybind brackets hyprshot