Show the branding text on the lock screen

This commit is contained in:
2026-08-21 14:56:46 -04:00
parent 55a0b5a06c
commit 96759d68fd
6 changed files with 881 additions and 5 deletions
+1
View File
@@ -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.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.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.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/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/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 check_file "$SCRIPT_DIR/ags/app.ts" "$HOME_DIR/.config/ags/app.ts" "ags/app.ts" || check_status=1
+37 -2
View File
@@ -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 Screensaver at 300 and lock at 900 leaves ten minutes of branding before the
session locks. 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 ## Custom modules
The bar accepts arbitrary ids with `type: "command"`, which is how the two 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 `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 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 - hence the `blob.` ids in `shell.json`. `blob.bar` and `blob.lock` were copied
below for why. by hand; see below for why.
`plugins/blob.workspaces/` clones `omarchy.workspaces`. The stock widget `plugins/blob.workspaces/` clones `omarchy.workspaces`. The stock widget
hardcodes workspaces 1-5 as always visible and has no setting for it, so the 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 under `Apps` instead of below `System`. A `before:` that names an unknown id is
ignored, and a row without one keeps its file order. 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 `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 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, persists the drop into `bar.layout`; once `blob.clock` leaves the center list,
+249
View File
@@ -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
}
}
}
}
+567
View File
@@ -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"
}
}
}
+18
View File
@@ -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"
}
}
+9 -3
View File
@@ -74,11 +74,17 @@
] ]
} }
}, },
"plugins": [], "plugins": [
{
"id": "blob.lock"
}
],
"disabledPlugins": [ "disabledPlugins": [
"omarchy.menu" "omarchy.menu",
"omarchy.lock"
], ],
"cloneSourceRestores": [ "cloneSourceRestores": [
"blob.menu" "blob.menu",
"blob.lock"
] ]
} }