From 96759d68fd06afb17f12e8cc9513564ed4574ed7 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Fri, 21 Aug 2026 14:56:46 -0400 Subject: [PATCH] Show the branding text on the lock screen --- install.sh | 1 + omarchy/README.md | 39 +- omarchy/plugins/blob.lock/LockView.qml | 249 +++++++++++ omarchy/plugins/blob.lock/Service.qml | 567 ++++++++++++++++++++++++ omarchy/plugins/blob.lock/manifest.json | 18 + omarchy/shell.json | 12 +- 6 files changed, 881 insertions(+), 5 deletions(-) create mode 100644 omarchy/plugins/blob.lock/LockView.qml create mode 100644 omarchy/plugins/blob.lock/Service.qml create mode 100644 omarchy/plugins/blob.lock/manifest.json diff --git a/install.sh b/install.sh index da70a9d..f4e06ff 100755 --- a/install.sh +++ b/install.sh @@ -206,6 +206,7 @@ check_file "$SCRIPT_DIR/omarchy/extensions/omarchy-menu.jsonc" "$HOME_DIR/.confi check_file "$SCRIPT_DIR/omarchy/plugins/blob.workspaces/Workspaces.qml" "$HOME_DIR/.config/omarchy/plugins/blob.workspaces/Workspaces.qml" "omarchy/plugins/blob.workspaces" || check_status=1 check_file "$SCRIPT_DIR/omarchy/plugins/blob.menu/Menu.qml" "$HOME_DIR/.config/omarchy/plugins/blob.menu/Menu.qml" "omarchy/plugins/blob.menu" || check_status=1 check_file "$SCRIPT_DIR/omarchy/plugins/blob.bar/Bar.qml" "$HOME_DIR/.config/omarchy/plugins/blob.bar/Bar.qml" "omarchy/plugins/blob.bar" || check_status=1 +check_file "$SCRIPT_DIR/omarchy/plugins/blob.lock/LockView.qml" "$HOME_DIR/.config/omarchy/plugins/blob.lock/LockView.qml" "omarchy/plugins/blob.lock" || check_status=1 check_file "$SCRIPT_DIR/omarchy/hooks/theme-set" "$HOME_DIR/.config/omarchy/hooks/theme-set" "omarchy/hooks/theme-set" || check_status=1 check_file "$SCRIPT_DIR/omarchy/themed/zen.css.tpl" "$HOME_DIR/.config/omarchy/themed/zen.css.tpl" "omarchy/themed/zen.css.tpl" || check_status=1 check_file "$SCRIPT_DIR/ags/app.ts" "$HOME_DIR/.config/ags/app.ts" "ags/app.ts" || check_status=1 diff --git a/omarchy/README.md b/omarchy/README.md index f9bde2b..b849efa 100644 --- a/omarchy/README.md +++ b/omarchy/README.md @@ -43,6 +43,13 @@ gap between them or the lock screen covers the branding as soon as it appears. Screensaver at 300 and lock at 900 leaves ten minutes of branding before the session locks. +Neither timer runs while `~/.local/state/omarchy/indicators/stay-awake` exists. +That file is the Stay Awake toggle, it is runtime state rather than config so +it is not tracked here, and while it is set the idle service cancels every +cycle with `idle-cycle-cancel: stay-awake`. `omarchy-toggle-idle allow-idle` +clears it. The lock screen carries the branding on its own (see +`plugins/blob.lock/` below), so this only affects the screensaver. + ## Custom modules The bar accepts arbitrary ids with `type: "command"`, which is how the two @@ -64,8 +71,8 @@ A command module with no `exec` key is a static icon; add `exec` and `blob.workspaces` and `blob.menu` were made with `omarchy plugin clone`, which copies a built-in plugin, disables the original, and points the bar at the copy -- hence the `blob.` ids in `shell.json`. `blob.bar` was copied by hand; see -below for why. +- hence the `blob.` ids in `shell.json`. `blob.bar` and `blob.lock` were copied +by hand; see below for why. `plugins/blob.workspaces/` clones `omarchy.workspaces`. The stock widget hardcodes workspaces 1-5 as always visible and has no setting for it, so the @@ -87,6 +94,34 @@ ahead of it once both sources are merged - which is how `Blob` sits directly under `Apps` instead of below `System`. A `before:` that names an unknown id is ignored, and a row without one keeps its file order. +`plugins/blob.lock/` clones `omarchy.lock` to put `branding/screensaver.txt` on +the lock screen. Stock `LockView.qml` draws a blurred wallpaper, the password +field, and a fingerprint hint - there is no branding element and no config key +for one, so the only way to get the art behind the password prompt is a fork. +`Service.qml` gains a watched `FileView` on the branding file and passes the +text to both `LockView` instances (the real lock surface and the theme +preview); `LockView.qml` gains one `Text` above the input field. The field +itself keeps the position and size it has upstream. `Text.Fit` scales the art +down to whatever room is left above the field, so a wider or taller +`screensaver.txt` cannot run off the screen, and an unreadable or missing file +leaves the lock unbranded rather than broken. + +Unlike the other clones this one is load-bearing for security. The lock is a +`service` plugin, so it is enabled by its id appearing in `plugins[]` and the +original is switched off through `disabledPlugins[]` - both are needed, because +the two would otherwise register the same `lock` IPC target. There is no +fallback: a QML error means the service never loads and `omarchy-shell lock +lock` silently does nothing, which leaves the machine unlockable rather than +locked open. `journalctl --user -t omarchy-shell` names the fault as +`service plugin load failed for blob.lock`. To back out, drop `blob.lock` from +`plugins[]` and `omarchy.lock` from `disabledPlugins[]`; `cloneSourceRestores` +lists `blob.lock` so the shell restores the original by itself if the clone is +removed through `omarchy plugin remove`. + +Re-copy `LockView.qml` and `Service.qml` from +`/usr/share/omarchy/shell/plugins/lock/` after an Omarchy update that touches +the lock, then re-apply the branding property, the `FileView`, and the `Text`. + `plugins/blob.bar/` replaces the whole bar so the clock cannot be dragged out of the center. Omarchy 4 puts a drag-to-reorder handler on every bar module and persists the drop into `bar.layout`; once `blob.clock` leaves the center list, diff --git a/omarchy/plugins/blob.lock/LockView.qml b/omarchy/plugins/blob.lock/LockView.qml new file mode 100644 index 0000000..dc75e14 --- /dev/null +++ b/omarchy/plugins/blob.lock/LockView.qml @@ -0,0 +1,249 @@ +import QtQuick +import QtQuick.Effects +import qs.Commons +import qs.Ui + +Item { + id: root + + property string backgroundPath: "" + property int backgroundVersion: 0 + property string brandingText: "" + property bool fingerprintConfigured: false + property bool authenticatingPassword: false + property string failureMessage: "" + property int failedAttempts: 0 + property bool inputEnabled: true + property bool loadBackground: true + property string passwordText: "" + property bool syncingPasswordText: false + + readonly property string placeholderText: "Enter Password" + readonly property int fieldWidth: 381 + readonly property int fieldHeight: 67 + readonly property int outlineThickness: 3 + readonly property int fieldFontSize: Math.round(Style.font.heading * 1.125) + readonly property int passwordDotFontSize: Math.round(Style.font.heading * 1.33) + readonly property int passwordDotLetterSpacing: Math.round(Style.font.heading * 0.19) + // Space to keep clear on each side of the field for the fingerprint icon + // (icon width plus a gap) so the centered dots never run under it. + readonly property real fingerprintReserve: fingerprintConfigured ? Math.round(fingerprintIcon.implicitWidth + 12) : 0 + // Shrink the dots to fit once the password outgrows the field, so every + // keystroke stays visible — otherwise long passwords clip with no feedback. + readonly property real passwordDotScale: dotMetrics.advanceWidth > 0 + ? Math.min(1, (passwordInput.width - 4) / dotMetrics.advanceWidth) + : 1 + // Gap between the branding and the password field, and the share of the + // screen the art may claim. Text.Fit needs a bounded width and height to + // scale into, so both are given explicitly below. + readonly property int brandingGap: Style.space(48) + readonly property int brandingMaxWidth: 1100 + readonly property int brandingMaxFontSize: Math.round(Style.font.heading * 1.5) + + readonly property bool showPasswordCursor: inputEnabled && !authenticatingPassword && failureMessage.length === 0 + readonly property bool errorState: failureMessage.length > 0 + readonly property var inputBorderSpec: errorState + ? Border.surfaceSpec("lock", "border-error", Color.lock.borderError, root.outlineThickness, "border-alpha") + : Border.surfaceSpec("lock", "border-active", Color.lock.borderActive, root.outlineThickness, "border-alpha") + + signal submitPassword(string password) + signal passwordTextEdited(string password) + signal clearFailureRequested() + signal wakeRequested() + + // Cache-busts the lock background by appending `?v=`. Adding a query + // string keeps Image's loader happy while forcing it to reload when the + // user picks a new background mid-session. + 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() { + passwordTextEdited("") + } + + function syncPasswordText() { + if (passwordInput.text === passwordText) return + syncingPasswordText = true + passwordInput.text = passwordText + syncingPasswordText = false + } + + onPasswordTextChanged: syncPasswordText() + onInputEnabledChanged: { + if (inputEnabled) Qt.callLater(forcePasswordFocus) + } + Component.onCompleted: { + syncPasswordText() + if (inputEnabled) Qt.callLater(forcePasswordFocus) + } + + // Measures the masked password at full size; passwordDotScale compares this + // against the field width to decide how far the dots must shrink to fit. + TextMetrics { + id: dotMetrics + font.family: Style.font.family + font.pixelSize: root.passwordDotFontSize + font.letterSpacing: root.passwordDotLetterSpacing + text: "●".repeat(passwordInput.text.length) + } + + Rectangle { + anchors.fill: parent + color: Color.background + + Image { + id: wallpaper + anchors.fill: parent + source: root.loadBackground ? root.fileUrl(root.backgroundPath) : "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + sourceSize.width: width + sourceSize.height: height + } + + MultiEffect { + anchors.fill: wallpaper + source: wallpaper + autoPaddingEnabled: false + blurEnabled: root.loadBackground && wallpaper.status === Image.Ready + blur: 1.0 + blurMax: 128 + blurMultiplier: 1.25 + contrast: -0.08 + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: { root.wakeRequested(); root.forcePasswordFocus() } + onPositionChanged: root.wakeRequested() + } + + // Sits above the password field rather than replacing anything, so the + // input keeps the position and size it has upstream. Text.Fit shrinks the + // art to whatever room is left above the field, which keeps a tall or wide + // screensaver.txt from running off the top of a small screen. + Text { + id: branding + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: inputField.top + anchors.bottomMargin: root.brandingGap + width: Math.min(parent.width * 0.86, root.brandingMaxWidth) + height: Math.max(0, inputField.y - root.brandingGap * 2) + visible: root.brandingText.length > 0 && height > 0 + text: root.brandingText + textFormat: Text.PlainText + color: Color.lock.text + font.family: Style.font.family + font.pixelSize: root.brandingMaxFontSize + minimumPixelSize: 4 + fontSizeMode: Text.Fit + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignBottom + } + + BorderSurface { + id: inputField + width: root.fieldWidth + height: root.fieldHeight + anchors.centerIn: parent + color: Color.lock.background + borderSpec: root.inputBorderSpec + radius: Style.cornerRadius + clip: true + + TextInput { + id: passwordInput + anchors.fill: parent + anchors.topMargin: inputField.borderTop + // Reserve the fingerprint icon's width on both sides so the centered + // dots stay symmetric and never slide under the icon as they grow. + anchors.rightMargin: inputField.borderRight + 18 + root.fingerprintReserve + anchors.bottomMargin: inputField.borderBottom + anchors.leftMargin: inputField.borderLeft + 18 + root.fingerprintReserve + verticalAlignment: TextInput.AlignVCenter + horizontalAlignment: TextInput.AlignHCenter + activeFocusOnPress: true + clip: true + enabled: root.inputEnabled && !root.authenticatingPassword + readOnly: root.authenticatingPassword + echoMode: TextInput.Password + passwordCharacter: "\u25CF" + passwordMaskDelay: 0 + color: Color.lock.text + selectionColor: Color.lock.selection + selectedTextColor: Color.lock.text + font.family: Style.font.family + font.pixelSize: text.length > 0 ? Math.max(1, Math.floor(root.passwordDotFontSize * root.passwordDotScale)) : root.fieldFontSize + font.letterSpacing: text.length > 0 ? root.passwordDotLetterSpacing * root.passwordDotScale : 0 + cursorVisible: activeFocus && root.showPasswordCursor && text.length > 0 + cursorDelegate: Rectangle { + width: 2 + color: Color.lock.text + visible: passwordInput.cursorVisible + } + + onTextChanged: { + if (!root.syncingPasswordText) root.passwordTextEdited(text) + if (text.length > 0) { + root.wakeRequested() + } + if (text.length > 0 && root.failureMessage.length > 0) root.clearFailureRequested() + } + + onAccepted: { + var submitted = root.passwordText + root.passwordTextEdited("") + if (submitted.length > 0) root.submitPassword(submitted) + } + + Keys.onPressed: function(event) { + root.wakeRequested() + if (event.key === Qt.Key_Escape || (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_U)) { + root.passwordTextEdited("") + event.accepted = true + } + } + } + + Text { + anchors.fill: passwordInput + text: root.authenticatingPassword ? "Checking…" : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText) + visible: passwordInput.text.length === 0 + color: root.authenticatingPassword ? Color.lock.text : (root.failureMessage.length > 0 ? Color.lock.textError : Color.lock.placeholder) + font.family: Style.font.family + font.pixelSize: root.fieldFontSize + font.italic: !root.authenticatingPassword && root.failureMessage.length > 0 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + // Fingerprint hint pinned inside the field's right edge when a sensor is + // enrolled, so the user knows they can touch to unlock instead of typing. + // Matches hyprlock, which draws its fingerprint icon in the same spot. + Text { + id: fingerprintIcon + objectName: "fingerprintIndicator" + anchors.right: parent.right + anchors.rightMargin: inputField.borderRight + 18 + anchors.verticalCenter: parent.verticalCenter + visible: root.fingerprintConfigured + text: "󰈷" + color: Color.lock.placeholder + font.family: Style.font.family + font.pixelSize: Math.round(root.fieldFontSize * 1.1) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } +} diff --git a/omarchy/plugins/blob.lock/Service.qml b/omarchy/plugins/blob.lock/Service.qml new file mode 100644 index 0000000..08649ac --- /dev/null +++ b/omarchy/plugins/blob.lock/Service.qml @@ -0,0 +1,567 @@ +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 stateHome: home + "/.local/state" + readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME") + readonly property string currentBackgroundLink: stateHome + "/omarchy/current/background" + readonly property string brandingPath: home + "/.config/omarchy/branding/screensaver.txt" + + 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 bool previewVisible: false + property string enteredPassword: "" + property string pendingPassword: "" + property string failureMessage: "" + property int failedAttempts: 0 + property string backgroundPath: "" + property int backgroundVersion: 0 + property string brandingText: "" + property string lastEvent: "init" + property string lastEventAt: "" + property bool strandedLock: false + property bool strandedLockResolved: false + + readonly property bool locked: lockRequested || sessionLock.locked || sessionLock.secure + readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating + + 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 refreshFingerprintStatus() { + if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true + } + + 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 + fingerprintRetryTimer.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() { + if (!wakeProcess.running) wakeProcess.running = true + if (lockRequested) armBlankTimer() + } + + function runBlank() { + if (!blankProcess.running) blankProcess.running = 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 + 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: { + 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: Color.background + + LockView { + id: lockView + anchors.fill: parent + backgroundPath: root.backgroundPath + backgroundVersion: root.backgroundVersion + brandingText: root.brandingText + fingerprintConfigured: root.fingerprintConfigured + authenticatingPassword: root.authenticatingPassword + failureMessage: root.failureMessage + failedAttempts: root.failedAttempts + inputEnabled: root.lockRequested + loadBackground: root.locked + 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 + backgroundVersion: root.backgroundVersion + brandingText: root.brandingText + fingerprintConfigured: root.fingerprintConfigured + authenticatingPassword: false + failureMessage: "" + failedAttempts: 0 + inputEnabled: false + loadBackground: root.previewVisible + 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 + + 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", "-c", "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: 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"] + } + + 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() { + 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() + } + + // The same file the screensaver paints. Watched so an edit through + // `omarchy branding screensaver text` shows up on the next lock without a + // shell restart. A missing file simply leaves the lock screen unbranded. + FileView { + path: root.brandingPath + watchChanges: true + printErrors: false + onLoaded: root.brandingText = text() + onLoadFailed: root.brandingText = "" + onFileChanged: reload() + } + + 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() + } + + 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, + requested: root.lockRequested, + pending: root.pendingSessionLock, + sessionLocked: sessionLock.locked, + secure: sessionLock.secure, + realScreens: root.realScreenCount(), + passwordPam: root.passwordPamConfigured, + fingerprint: root.fingerprintConfigured, + 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" + } + } +} diff --git a/omarchy/plugins/blob.lock/manifest.json b/omarchy/plugins/blob.lock/manifest.json new file mode 100644 index 0000000..0443abf --- /dev/null +++ b/omarchy/plugins/blob.lock/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "id": "blob.lock", + "name": "My 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" + }, + "omarchy": { + "clonedFrom": "omarchy.lock" + } +} diff --git a/omarchy/shell.json b/omarchy/shell.json index df3ef66..3a12d09 100644 --- a/omarchy/shell.json +++ b/omarchy/shell.json @@ -74,11 +74,17 @@ ] } }, - "plugins": [], + "plugins": [ + { + "id": "blob.lock" + } + ], "disabledPlugins": [ - "omarchy.menu" + "omarchy.menu", + "omarchy.lock" ], "cloneSourceRestores": [ - "blob.menu" + "blob.menu", + "blob.lock" ] }