Files
omarchy/shell/plugins/lock/Service.qml
T
David Heinemeier HanssonandClaude Opus 5.5 d1a614ce66 Decide lock requests from the current lock state (#14978)
The locked binding once read true after an unlock, though the request,
the session lock, and its secure state were all false. A lock before
suspend then took the session for locked and did nothing, and the
machine slept unlocked. Lock requests, the status reply, and stranded
lock recovery now read those three values directly.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:37:45 +02:00

881 lines
28 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pam
import Quickshell.Wayland
import qs.Commons
import qs.Commons as Commons
import "FingerprintModel.js" as FingerprintModel
Item {
id: root
property var shell: null
property string omarchyPath: ""
readonly property string home: Quickshell.env("HOME")
readonly property string stateHome: home + "/.local/state"
readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME")
readonly property string currentBackgroundLink: stateHome + "/omarchy/current/background"
property bool lockRequested: false
property bool pendingSessionLock: false
property bool authenticatingPassword: false
property bool fingerprintAuthenticating: false
property bool passwordPamConfigured: false
property bool fingerprintConfigured: false
property int fingerprintUnreachedStreak: 0
property bool fingerprintAttemptReachedDevice: false
property bool fingerprintAttemptFastError: false
property double fingerprintAttemptPromptedAtMs: 0
property double fingerprintLastNudgeMs: 0
property double fingerprintLastSettleMs: 0
property double fingerprintResumedAtMs: 0
property int fingerprintProbeStreak: 0
property bool previewVisible: false
property string enteredPassword: ""
property string pendingPassword: ""
property string failureMessage: ""
property int failedAttempts: 0
property string backgroundPath: ""
property string videoPosterPath: ""
property int backgroundVersion: 0
// The wallpaper file's mtime and size. The lock caches its wallpaper by
// version, so a file overwritten in place must bump the version too.
property string backgroundSignature: ""
property string lastEvent: "init"
property string lastEventAt: ""
property bool displaysBlank: false
// displaysBlank tracks what the lock asked for; Hyprland reports what each
// panel actually did. While a video is on show the two are reconciled, so a
// blank that failed keeps playing and a panel woken behind the lock's back
// (a resume that kept the same outputs) resumes instead of freezing.
property var monitorDpms: ({})
property bool monitorDpmsKnown: false
readonly property bool videoBackground: Util.isVideoPath(backgroundPath)
property bool strandedLock: false
property bool strandedLockResolved: false
readonly property bool locked: lockRequested || sessionLock.locked || sessionLock.secure
// Decisions read the lock state afresh. A property binding that missed a change
// notification once reported locked after an unlock, and a lock request before
// suspend then took that for done and never locked the session.
function currentlyLocked() {
return lockRequested || sessionLock.locked || sessionLock.secure
}
readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating
readonly property var batteryService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.battery") : null
readonly property bool powerSaverActive: batteryService ? batteryService.powerSaverOnBattery : false
// A prompt clears the unavailable notice before the attempt finishes.
readonly property bool fingerprintUnavailable: FingerprintModel.isUnavailable(fingerprintProbeStreak) || (fingerprintConfigured && (!fingerprintAttemptReachedDevice || fingerprintAttemptFastError) && FingerprintModel.isUnavailable(fingerprintUnreachedStreak))
function realScreenCount() {
var screens = Quickshell.screens || []
var count = 0
for (var i = 0; i < screens.length; i++) {
var screen = screens[i]
if (screen && screen.name && screen.width > 0 && screen.height > 0) count += 1
}
return count
}
function hasRealScreen() {
return realScreenCount() > 0
}
function queueSessionLock() {
pendingSessionLock = true
if (!sessionLockStabilizeTimer.running) logEvent("lock-pending: screen-stabilizing")
sessionLockStabilizeTimer.restart()
if (!pendingSessionLockTimer.running) pendingSessionLockTimer.start()
}
function requestSessionLock() {
if (!lockRequested || sessionLock.locked || sessionLock.secure) return
if (sessionLockStabilizeTimer.running) return
if (!hasRealScreen()) {
if (!pendingSessionLock || lastEvent !== "lock-pending: no-real-screen") logEvent("lock-pending: no-real-screen")
pendingSessionLock = true
if (!pendingSessionLockTimer.running) pendingSessionLockTimer.start()
return
}
pendingSessionLock = false
pendingSessionLockTimer.stop()
sessionLock.locked = true
}
// ext-session-lock outlives its client, and a restart carries no lock over, so
// a session locked this early is an orphan behind Hyprland's failsafe. Outputs
// are often still absent here, so ask until the answer means something.
function checkStrandedLock() {
if (strandedLockResolved || strandedLockCheckProc.running) return
// A lock this shell took is nobody's orphan.
if (currentlyLocked()) {
strandedLockResolved = true
return
}
strandedLockCheckProc.running = true
}
function recoverStrandedLock() {
if (!strandedLock || currentlyLocked() || !passwordPamConfigured) return
strandedLock = false
logEvent("lock-stranded: recovering")
beginLock()
}
function refreshBackground() {
if (!readlinkProc.running) readlinkProc.running = true
}
function refreshPoster() {
if (!root.videoBackground) {
root.videoPosterPath = ""
return
}
if (posterProc.running) return
posterProc.sourcePath = root.backgroundPath
posterProc.running = true
}
function refreshFingerprintStatus() {
if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true
}
// Only definitive enrollment results may disable authentication.
function applyFingerprintProbe(text) {
var status = FingerprintModel.classifyProbe(text)
if (status === "unknown") {
if (fingerprintConfigured) return
fingerprintProbeStreak += 1
if (lockRequested) {
fingerprintRecheckTimer.interval = FingerprintModel.retryDelayMs(fingerprintProbeStreak)
fingerprintRecheckTimer.restart()
}
return
}
fingerprintProbeStreak = 0
fingerprintRecheckTimer.stop()
fingerprintConfigured = status === "yes"
if (lockRequested && fingerprintConfigured) {
// A pending retry already owns the next attempt.
if (!fingerprintRetryTimer.running) startFingerprint()
} else if (!fingerprintConfigured) {
// abort() delivers no completion signal, so close the attempt here
// too; settle returns before arming a retry while unconfigured.
if (fingerprintPam.active) fingerprintPam.abort()
settleFingerprintAttempt()
fingerprintRetryTimer.stop()
}
}
function logEvent(event) {
lastEvent = event
lastEventAt = new Date().toISOString()
console.log("omarchy lock " + lastEventAt + " " + event)
}
function resetAuthenticationState() {
enteredPassword = ""
pendingPassword = ""
failureMessage = ""
failedAttempts = 0
authenticatingPassword = false
fingerprintAuthenticating = false
fingerprintUnreachedStreak = 0
fingerprintAttemptFastError = false
fingerprintAttemptPromptedAtMs = 0
fingerprintLastNudgeMs = 0
fingerprintLastSettleMs = 0
fingerprintResumedAtMs = 0
fingerprintProbeStreak = 0
fingerprintRecheckTimer.stop()
fingerprintRetryTimer.stop()
fingerprintReachTimer.stop()
if (passwordPam.active) passwordPam.abort()
if (fingerprintPam.active) fingerprintPam.abort()
}
function beginLock() {
if (!passwordPamConfigured) {
logEvent("lock-denied: missing-pam")
return false
}
resetAuthenticationState()
lockRequested = true
armBlankTimer()
logEvent("lock-requested")
queueSessionLock()
Qt.callLater(function() {
root.refreshBackground()
root.refreshFingerprintStatus()
})
return true
}
function finishUnlock() {
if (!root.currentlyLocked()) return
lockRequested = false
pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
resetAuthenticationState()
idleBlankTimer.stop()
sessionLock.locked = false
logEvent("unlocked")
runWake()
}
function armBlankTimer() {
idleBlankTimer.armedAt = Date.now()
idleBlankTimer.restart()
}
function runWake() {
root.displaysBlank = false
root.monitorDpmsKnown = false
if (!wakeProcess.running) wakeProcess.running = true
if (lockRequested) armBlankTimer()
nudgeFingerprint()
}
// User activity advances retries without recreating a busy retry loop.
function nudgeFingerprint() {
if (!lockRequested) return
if (!fingerprintConfigured && fingerprintRecheckTimer.running) {
var now = Date.now()
if (FingerprintModel.shouldNudge(now, fingerprintLastNudgeMs, now - FingerprintModel.IDLE_CLEAR_MS, fingerprintRecheckTimer.interval)) {
fingerprintLastNudgeMs = now
fingerprintRecheckTimer.interval = FingerprintModel.MATCH_RETRY_MS
fingerprintRecheckTimer.restart()
}
return
}
if (!fingerprintConfigured) return
if (fingerprintPam.active || fingerprintAuthenticating) return
if (!fingerprintRetryTimer.running) return
var now = Date.now()
if (!FingerprintModel.shouldNudge(now, fingerprintLastNudgeMs, fingerprintLastSettleMs, fingerprintRetryTimer.interval)) return
fingerprintLastNudgeMs = now
armFingerprintRetry(FingerprintModel.MATCH_RETRY_MS)
}
function armFingerprintRetry(delayMs) {
fingerprintRetryTimer.interval = delayMs
fingerprintRetryTimer.restart()
}
// Reset pre-sleep failures while the resume hook restarts fprintd.
function noteFingerprintResumed() {
var now = Date.now()
if (FingerprintModel.inResumeGrace(now, fingerprintResumedAtMs)) return
logEvent("fingerprint-resume: streak=" + fingerprintUnreachedStreak)
fingerprintResumedAtMs = now
fingerprintUnreachedStreak = 0
}
// A suspended PAM conversation may be orphaned by the daemon restart.
function restartFingerprintAfterSleep() {
noteFingerprintResumed()
if (fingerprintAuthenticating || fingerprintPam.active) {
if (fingerprintPam.active) fingerprintPam.abort()
settleFingerprintAttempt()
return
}
if (!fingerprintRetryTimer.running) return
armFingerprintRetry(FingerprintModel.MATCH_RETRY_MS)
}
function runBlank() {
root.displaysBlank = true
root.monitorDpmsKnown = false
if (!blankProcess.running) blankProcess.running = true
}
function screenBlank(screenName) {
var name = String(screenName || "")
if (!monitorDpmsKnown || !(name in monitorDpms)) return displaysBlank
return !monitorDpms[name]
}
function applyMonitorDpms(text) {
var monitors
try {
monitors = JSON.parse(String(text || ""))
} catch (error) {
return
}
if (!Array.isArray(monitors)) return
var dpms = {}
for (var i = 0; i < monitors.length; i++) {
var monitor = monitors[i]
if (monitor && monitor.name && !monitor.disabled) dpms[String(monitor.name)] = !!monitor.dpmsStatus
}
monitorDpms = dpms
monitorDpmsKnown = true
}
function submitPassword(value) {
var password = String(value || "")
if (!lockRequested || authenticatingPassword || password.length === 0) return
runWake()
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
enteredPassword = ""
pendingPassword = ""
failedAttempts += 1
failureMessage = "Authentication failed (" + failedAttempts + ")"
runWake()
}
function startFingerprint() {
if (!lockRequested || !sessionLock.secure || !fingerprintConfigured) return
if (fingerprintPam.active || fingerprintAuthenticating) return
fingerprintAuthenticating = true
fingerprintAttemptReachedDevice = false
fingerprintAttemptFastError = false
fingerprintAttemptPromptedAtMs = 0
if (!fingerprintPam.start()) {
// Pace a failed start while checking whether its PAM configuration was
// removed; a definitive "no" stops retries and hides the indicator.
settleFingerprintAttempt()
refreshFingerprintStatus()
return
}
// Bound claims that never prompt; a normal verify waits for a finger
// under pam_fprintd's own timeout after reaching the reader.
fingerprintReachTimer.restart()
}
// A prompt proves the claim landed, so stop waiting for reachability.
function noteFingerprintReachedDevice() {
if (fingerprintAttemptReachedDevice) return
fingerprintAttemptReachedDevice = true
fingerprintAttemptPromptedAtMs = Date.now()
fingerprintReachTimer.stop()
}
// abort() gives no completion signal; settle the attempt here.
function timeoutFingerprintReach() {
logEvent("fingerprint-reach-timeout")
if (fingerprintPam.active) fingerprintPam.abort()
settleFingerprintAttempt()
}
// onError and onCompleted can both fire; settle each attempt once.
function settleFingerprintAttempt(deviceError) {
if (!fingerprintAuthenticating) return
fingerprintAuthenticating = false
fingerprintReachTimer.stop()
if (!lockRequested || !fingerprintConfigured) return
// An error can arrive before the sleep watcher notices the wall-clock gap.
var now = Date.now()
fingerprintAttemptFastError = !!deviceError && fingerprintAttemptReachedDevice && now - fingerprintAttemptPromptedAtMs < FingerprintModel.FAST_ERROR_MS
var usableAttempt = fingerprintAttemptReachedDevice && !fingerprintAttemptFastError
if (!usableAttempt && fingerprintSleepWatch.running
&& FingerprintModel.spannedSleep(now - fingerprintSleepWatch.lastTickMs, fingerprintSleepWatch.interval)) {
noteFingerprintResumed()
}
// Reached attempts are the steady state (one per swipe window), so only
// the misses and the recovery from them leave a trace.
var previousStreak = fingerprintUnreachedStreak
var inGrace = FingerprintModel.inResumeGrace(now, fingerprintResumedAtMs)
fingerprintUnreachedStreak = FingerprintModel.nextStreak(previousStreak, usableAttempt, inGrace)
if (!usableAttempt) {
var crossed = !FingerprintModel.isUnavailable(previousStreak) && FingerprintModel.isUnavailable(fingerprintUnreachedStreak)
logEvent((crossed ? "fingerprint-unavailable" : "fingerprint-unreached") + ": streak=" + fingerprintUnreachedStreak)
} else if (previousStreak > 0) {
logEvent("fingerprint-recovered: streak=" + previousStreak)
}
fingerprintLastSettleMs = now
armFingerprintRetry(FingerprintModel.retryDelayMs(fingerprintUnreachedStreak))
}
function handleFingerprintFinished(result) {
if (result === PamResult.Success && lockRequested) {
// A match after a run of misses is the recovery too; the unlock resets
// the streak without settling, so log it here or it leaves no trace.
if (fingerprintUnreachedStreak > 0) logEvent("fingerprint-recovered: streak=" + fingerprintUnreachedStreak)
finishUnlock()
} else {
settleFingerprintAttempt(result === PamResult.Error)
}
}
WlSessionLock {
id: sessionLock
locked: false
onSecureStateChanged: {
root.logEvent("secure=" + secure)
if (secure) {
root.pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
root.startFingerprint()
}
}
onLockStateChanged: {
root.logEvent("session-locked=" + locked)
if (locked) {
root.pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
}
if (!locked && root.lockRequested) {
root.lockRequested = false
root.pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
root.resetAuthenticationState()
root.runWake()
}
}
WlSessionLockSurface {
id: lockSurface
color: Commons.Color.background
LockView {
id: lockView
anchors.fill: parent
backgroundPath: root.backgroundPath
videoPosterPath: root.videoPosterPath
backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured || root.fingerprintUnavailable
fingerprintUnavailable: root.fingerprintUnavailable
authenticatingPassword: root.authenticatingPassword
failureMessage: root.failureMessage
failedAttempts: root.failedAttempts
inputEnabled: root.lockRequested
loadBackground: root.locked
displaysBlank: root.screenBlank(lockSurface.screen ? lockSurface.screen.name : "")
powerSaverActive: root.powerSaverActive
passwordText: root.enteredPassword
onPasswordTextEdited: function(password) { root.enteredPassword = password }
onSubmitPassword: function(password) { root.submitPassword(password) }
onClearFailureRequested: root.failureMessage = ""
onWakeRequested: root.runWake()
}
}
}
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
videoPosterPath: root.videoPosterPath
backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured || root.fingerprintUnavailable
fingerprintUnavailable: root.fingerprintUnavailable
authenticatingPassword: false
failureMessage: ""
failedAttempts: 0
inputEnabled: false
loadBackground: root.previewVisible
powerSaverActive: root.powerSaverActive
passwordText: ""
}
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
onPamMessage: {
if (!messageIsError) root.noteFingerprintReachedDevice()
}
onCompleted: function(result) {
root.handleFingerprintFinished(result)
}
onError: function(error) {
root.settleFingerprintAttempt(true)
}
}
// The lock only starts decoding its wallpaper once locked, and a machine
// suspending right after locking froze that decode partway: waking showed
// the password field on a bare background, then the wallpaper popped in.
// Keep each screen's lock wallpaper decoded in the image cache ahead of
// time, as the lock view requests it (same URL, the screen's logical size,
// PreserveAspectCrop), so the lock draws it on its first frame.
readonly property string lockWallpaperPath: videoBackground ? videoPosterPath : backgroundPath
readonly property string lockWallpaperUrl: lockWallpaperPath && !Util.isVideoPath(lockWallpaperPath)
? Util.fileUrl(lockWallpaperPath) + (backgroundVersion ? "?v=" + backgroundVersion : "")
: ""
Variants {
model: Quickshell.screens
Image {
required property var modelData
visible: false
source: root.lockWallpaperUrl
sourceSize.width: modelData.width
sourceSize.height: modelData.height
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
}
}
Timer {
id: fingerprintRetryTimer
interval: FingerprintModel.MATCH_RETRY_MS
repeat: false
onTriggered: root.startFingerprint()
}
// Detect resume both during an active attempt and during backoff.
Timer {
id: fingerprintSleepWatch
interval: 1000
repeat: true
running: root.lockRequested && root.fingerprintConfigured
property double lastTickMs: 0
onRunningChanged: lastTickMs = Date.now()
onTriggered: {
var now = Date.now()
var slept = FingerprintModel.spannedSleep(now - lastTickMs, interval)
lastTickMs = now
if (slept) root.restartFingerprintAfterSleep()
}
}
Timer {
id: fingerprintReachTimer
interval: FingerprintModel.REACH_TIMEOUT_MS
repeat: false
onTriggered: root.timeoutFingerprintReach()
}
Process {
id: readlinkProc
command: ["bash", "-c", "path=$(readlink -f -- \"$1\") && printf '%s\\n%s\\n' \"$path\" \"$(stat -Lc %Y:%s -- \"$path\" 2>/dev/null)\"", "_", root.currentBackgroundLink]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var lines = String(text || "").split("\n")
var next = String(lines[0] || "").trim()
var signature = String(lines[1] || "").trim()
if (next !== root.backgroundPath) {
root.videoPosterPath = ""
root.backgroundPath = next
root.backgroundSignature = signature
root.backgroundVersion += 1
} else if (signature !== root.backgroundSignature) {
root.backgroundSignature = signature
root.backgroundVersion += 1
}
root.refreshPoster()
}
}
}
Process {
id: posterProc
property string sourcePath: ""
command: ["bash", Quickshell.env("OMARCHY_PATH") + "/shell/plugins/lock/poster.sh", sourcePath]
stdout: StdioCollector { id: posterOutput; waitForEnd: true }
onExited: function(exitCode) {
if (sourcePath !== root.backgroundPath) {
root.refreshPoster()
} else {
root.videoPosterPath = exitCode === 0 ? String(posterOutput.text || "").trim() : ""
}
}
}
// Keep fprintd errors distinguishable from an explicit empty enrollment.
Process {
id: fingerprintCheckProc
command: ["bash", "-c", "if [[ -f /etc/pam.d/omarchy-lock-fingerprint ]] && command -v fprintd-list >/dev/null 2>&1; then LC_ALL=C fprintd-list \"$USER\" 2>&1; else echo no; fi"]
stdout: StdioCollector { id: fingerprintCheckStdout; waitForEnd: true }
onExited: root.applyFingerprintProbe(fingerprintCheckStdout.text)
}
// Retries a probe that could not reach fprintd, paced like the attempt
// retries so a daemon that stays unreachable is asked about ever less often.
Timer {
id: fingerprintRecheckTimer
interval: FingerprintModel.ERROR_RETRY_BASE_MS
repeat: false
onTriggered: root.refreshFingerprintStatus()
}
Process {
id: strandedLockCheckProc
command: ["bash", "-c", "omarchy-hyprland-session-locked"]
onExited: function(exitCode) {
// No output to read the lock off yet.
if (exitCode === 2) return
root.strandedLockResolved = true
// A lock taken while this was in flight is this shell's own.
root.strandedLock = exitCode === 0 && !root.currentlyLocked()
root.recoverStrandedLock()
}
}
Process {
id: wakeProcess
command: ["bash", "-c", "omarchy-system-wake"]
}
Process {
id: blankProcess
command: ["bash", "-c", "omarchy-brightness-keyboard off; omarchy-brightness-display off"]
}
// Quickshell exposes no DPMS signal, so the panel state is polled while a
// video is the locked wallpaper. A wake or blank request drops the last
// answer, so its optimistic state applies until the next poll confirms it.
Process {
id: monitorDpmsProcess
command: ["hyprctl", "monitors", "-j"]
stdout: StdioCollector {
onStreamFinished: root.applyMonitorDpms(text)
}
}
Timer {
id: monitorDpmsTimer
interval: 3000
repeat: true
triggeredOnStart: true
running: root.locked && root.videoBackground
onTriggered: {
if (!monitorDpmsProcess.running) monitorDpmsProcess.running = true
}
onRunningChanged: {
if (!running) root.monitorDpmsKnown = false
}
}
Timer {
id: idleBlankTimer
interval: 5000
repeat: false
property double armedAt: 0
onTriggered: {
// A countdown frozen by suspend fires right after resume, which would
// blank the freshly woken unlock screen under the user. Wall-clock time
// exposes the gap: take a fresh run-up instead of blanking.
if (Date.now() - armedAt > interval + 2000) {
root.armBlankTimer()
return
}
// Only a password check in flight should hold the display up. The
// fingerprint PAM stays armed for the whole lock, so gating on
// `authenticating` here would keep the panel lit until unlock.
if (root.lockRequested && !root.authenticatingPassword) root.runBlank()
}
}
Timer {
id: sessionLockStabilizeTimer
interval: 500
repeat: false
onTriggered: root.requestSessionLock()
}
Timer {
id: pendingSessionLockTimer
interval: 100
repeat: true
onTriggered: root.requestSessionLock()
}
Timer {
id: strandedLockRetryTimer
interval: 500
repeat: true
// Covers the compositor settling; screens coming back re-arm it.
readonly property int budget: 20
property int remaining: 20
running: !root.strandedLockResolved && remaining > 0
function rearm() {
if (!root.strandedLockResolved) remaining = budget
}
onTriggered: {
remaining -= 1
root.checkStrandedLock()
}
}
Connections {
target: Quickshell
function onScreensChanged() {
// A panel coming back is a display turning on that runWake did not ask
// for, so the blank state has to be given up here or a visible lock
// wallpaper stays frozen until the next keypress.
root.displaysBlank = false
root.requestSessionLock()
// A monitor still coming up has no workspace, so cannot answer yet.
strandedLockRetryTimer.rearm()
root.checkStrandedLock()
}
}
onAuthenticatingPasswordChanged: {
if (!lockRequested) return
if (authenticatingPassword) idleBlankTimer.stop()
else armBlankTimer()
}
FileView {
path: "/etc/pam.d/omarchy-lock-password"
watchChanges: true
printErrors: false
onLoaded: root.passwordPamConfigured = true
onLoadFailed: root.passwordPamConfigured = false
onFileChanged: reload()
}
// No lock before PAM is known good. An answer from before then may be stale --
// the failsafe can be cleared from a TTY -- so re-ask rather than act on it.
onPasswordPamConfiguredChanged: {
if (!passwordPamConfigured) return
strandedLock = false
strandedLockResolved = false
strandedLockRetryTimer.rearm()
checkStrandedLock()
}
Component.onCompleted: {
refreshBackground()
refreshFingerprintStatus()
checkStrandedLock()
}
ShellIpc {
target: "lock"
function lock(): string {
if (!root.passwordPamConfigured) return "missing-pam"
if (!root.currentlyLocked() && !root.beginLock()) return "failed"
return "ok"
}
function isLocked(): string {
return root.currentlyLocked() ? "true" : "false"
}
function status(): string {
return JSON.stringify({
locked: root.currentlyLocked(),
requested: root.lockRequested,
pending: root.pendingSessionLock,
sessionLocked: sessionLock.locked,
secure: sessionLock.secure,
realScreens: root.realScreenCount(),
passwordPam: root.passwordPamConfigured,
fingerprint: root.fingerprintConfigured,
fingerprintUnavailable: root.fingerprintUnavailable,
authenticating: root.authenticating,
lastEvent: root.lastEvent,
lastEventAt: root.lastEventAt
})
}
function preview(): string {
root.refreshBackground()
root.refreshFingerprintStatus()
root.previewVisible = true
return "ok"
}
function hidePreview(): string {
root.previewVisible = false
return "ok"
}
}
}