Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property string home: Quickshell.env("HOME")
|
||||
readonly property string brandingPath: home + "/.config/blob/branding/screensaver.txt"
|
||||
readonly property string palettePath: home + "/.local/state/blob/current/theme/colors.toml"
|
||||
|
||||
property string brandingText: ""
|
||||
property string paletteColor4: ""
|
||||
|
||||
function readPaletteColor4(raw) {
|
||||
var lines = String(raw || "").split("\n")
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var match = lines[i].match(/^\s*color4\s*=\s*["']?(#[0-9A-Fa-f]{6})/)
|
||||
if (match) return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
path: root.brandingPath
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: root.brandingText = text()
|
||||
onLoadFailed: root.brandingText = ""
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
FileView {
|
||||
path: root.palettePath
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: root.paletteColor4 = root.readPaletteColor4(text())
|
||||
onLoadFailed: root.paletteColor4 = ""
|
||||
onFileChanged: reload()
|
||||
}
|
||||
}
|
||||
@@ -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 bool fingerprintConfigured: false
|
||||
property bool authenticatingPassword: false
|
||||
property string brandingText: ""
|
||||
property string paletteColor4: ""
|
||||
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: 2
|
||||
readonly property int fieldRadius: 0
|
||||
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
|
||||
readonly property bool showPasswordCursor: inputEnabled && !authenticatingPassword && failureMessage.length === 0
|
||||
readonly property bool errorState: failureMessage.length > 0
|
||||
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 inputActive: passwordText.length > 0 || authenticatingPassword
|
||||
readonly property color inputRestingBorder: paletteColor4.length > 0 ? paletteColor4 : Color.accent
|
||||
readonly property color inputBorderColor: errorState
|
||||
? Color.urgent
|
||||
: (inputActive ? Color.accent : Util.alpha(root.inputRestingBorder, 0.5))
|
||||
readonly property color inputBackground: Util.alpha(Color.background, 0.6)
|
||||
readonly property var inputBorderSpec: Border.flat(root.inputBorderColor, root.outlineThickness)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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: root.inputBackground
|
||||
borderSpec: root.inputBorderSpec
|
||||
radius: root.fieldRadius
|
||||
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 {
|
||||
textFormat: Text.PlainText
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
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 blobPath: ""
|
||||
|
||||
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 + "/blob/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 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 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("blob 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
|
||||
fingerprintConfigured: root.fingerprintConfigured
|
||||
authenticatingPassword: root.authenticatingPassword
|
||||
failureMessage: root.failureMessage
|
||||
failedAttempts: root.failedAttempts
|
||||
inputEnabled: root.lockRequested
|
||||
loadBackground: root.locked
|
||||
brandingText: brandingSource.brandingText
|
||||
paletteColor4: brandingSource.paletteColor4
|
||||
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: "blob-lock-preview"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
LockView {
|
||||
anchors.fill: parent
|
||||
backgroundPath: root.backgroundPath
|
||||
backgroundVersion: root.backgroundVersion
|
||||
fingerprintConfigured: root.fingerprintConfigured
|
||||
authenticatingPassword: false
|
||||
failureMessage: ""
|
||||
failedAttempts: 0
|
||||
inputEnabled: false
|
||||
loadBackground: root.previewVisible
|
||||
passwordText: ""
|
||||
brandingText: brandingSource.brandingText
|
||||
paletteColor4: brandingSource.paletteColor4
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: root.previewVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
PamContext {
|
||||
id: passwordPam
|
||||
config: "blob-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: "blob-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/blob-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", "blob-hypr-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", "blob-system-wake"]
|
||||
}
|
||||
|
||||
Process {
|
||||
id: blankProcess
|
||||
command: ["bash", "-c", "blob-brightness-keyboard off; blob-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()
|
||||
}
|
||||
|
||||
BrandingSource {
|
||||
id: brandingSource
|
||||
}
|
||||
|
||||
FileView {
|
||||
path: "/etc/pam.d/blob-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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.lock",
|
||||
"name": "Lock Screen",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Quickshell session lock with separate password and fingerprint PAM flows.",
|
||||
"blob": {
|
||||
"capabilities": [
|
||||
"authentication"
|
||||
]
|
||||
},
|
||||
"kinds": [
|
||||
"service"
|
||||
],
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {
|
||||
"service": "Service.qml"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user