Switch hyprlock to QS

This commit is contained in:
David Heinemeier Hansson
2026-05-19 17:08:14 +02:00
parent 0f5044167c
commit 7ea4e1ab04
35 changed files with 784 additions and 219 deletions
+9
View File
@@ -19,6 +19,7 @@ User-installed plugins live alongside these conceptually but on disk under
| Omarchy menu | `omarchy.menu` | `menu` | `menu/Menu.qml` |
| Notifications | `omarchy.notifications` | `service` | `notifications/Service.qml` |
| Idle monitor | `omarchy.idle` | `service` | `idle/Service.qml` |
| Lock screen | `omarchy.lock` | `service` | `lock/Service.qml` |
| OSD | `omarchy.osd` | `panel` | `osd/Osd.qml` |
| Polkit agent | `omarchy.polkit` | `service` | `polkit/PolkitAgent.qml` |
@@ -69,6 +70,14 @@ clears it without writing a selection.
The plugin has `keepLoaded: true` so the layer-shell window survives
between summons within a single shell session.
## Lock screen
Session-lock surface using Quickshell's native `WlSessionLock` and two
separate PAM services: `omarchy-lock-password` for password auth and,
only when fingerprints are enrolled, `omarchy-lock-fingerprint` for
fingerprint auth. It mirrors the previous lock screen field dimensions,
colors, blurred wallpaper, placeholder, and corner toggle.
## Polkit agent
Theme-aware authentication dialog for privileged actions. It uses
+131 -32
View File
@@ -21,6 +21,11 @@ Item {
property bool idledThisCycle: false
property bool screensaverStartedThisCycle: false
property string lastEvent: "starting"
property string lastEventAt: ""
property string lastScreensaverState: "unknown"
property string lastScreensaverCheckAt: ""
property int screensaverCheckCount: 0
PersistentProperties {
id: persisted
@@ -34,8 +39,23 @@ Item {
return Math.floor(n)
}
function runProcess(process, command) {
if (process.running) return false
function nowIso() {
return new Date().toISOString()
}
function logEvent(event, details) {
var suffix = details === undefined || details === null || details === "" ? "" : ": " + String(details)
root.lastEventAt = nowIso()
root.lastEvent = event + suffix
console.log("omarchy idle " + root.lastEventAt + " " + root.lastEvent)
}
function runProcess(process, label, command) {
if (process.running) {
logEvent("process-skip", label + " already running")
return false
}
logEvent("process-start", label + " " + command)
process.command = ["bash", "-lc", command]
process.running = true
return true
@@ -43,21 +63,27 @@ Item {
function launchScreensaver() {
root.screensaverStartedThisCycle = true
runProcess(screensaverProcess, "pidof hyprlock >/dev/null || omarchy-launch-screensaver")
screensaverResumePollTimer.restart()
runProcess(screensaverProcess, "screensaver", "[[ $(omarchy-shell lock isLocked 2>/dev/null) == \"true\" ]] || omarchy-launch-screensaver")
}
function lockSystem() {
function lockSystem(reason) {
logEvent("lock-system", reason || "requested")
screensaverTimer.stop()
lockTimer.stop()
screensaverResumePollTimer.stop()
root.idledThisCycle = false
root.screensaverStartedThisCycle = false
runProcess(lockProcess, "omarchy-system-lock")
runProcess(lockProcess, "lock", "omarchy-system-lock")
}
function startIdleCycle() {
if (root.idledThisCycle) return
if (root.idledThisCycle) {
logEvent("idle-cycle-already-running")
return
}
logEvent("idle-cycle-start", "screensaver=" + root.screensaverTimeoutSeconds + " lock=" + root.lockTimeoutSeconds)
root.idledThisCycle = true
root.screensaverStartedThisCycle = false
screensaverResumePollTimer.stop()
@@ -65,16 +91,17 @@ Item {
if (root.screensaverDelaySeconds === 0) launchScreensaver()
else screensaverTimer.restart()
if (root.lockDelaySeconds === 0) lockSystem()
if (root.lockDelaySeconds === 0) lockSystem("lock-timeout-immediate")
else lockTimer.restart()
}
function cancelIdleCycle() {
function cancelIdleCycle(reason) {
logEvent("idle-cycle-cancel", reason || "requested")
screensaverTimer.stop()
lockTimer.stop()
screensaverResumePollTimer.stop()
if (root.idledThisCycle) runProcess(wakeProcess, "omarchy-system-wake")
if (root.idledThisCycle) runProcess(wakeProcess, "wake", "omarchy-system-wake")
root.idledThisCycle = false
root.screensaverStartedThisCycle = false
@@ -83,7 +110,9 @@ Item {
function checkScreensaverAfterActiveSignal() {
if (!root.idledThisCycle || !root.screensaverStartedThisCycle) return
if (screensaverCheckProcess.running) return
screensaverCheckProcess.command = ["bash", "-lc", "pgrep -f org.omarchy.screensaver >/dev/null && echo running || echo stopped"]
root.lastScreensaverState = "checking"
root.lastScreensaverCheckAt = nowIso()
screensaverCheckProcess.command = ["bash", "-lc", "if hyprctl clients -j 2>/dev/null | jq -e '.[] | select(.class == \"org.omarchy.screensaver\" or .initialClass == \"org.omarchy.screensaver\")' >/dev/null; then echo running-window; elif pgrep -f '[o]rg.omarchy.screensaver' >/dev/null; then echo running-process; elif omarchy-toggle-enabled screensaver-off; then echo disabled; else echo stopped; fi"]
screensaverCheckProcess.running = true
}
@@ -94,14 +123,16 @@ Item {
// the lock timer running while the screensaver is still alive so the lock
// deadline remains `idle.lock` seconds from the original user idle time.
if (root.screensaverStartedThisCycle) {
logEvent("idle-monitor-active", "screensaver cycle remains armed")
screensaverResumePollTimer.restart()
return
}
cancelIdleCycle()
cancelIdleCycle("activity")
}
function handleIdleChanged() {
logEvent("idle-monitor", idleMonitor.isIdle ? "idle" : "active")
if (!root.idleEnabled) return
if (idleMonitor.isIdle) startIdleCycle()
@@ -113,9 +144,31 @@ Item {
enabled: root.idleEnabled,
idle: idleMonitor.isIdle,
inIdleCycle: root.idledThisCycle,
screensaverStarted: root.screensaverStartedThisCycle,
sleepMonitor: sleepMonitorProcess.running,
screensaver: root.screensaverTimeoutSeconds,
lock: root.lockTimeoutSeconds
lock: root.lockTimeoutSeconds,
screensaverDelay: root.screensaverDelaySeconds,
lockDelay: root.lockDelaySeconds,
timers: {
screensaver: screensaverTimer.running,
lock: lockTimer.running,
poll: screensaverResumePollTimer.running,
sleepMonitorRestart: sleepMonitorRestartTimer.running
},
processes: {
screensaver: screensaverProcess.running,
lock: lockProcess.running,
wake: wakeProcess.running,
check: screensaverCheckProcess.running,
sleepLock: sleepLockProcess.running,
sleepWake: sleepWakeProcess.running
},
lastEvent: root.lastEvent,
lastEventAt: root.lastEventAt,
lastScreensaverState: root.lastScreensaverState,
lastScreensaverCheckAt: root.lastScreensaverCheckAt,
screensaverCheckCount: root.screensaverCheckCount
})
}
@@ -123,10 +176,10 @@ Item {
if (!root.idleEnabled) return
if (preparing) {
cancelIdleCycle()
runProcess(sleepLockProcess, "OMARCHY_LOCK_ONLY=true omarchy-system-lock")
cancelIdleCycle("sleep-preparing")
runProcess(sleepLockProcess, "sleep-lock", "OMARCHY_LOCK_ONLY=true omarchy-system-lock")
} else {
runProcess(sleepWakeProcess, "sleep 1 && omarchy-system-wake")
runProcess(sleepWakeProcess, "sleep-wake", "sleep 1 && omarchy-system-wake")
}
}
@@ -135,7 +188,8 @@ Item {
if (persisted.idleEnabled === enabled) return enabled ? "enabled" : "disabled"
persisted.idleEnabled = enabled
if (!enabled) cancelIdleCycle()
logEvent("idle-enabled", enabled ? "enabled" : "disabled")
if (!enabled) cancelIdleCycle("disabled")
else Qt.callLater(root.handleIdleChanged)
return enabled ? "enabled" : "disabled"
@@ -160,7 +214,7 @@ Item {
id: lockTimer
interval: root.lockDelaySeconds * 1000
repeat: false
onTriggered: if (root.idleEnabled && root.idledThisCycle) root.lockSystem()
onTriggered: if (root.idleEnabled && root.idledThisCycle) root.lockSystem("lock-timeout")
}
Timer {
@@ -170,22 +224,52 @@ Item {
onTriggered: root.checkScreensaverAfterActiveSignal()
}
Process { id: screensaverProcess }
Process { id: lockProcess }
Process { id: wakeProcess }
Process { id: sleepLockProcess }
Process { id: sleepWakeProcess }
Process {
id: screensaverProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "screensaver exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: lockProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "lock exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: wakeProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "wake exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: sleepLockProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "sleep-lock exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: sleepWakeProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "sleep-wake exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: screensaverCheckProcess
stdout: StdioCollector {
id: screensaverCheckStdout
waitForEnd: true
stdout: SplitParser {
onRead: function(line) {
root.lastScreensaverState = String(line || "").trim()
root.lastScreensaverCheckAt = root.nowIso()
root.screensaverCheckCount++
root.logEvent("screensaver-check", root.lastScreensaverState)
}
}
onExited: {
if (!root.idledThisCycle || !root.screensaverStartedThisCycle) return
if (String(screensaverCheckStdout.text || "").trim() === "running") return
root.cancelIdleCycle()
onExited: function(exitCode, exitStatus) {
root.logEvent("process-exit", "screensaver-check exitCode=" + exitCode + " status=" + exitStatus + " state=" + root.lastScreensaverState)
if (!root.idleEnabled || !root.idledThisCycle || !root.screensaverStartedThisCycle) return
var state = String(root.lastScreensaverState || "").trim()
if (state.indexOf("running") === 0) return
if (state === "disabled") {
screensaverResumePollTimer.stop()
return
}
// If the screensaver disappears while we're still in the idle cycle,
// treat that as the user returning and require authentication before
// showing the desktop again.
root.lockSystem("screensaver-stopped state=" + state)
}
}
@@ -200,17 +284,28 @@ Item {
else if (text === "boolean false") root.handleSleepPreparing(false)
}
}
onExited: sleepMonitorRestartTimer.restart()
onExited: {
root.logEvent("sleep-monitor-exit", "restarting")
sleepMonitorRestartTimer.restart()
}
}
Timer {
id: sleepMonitorRestartTimer
interval: 5000
repeat: false
onTriggered: if (!sleepMonitorProcess.running) sleepMonitorProcess.running = true
onTriggered: {
if (!sleepMonitorProcess.running) {
root.logEvent("sleep-monitor-restart")
sleepMonitorProcess.running = true
}
}
}
Component.onCompleted: Qt.callLater(root.handleIdleChanged)
Component.onCompleted: {
logEvent("service-ready")
Qt.callLater(root.handleIdleChanged)
}
IpcHandler {
target: "idle"
@@ -219,6 +314,10 @@ Item {
return root.statusJson()
}
function debug(): string {
return root.statusJson()
}
function enable(): string {
return root.setIdleEnabled(true)
}
+152
View File
@@ -0,0 +1,152 @@
import QtQuick
import QtQuick.Effects
import qs.Commons
Item {
id: root
property string backgroundPath: ""
property int backgroundVersion: 0
property bool fingerprintConfigured: false
property bool authenticatingPassword: false
property string failureMessage: ""
property int failedAttempts: 0
property bool inputEnabled: true
property real scaleFactor: 1
property bool hasTyped: false
readonly property string fingerprintGlyph: "\uDB80\uDE37"
readonly property string placeholderText: fingerprintConfigured ? "Enter Password " + fingerprintGlyph : "Enter Password"
readonly property real effectiveScale: Math.max(1, scaleFactor)
readonly property int fieldWidth: Math.round(650 / effectiveScale)
readonly property int fieldHeight: Math.round(100 / effectiveScale)
readonly property int outlineThickness: Math.max(1, Math.round(4 / effectiveScale))
readonly property int fieldFontSize: Style.font.heading
signal submitPassword(string password)
function withAlpha(color, alpha) {
return Qt.rgba(color.r, color.g, color.b, alpha)
}
function fileUrl(path) {
if (!path) return ""
var encoded = String(path).split("/").map(encodeURIComponent).join("/")
return "file://" + encoded + "?v=" + backgroundVersion
}
function forcePasswordFocus() {
passwordInput.forceActiveFocus()
}
function clearPassword() {
passwordInput.text = ""
hasTyped = false
}
onInputEnabledChanged: {
hasTyped = false
if (inputEnabled) Qt.callLater(forcePasswordFocus)
}
Component.onCompleted: {
hasTyped = false
if (inputEnabled) Qt.callLater(forcePasswordFocus)
}
Rectangle {
anchors.fill: parent
color: Color.background
Image {
id: wallpaper
anchors.fill: parent
source: root.fileUrl(root.backgroundPath)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
sourceSize.width: width
sourceSize.height: height
}
MultiEffect {
anchors.fill: wallpaper
source: wallpaper
blurEnabled: wallpaper.status === Image.Ready
blur: 1.0
blurMax: 64
blurMultiplier: 1.0
}
MouseArea {
anchors.fill: parent
onClicked: root.forcePasswordFocus()
}
Rectangle {
id: inputField
width: root.fieldWidth
height: root.fieldHeight
anchors.centerIn: parent
color: root.withAlpha(Color.background, 0.8)
border.color: root.failureMessage.length > 0 ? Color.urgent : (root.authenticatingPassword ? Color.accent : Color.foreground)
border.width: root.outlineThickness
radius: Style.cornerRadius
clip: true
TextInput {
id: passwordInput
anchors.fill: parent
anchors.leftMargin: root.outlineThickness + 18
anchors.rightMargin: root.outlineThickness + 18
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
activeFocusOnPress: true
clip: true
enabled: root.inputEnabled && !root.authenticatingPassword
readOnly: root.authenticatingPassword
echoMode: TextInput.Password
passwordCharacter: "\u2022"
passwordMaskDelay: 0
color: Color.foreground
selectionColor: root.withAlpha(Color.accent, 0.45)
selectedTextColor: Color.foreground
font.family: "monospace"
font.pixelSize: root.fieldFontSize
cursorVisible: activeFocus && !root.authenticatingPassword && root.hasTyped
onTextChanged: {
if (text.length > 0) root.hasTyped = true
if (text.length > 0 && root.failureMessage.length > 0) root.failureMessage = ""
}
onAccepted: {
var submitted = text
text = ""
root.hasTyped = false
if (submitted.length > 0) root.submitPassword(submitted)
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape || (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_U)) {
text = ""
root.hasTyped = false
event.accepted = true
}
}
}
Text {
anchors.fill: passwordInput
text: root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText
visible: passwordInput.text.length === 0
color: root.failureMessage.length > 0 ? Color.urgent : Color.foreground
font.family: "monospace"
font.pixelSize: root.fieldFontSize
font.italic: root.failureMessage.length > 0
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
+318
View File
@@ -0,0 +1,318 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pam
import Quickshell.Wayland
import qs.Commons
Item {
id: root
property var shell: null
property string omarchyPath: ""
readonly property string home: Quickshell.env("HOME")
readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME")
readonly property string currentBackgroundLink: home + "/.config/omarchy/current/background"
property bool lockRequested: false
property bool authenticatingPassword: false
property bool fingerprintAuthenticating: false
property bool passwordPamConfigured: false
property bool fingerprintConfigured: false
property bool previewVisible: false
property string pendingPassword: ""
property string failureMessage: ""
property int failedAttempts: 0
property string backgroundPath: ""
property int backgroundVersion: 0
readonly property bool locked: lockRequested || sessionLock.locked || sessionLock.secure
readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating
function refreshBackground() {
if (!readlinkProc.running) readlinkProc.running = true
}
function refreshFingerprintStatus() {
if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true
}
function resetAuthenticationState() {
pendingPassword = ""
failureMessage = ""
failedAttempts = 0
authenticatingPassword = false
fingerprintAuthenticating = false
fingerprintRetryTimer.stop()
if (passwordPam.active) passwordPam.abort()
if (fingerprintPam.active) fingerprintPam.abort()
}
function beginLock() {
if (!passwordPamConfigured) return false
resetAuthenticationState()
refreshBackground()
refreshFingerprintStatus()
lockRequested = true
sessionLock.locked = true
return true
}
function finishUnlock() {
if (!root.locked && !lockRequested) return
lockRequested = false
resetAuthenticationState()
sessionLock.locked = false
runWake()
}
function runWake() {
if (!wakeProcess.running) wakeProcess.running = true
}
function submitPassword(value) {
var password = String(value || "")
if (!lockRequested || authenticatingPassword || password.length === 0) return
pendingPassword = password
failureMessage = ""
authenticatingPassword = true
if (!passwordPam.start()) {
handlePasswordFailure()
return
}
Qt.callLater(respondToPasswordPrompt)
}
function respondToPasswordPrompt() {
if (!authenticatingPassword || !passwordPam.active || !passwordPam.responseRequired) return
passwordPam.respond(pendingPassword)
}
function handlePasswordFailure() {
if (!lockRequested) return
authenticatingPassword = false
pendingPassword = ""
failedAttempts += 1
failureMessage = "Authentication failed (" + failedAttempts + ")"
}
function startFingerprint() {
if (!lockRequested || !sessionLock.secure || !fingerprintConfigured) return
if (fingerprintPam.active || fingerprintAuthenticating) return
fingerprintAuthenticating = true
if (!fingerprintPam.start()) {
fingerprintAuthenticating = false
}
}
function handleFingerprintFinished(result) {
fingerprintAuthenticating = false
if (!lockRequested) return
if (result === PamResult.Success) {
finishUnlock()
} else if (fingerprintConfigured) {
fingerprintRetryTimer.restart()
}
}
WlSessionLock {
id: sessionLock
locked: false
onSecureStateChanged: {
if (secure) root.startFingerprint()
}
onLockStateChanged: {
if (!locked && root.lockRequested) {
root.lockRequested = false
root.resetAuthenticationState()
root.runWake()
}
}
WlSessionLockSurface {
id: lockSurface
color: Color.background
LockView {
id: lockView
anchors.fill: parent
backgroundPath: root.backgroundPath
backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured
authenticatingPassword: root.authenticatingPassword
failureMessage: root.failureMessage
failedAttempts: root.failedAttempts
inputEnabled: root.lockRequested
scaleFactor: lockSurface.screen && lockSurface.screen.devicePixelRatio > 0 ? lockSurface.screen.devicePixelRatio : 1
onSubmitPassword: function(password) { root.submitPassword(password) }
}
}
}
PanelWindow {
id: previewWindow
visible: root.previewVisible
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-lock-preview"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
LockView {
anchors.fill: parent
backgroundPath: root.backgroundPath
backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured
authenticatingPassword: false
failureMessage: ""
failedAttempts: 0
inputEnabled: false
scaleFactor: previewWindow.screen && previewWindow.screen.devicePixelRatio > 0 ? previewWindow.screen.devicePixelRatio : 1
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: root.previewVisible = false
}
}
PamContext {
id: passwordPam
config: "omarchy-lock-password"
user: root.userName
onResponseRequiredChanged: root.respondToPasswordPrompt()
onPamMessage: root.respondToPasswordPrompt()
onCompleted: function(result) {
root.authenticatingPassword = false
root.pendingPassword = ""
if (!root.lockRequested) return
if (result === PamResult.Success) root.finishUnlock()
else root.handlePasswordFailure()
}
onError: function(error) {
root.handlePasswordFailure()
}
}
PamContext {
id: fingerprintPam
config: "omarchy-lock-fingerprint"
user: root.userName
onCompleted: function(result) {
root.handleFingerprintFinished(result)
}
onError: function(error) {
root.fingerprintAuthenticating = false
if (root.lockRequested && root.fingerprintConfigured) fingerprintRetryTimer.restart()
}
}
Timer {
id: fingerprintRetryTimer
interval: 250
repeat: false
onTriggered: root.startFingerprint()
}
Process {
id: readlinkProc
command: ["readlink", "-f", root.currentBackgroundLink]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var next = String(text || "").trim()
if (next !== root.backgroundPath) {
root.backgroundPath = next
root.backgroundVersion += 1
}
}
}
}
Process {
id: fingerprintCheckProc
command: ["bash", "-lc", "if [[ -f /etc/pam.d/omarchy-lock-fingerprint ]] && command -v fprintd-list >/dev/null 2>&1 && fprintd-list \"$USER\" 2>/dev/null | grep -qi finger; then echo yes; else echo no; fi"]
stdout: StdioCollector { id: fingerprintCheckStdout; waitForEnd: true }
onExited: {
root.fingerprintConfigured = String(fingerprintCheckStdout.text || "").trim() === "yes"
if (root.lockRequested && root.fingerprintConfigured) root.startFingerprint()
else if (!root.fingerprintConfigured && fingerprintPam.active) fingerprintPam.abort()
}
}
Process {
id: wakeProcess
command: ["bash", "-lc", "omarchy-system-wake"]
}
FileView {
path: "/etc/pam.d/omarchy-lock-password"
watchChanges: true
printErrors: false
onLoaded: root.passwordPamConfigured = true
onLoadFailed: root.passwordPamConfigured = false
onFileChanged: reload()
}
Component.onCompleted: {
refreshBackground()
refreshFingerprintStatus()
}
IpcHandler {
target: "lock"
function lock(): string {
if (!root.passwordPamConfigured) return "missing-pam"
if (!root.locked && !root.beginLock()) return "failed"
return "ok"
}
function isLocked(): string {
return root.locked ? "true" : "false"
}
function status(): string {
return JSON.stringify({
locked: root.locked,
secure: sessionLock.secure,
passwordPam: root.passwordPamConfigured,
fingerprint: root.fingerprintConfigured,
authenticating: root.authenticating
})
}
function preview(): string {
root.refreshBackground()
root.refreshFingerprintStatus()
root.previewVisible = true
return "ok"
}
function hidePreview(): string {
root.previewVisible = false
return "ok"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "omarchy.lock",
"name": "Lock Screen",
"version": "1.0.0",
"author": "Omarchy",
"description": "Quickshell session lock with separate password and fingerprint PAM flows.",
"kinds": [
"service"
],
"keepLoaded": true,
"entryPoints": {
"service": "Service.qml"
}
}