874 lines
28 KiB
QML
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"
|
|
}
|
|
}
|
|
}
|