Files
omarchy/shell/plugins/lock/Service.qml
T

874 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
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 (locked || lockRequested) {
strandedLockResolved = true
return
}
strandedLockCheckProc.running = true
}
function recoverStrandedLock() {
if (!strandedLock || locked || !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.locked && !lockRequested) 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.locked && !root.lockRequested
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.locked && !root.beginLock()) return "failed"
return "ok"
}
function isLocked(): string {
return root.locked ? "true" : "false"
}
function status(): string {
return JSON.stringify({
locked: root.locked,
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"
}
}
}