Files
DankMaterialShell/quickshell/Modules/Lock/LockScreenContent.qml
T
Vladimir KosteleiandClaude Opus 5 d7074a53eb fix: fingerprint lid open (#3448)
* lock: keep the fingerprint reader usable across suspend

The reader stopped responding after the lid was closed and opened, after a long
idle period, and after suspend.

The shell held a PAM session for as long as the screen was locked, so a suspend
always landed on a running verify. fprintd then suspends the device under the
verify, pam_fprintd's ReleaseDevice fails with "device is still busy", and the
claim is never released, so every later Claim fails until fprintd restarts. The
reader is now released when the monitors blank and armed again when they come
back, so a suspend has nothing left to wedge.

The PAM profile drops to max-tries=1 and sets an explicit timeout. pam_fprintd
only counts a wrong finger, so one finished session is now one wrong scan and
maxFprintTries means the number of scans it names rather than five times that.
A timeout expiry arrives as PamResult.Error, exactly like a device fault, so a
session that ran at least as long as the configured timeout is treated as a
planned renewal instead of spending the error budget and painting an error.

A suspend that still lands while the screen is on wedges as before. Recovery now
waits out fprintd's idle timeout instead of climbing to it, which brings the
reader back about 37 seconds after resume instead of about 98.

SessionService gains lidOpened from UPower, because logind can ignore the lid on
AC and resume alone is not enough. The lock PAM state machine is fixed alongside:
PamResult.Failed was dropped, a pending retry outlived the contexts that owned
it, and an availability check reset the wrong-scan counter.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q5LQHivuucTLXwLkvZMfcn

* lock: time the fingerprint session from the verify, not the claim

pam_fprintd's timeout runs from the verify, but the session age was measured
from start(), which folds the claim into it. A slow claim plus a verify that
failed before its own timeout could add up past the timeout and be taken for a
planned renewal, skipping the backoff. A session that never reached a verify had
the same problem.

The age now starts at the message pam_fprintd sends when the verify begins, so a
session that never verified can never qualify as an expiry, and a slow claim no
longer ages a fault. The message is translated and quickshell clears it before
completed, so only its timing is used, never its text.

That message arrives after pam_fprintd has already armed its deadline, because
VerifyStart is asynchronous, so the age read here runs short of the real timeout
and a slack makes it up. The bias is deliberate. A fault misread as a timeout
costs one extra retry, while a timeout misread as a fault parks the reader behind
a backoff that grows to a minute, which on an idle lock screen would leave it
unusable most of the time.

Also corrects the comment that claimed a timeout is reported without a message.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q5LQHivuucTLXwLkvZMfcn

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-25 11:02:51 -04:00

1487 lines
63 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import QtQuick.Window
import Quickshell
import qs.Common
import qs.Modals
import qs.Modules.Notifications as Notifications
import qs.Services
import qs.Widgets
import qs.DankCommon.Session
import "../../Common/KeyUtils.js" as KeyUtils
Item {
id: root
readonly property var log: Log.scoped("LockScreenContent")
function encodeFileUrl(path) {
if (!path)
return "";
return "file://" + path.split('/').map(s => encodeURIComponent(s)).join('/');
}
property string passwordBuffer: ""
property bool demoMode: false
property var pam: demoPam
property string screenName: ""
property bool unlocking: false
property string pamState: ""
property bool lockerReadySent: false
property bool lockerReadyArmed: false
property var sessionLock: null
readonly property bool hasCustomWallpaper: SettingsData.lockScreenWallpaperPath !== ""
readonly property string lockFontFamily: SettingsData.lockScreenFontFamily
component ClockDigitText: StyledText {
font.pixelSize: LockMetrics.clockSize
font.weight: Theme.fontWeight
color: Theme.lockScreenContentColor
horizontalAlignment: Text.AlignHCenter
font.family: root.lockFontFamily !== "" ? root.lockFontFamily : resolvedFontFamily
}
signal unlockRequested
signal passwordEdited(string text)
function resetLockState() {
lockerReadySent = false;
lockerReadyArmed = true;
unlocking = false;
pamState = "";
if (pam)
pam.lockMessage = "";
}
function focusPasswordField() {
if (demoMode || !passwordField)
return;
passwordField.forceActiveFocus();
}
function currentAuthFeedbackText() {
if (!pam)
return "";
if (pam.u2fState === "insert" && !pam.u2fPending)
return I18n.tr("Insert your security key...");
if (pam.u2fState === "waiting" && !pam.u2fPending)
return I18n.tr("Touch your security key...");
if (pam.lockMessage && pam.lockMessage.length > 0)
return pam.lockMessage;
if (root.pamState === "error")
return I18n.tr("Authentication error - try again");
if (root.pamState === "max")
return I18n.tr("Too many attempts - locked out");
if (root.pamState === "fail")
return I18n.tr("Incorrect password - try again");
if (pam.fprint.status === "disabled")
return "";
if (pam.fprintState === "error")
return I18n.tr("Fingerprint error");
if (pam.fprintState === "max")
return I18n.tr("Maximum fingerprint attempts reached. Please use password.");
if (pam.fprintState === "fail")
return I18n.tr("Fingerprint not recognized (%1/%2). Please try again or use password.", "lock screen message, %1 is attempts used, %2 is max attempts").arg(pam.fprint.tries).arg(SettingsData.maxFprintTries);
return "";
}
function authFeedbackIsHint() {
return pam && (pam.u2fState === "waiting" || pam.u2fState === "insert") && !pam.u2fPending;
}
function canStartSecurityKeyUnlock() {
return !demoMode && pam && pam.u2f && pam.u2f.available && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !pam.passwd.active && !pam.u2f.active && !pam.u2fPending && !root.unlocking;
}
function triggerSecurityKeyUnlock() {
if (!canStartSecurityKeyUnlock())
return;
passwordField.clear();
pam.u2f.startForAlternativeAuth();
}
function securityKeyShortcutMatches(event) {
return SettingsData.lockScreenSecurityKeyShortcutEnabled && KeyUtils.eventMatchesCombo(event, SettingsData.lockScreenSecurityKeyShortcut);
}
Component.onCompleted: {
WeatherService.addRef();
UserInfoService.getUserInfo();
lockerReadyArmed = true;
}
Component.onDestruction: {
WeatherService.removeRef();
}
function sendLockerReadyOnce() {
if (root.demoMode)
return;
if (lockerReadySent)
return;
if (root.unlocking)
return;
lockerReadySent = true;
if (SessionService.loginctlAvailable && DMSService.apiVersion >= 2) {
DMSService.sendRequest("loginctl.lockerReady", null, resp => {
if (resp?.error)
log.warn("lockerReady failed:", resp.error);
else
log.debug("lockerReady sent (afterAnimating/afterRendering)");
});
}
}
function maybeSend() {
if (!lockerReadyArmed)
return;
if (root.unlocking)
return;
if (!root.visible || root.opacity <= 0)
return;
if (root.sessionLock && !root.sessionLock.secure)
return;
Qt.callLater(() => {
if (root.visible && root.opacity > 0 && !root.unlocking)
sendLockerReadyOnce();
});
}
Connections {
target: root.sessionLock
enabled: target !== null
function onSecureChanged() {
root.maybeSend();
}
}
Connections {
target: root.Window.window
enabled: target !== null
function onAfterAnimating() {
maybeSend();
}
function onAfterRendering() {
maybeSend();
}
}
onVisibleChanged: maybeSend()
onOpacityChanged: maybeSend()
Rectangle {
anchors.fill: parent
color: SettingsData.effectiveWallpaperBackgroundColor
}
Loader {
anchors.fill: parent
active: {
if (root.hasCustomWallpaper)
return false;
var currentWallpaper = SessionData.getMonitorWallpaper(screenName);
return !currentWallpaper || (currentWallpaper && currentWallpaper.startsWith("#"));
}
asynchronous: true
sourceComponent: DankBackdrop {
screenName: root.screenName
}
}
Loader {
id: wallpaperBackground
anchors.fill: parent
readonly property string wallpaperSource: {
if (root.hasCustomWallpaper)
return root.encodeFileUrl(SettingsData.lockScreenWallpaperPath);
var w = SessionData.getMonitorWallpaper(screenName);
return (w && !w.startsWith("#")) ? encodeFileUrl(w) : "";
}
readonly property string fillModeName: {
if (SettingsData.lockScreenWallpaperFillMode !== "")
return SettingsData.lockScreenWallpaperFillMode;
return root.hasCustomWallpaper ? "Fill" : SessionData.getMonitorWallpaperFillMode(root.screenName);
}
readonly property real screenScale: CompositorService.getScreenScale(Quickshell.screens.find(s => s.name === root.screenName) ?? null)
readonly property size decodeSize: Qt.size(Math.round(width * screenScale), Math.round(height * screenScale))
active: wallpaperSource !== "" && width > 0 && height > 0
asynchronous: false
sourceComponent: fillModeName === "Scrolling" ? scrollWallpaperComp : plainWallpaperComp
layer.enabled: true
layer.effect: MultiEffect {
autoPaddingEnabled: false
blurEnabled: true
blur: Theme.lockScreenBlur
blurMax: Theme.lockScreenBlurMax
blurMultiplier: 1
}
Behavior on opacity {
NumberAnimation {
duration: LockMetrics.effectsDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
}
Component {
id: plainWallpaperComp
Image {
source: wallpaperBackground.wallpaperSource
sourceSize: wallpaperBackground.decodeSize
fillMode: Theme.getFillMode(wallpaperBackground.fillModeName)
smooth: true
cache: true
asynchronous: false
}
}
Component {
id: scrollWallpaperComp
Item {
Image {
id: scrollSource
anchors.fill: parent
visible: false
source: wallpaperBackground.wallpaperSource
sourceSize: wallpaperBackground.decodeSize
asynchronous: false
cache: true
}
ShaderEffectSource {
id: scrollSrc
sourceItem: scrollSource
hideSource: true
live: false
}
ShaderEffect {
anchors.fill: parent
readonly property var scrollPos: SessionData.getMonitorScrollPosition(screenName)
property variant source1: scrollSrc
property variant source2: scrollSrc
property real progress: 0.0
property real fillMode: Theme.getShaderFillMode(wallpaperBackground.fillModeName)
property real scrollX: scrollPos.scrollX
property real scrollY: scrollPos.scrollY
property real imageWidth1: scrollSource.implicitWidth > 0 ? scrollSource.implicitWidth : 1
property real imageHeight1: scrollSource.implicitHeight > 0 ? scrollSource.implicitHeight : 1
property real imageWidth2: imageWidth1
property real imageHeight2: imageHeight1
property real screenWidth: width > 0 ? width : 1
property real screenHeight: height > 0 ? height : 1
property vector4d fillColor: Qt.vector4d(0, 0, 0, 1)
fragmentShader: Qt.resolvedUrl("../../Shaders/qsb/wp_fade.frag.qsb")
}
}
}
Rectangle {
anchors.fill: parent
color: Theme.screenOffColor
opacity: Theme.lockScreenScrimAlpha
}
SystemClock {
id: systemClock
precision: SystemClock.Seconds
}
Rectangle {
anchors.fill: parent
color: "transparent"
Item {
id: clockContainer
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.verticalCenter
anchors.bottomMargin: LockMetrics.fieldHeight
width: parent.width
height: verticalClock.visible ? verticalClock.implicitHeight : clockText.implicitHeight
visible: SettingsData.lockScreenShowTime
Row {
id: clockText
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
spacing: 0
visible: SettingsData.lockScreenClockStyle !== "vertical"
property string fullTimeStr: {
const format = SettingsData.getEffectiveTimeFormat();
return systemClock.date.toLocaleTimeString(Qt.locale(), format);
}
property var timeParts: fullTimeStr.split(':')
property string hours: timeParts[0] || ""
property string minutes: timeParts[1] || ""
property string secondsWithAmPm: timeParts.length > 2 ? timeParts[2] : ""
property string seconds: secondsWithAmPm.replace(/\s*(AM|PM|am|pm)$/i, '')
property string ampm: {
const match = fullTimeStr.match(/\s*(AM|PM|am|pm)$/i);
return match ? match[0].trim() : "";
}
property bool hasSeconds: timeParts.length > 2
ClockDigitText {
width: clockText.hours.length > 1 ? LockMetrics.clockDigitWidth : 0
text: clockText.hours.length > 1 ? clockText.hours[0] : ""
}
ClockDigitText {
width: LockMetrics.clockDigitWidth
text: clockText.hours.length > 1 ? clockText.hours[1] : clockText.hours.length > 0 ? clockText.hours[0] : ""
}
ClockDigitText {
text: ":"
}
ClockDigitText {
width: LockMetrics.clockDigitWidth
text: clockText.minutes.length > 0 ? clockText.minutes[0] : ""
}
ClockDigitText {
width: LockMetrics.clockDigitWidth
text: clockText.minutes.length > 1 ? clockText.minutes[1] : ""
}
ClockDigitText {
text: clockText.hasSeconds ? ":" : ""
visible: clockText.hasSeconds
}
ClockDigitText {
width: LockMetrics.clockDigitWidth
text: clockText.hasSeconds && clockText.seconds.length > 0 ? clockText.seconds[0] : ""
visible: clockText.hasSeconds
}
ClockDigitText {
width: LockMetrics.clockDigitWidth
text: clockText.hasSeconds && clockText.seconds.length > 1 ? clockText.seconds[1] : ""
visible: clockText.hasSeconds
}
ClockDigitText {
width: Theme.iconSizeSmall
text: " "
visible: clockText.ampm !== ""
}
ClockDigitText {
text: clockText.ampm
visible: clockText.ampm !== ""
}
}
Column {
id: verticalClock
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
spacing: -Theme.spacingS
visible: SettingsData.lockScreenClockStyle === "vertical"
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 0
ClockDigitText {
width: clockText.hours.length > 1 ? LockMetrics.clockDigitWidth : 0
text: clockText.hours.length > 1 ? clockText.hours[0] : ""
}
ClockDigitText {
width: LockMetrics.clockDigitWidth
text: clockText.hours.length > 1 ? clockText.hours[1] : clockText.hours.length > 0 ? clockText.hours[0] : ""
}
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 0
ClockDigitText {
width: LockMetrics.clockDigitWidth
text: clockText.minutes.length > 0 ? clockText.minutes[0] : ""
}
ClockDigitText {
width: LockMetrics.clockDigitWidth
text: clockText.minutes.length > 1 ? clockText.minutes[1] : ""
}
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: clockText.hasSeconds ? clockText.seconds + (clockText.ampm !== "" ? " " + clockText.ampm : "") : clockText.ampm
font.pixelSize: Theme.fontSizeDisplay
font.weight: Theme.fontWeight
font.family: root.lockFontFamily !== "" ? root.lockFontFamily : resolvedFontFamily
color: Theme.lockScreenContentColor
visible: clockText.hasSeconds || clockText.ampm !== ""
}
}
}
StyledText {
id: dateText
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: clockContainer.bottom
anchors.topMargin: Theme.spacingXS
visible: SettingsData.lockScreenShowDate
text: {
if (SettingsData.lockDateFormat && SettingsData.lockDateFormat.length > 0) {
return systemClock.date.toLocaleDateString(I18n.locale(), SettingsData.lockDateFormat);
}
return systemClock.date.toLocaleDateString(I18n.locale(), Locale.LongFormat);
}
font.pixelSize: Theme.fontSizeXLarge
font.family: root.lockFontFamily !== "" ? root.lockFontFamily : resolvedFontFamily
color: Theme.lockScreenContentColor
opacity: 1
}
Item {
id: lockNotificationPanel
readonly property int notificationMode: SettingsData.lockScreenNotificationMode
readonly property var notifications: NotificationService.groupedNotifications
readonly property int totalCount: {
let count = 0;
for (const group of notifications) {
count += group.count || 0;
}
return count;
}
readonly property bool hasNotifications: totalCount > 0
readonly property var appNameGroups: {
const groups = {};
for (const group of notifications) {
const appName = (group.appName || "Unknown").toLowerCase();
if (!groups[appName]) {
groups[appName] = {
appName: group.appName || I18n.tr("Unknown"),
count: 0,
latestNotification: group.latestNotification
};
}
groups[appName].count += group.count || 0;
if (group.latestNotification && (!groups[appName].latestNotification || group.latestNotification.time > groups[appName].latestNotification.time)) {
groups[appName].latestNotification = group.latestNotification;
}
}
return Object.values(groups).sort((a, b) => b.count - a.count);
}
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: capsLockRow.bottom
anchors.topMargin: Theme.spacingM
width: Math.min(LockMetrics.notificationCardWidth, parent.width - Theme.spacingXL * 2)
height: notificationMode === 0 || !hasNotifications ? 0 : contentLoader.height
visible: notificationMode > 0 && hasNotifications
clip: true
Loader {
id: contentLoader
anchors.left: parent.left
anchors.right: parent.right
active: lockNotificationPanel.notificationMode > 0 && lockNotificationPanel.hasNotifications
sourceComponent: lockNotificationPanel.notificationMode === 1 ? countOnlyComponent : notificationListComponent
}
Component {
id: countOnlyComponent
LockNotificationCard {
width: parent.width
height: Theme.listItemHeight
color: Theme.notificationFloatingSurface
Accessible.name: countLabel.text
Row {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "notifications"
size: Theme.iconSize
color: Theme.onSurfaceVariant
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
id: countLabel
text: lockNotificationPanel.totalCount === 1 ? I18n.tr("1 notification") : I18n.tr("%1 notifications").arg(lockNotificationPanel.totalCount)
font.pixelSize: Theme.fontSizeMedium
color: Theme.onSurface
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
Component {
id: notificationListComponent
DankFlickable {
width: parent.width
height: Math.min(notificationColumn.implicitHeight, LockMetrics.notificationMaxHeight, Math.max(0, root.height - lockNotificationPanel.y - Theme.spacingXL))
contentHeight: notificationColumn.implicitHeight
clip: true
Column {
id: notificationColumn
width: parent.width
spacing: Theme.groupedListGap
Repeater {
id: notificationRepeater
model: lockNotificationPanel.appNameGroups.slice(0, LockMetrics.notificationLimit)
Notifications.NotificationCard {
required property var modelData
required property int index
width: notificationColumn.width
notificationData: modelData.latestNotification
groupCount: modelData.count
interactive: false
headerOnly: lockNotificationPanel.notificationMode === 2
showActions: false
showDismiss: false
showClose: false
showTime: !headerOnly
animateHeight: false
firstInGroup: index === 0
lastInGroup: index === notificationRepeater.count - 1
}
}
StyledText {
width: parent.width
visible: lockNotificationPanel.appNameGroups.length > LockMetrics.notificationLimit
text: I18n.tr("+ %1 more", "lock screen notification list overflow, %1 is a count of hidden apps").arg(lockNotificationPanel.appNameGroups.length - LockMetrics.notificationLimit)
font.pixelSize: Theme.fontSizeSmall
color: Theme.lockScreenContentColor
horizontalAlignment: Text.AlignHCenter
}
}
}
}
}
ColumnLayout {
id: passwordLayout
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: dateText.visible ? dateText.bottom : clockContainer.bottom
anchors.topMargin: Theme.spacingL
spacing: Theme.spacingM
width: Math.min(LockMetrics.passwordRowWidth, parent.width - Theme.spacingXL * 2)
RowLayout {
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
spacing: Theme.spacingM
Layout.fillWidth: true
DankCircularImage {
Layout.preferredWidth: LockMetrics.avatarSize
Layout.preferredHeight: LockMetrics.fieldHeight
imageSource: {
if (PortalService.profileImage === "")
return "";
if (PortalService.profileImage.startsWith("/"))
return encodeFileUrl(PortalService.profileImage);
return PortalService.profileImage;
}
fallbackIcon: "material:person"
visible: SettingsData.lockScreenShowProfileImage
}
Rectangle {
id: passwordBox
property bool showPassword: false
property real errorOffset: 0
readonly property bool focusRingShown: passwordField.activeFocus && Theme.focusRingWidth > 0
transform: Translate {
x: Math.max(-LockMetrics.shakeDistance, Math.min(LockMetrics.shakeDistance, passwordBox.errorOffset))
}
Layout.fillWidth: true
Layout.preferredHeight: LockMetrics.fieldHeight
radius: Theme.fullRadius(width, height)
color: Theme.cardSurface
border.width: focusRingShown ? Math.max(Theme.outlineWidth, Theme.focusRingWidth) : Theme.layerOutlineWidth
border.color: focusRingShown ? Theme.focusRingColor : Theme.outlineMedium
Accessible.name: I18n.tr("Password")
visible: SettingsData.lockScreenShowPasswordField || root.passwordBuffer.length > 0
Item {
id: lockIconContainer
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
DankIcon {
id: lockIcon
anchors.centerIn: parent
name: {
if (pam.u2fPending)
return "passkey";
switch (pam.fprint.status) {
case "max":
case "stopped":
return "fingerprint_off";
case "active":
return "fingerprint";
case "retrying":
return "hourglass_empty";
}
if (pam.u2f.active)
return "passkey";
return "lock";
}
size: Theme.iconSizeSmall
color: {
if (pam.fprint.tries >= SettingsData.maxFprintTries)
return Theme.error;
if (pam.u2fState !== "")
return Theme.tertiary;
return passwordField.activeFocus ? Theme.primary : Theme.surfaceVariantText;
}
opacity: pam.passwd.active ? 0 : 1
Behavior on opacity {
NumberAnimation {
duration: LockMetrics.effectsDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
}
}
FocusScope {
id: passwordField
KeyNavigation.tab: virtualKeyboardButton.visible ? virtualKeyboardButton : powerButton
KeyNavigation.backtab: powerButton
Accessible.role: Accessible.EditableText
Accessible.name: I18n.tr("Password")
readonly property string text: root.passwordBuffer
property int cursorPosition: text.length
signal accepted
function clampCursorPosition() {
cursorPosition = Math.max(0, Math.min(cursorPosition, text.length));
}
function clear() {
root.passwordEdited("");
cursorPosition = 0;
}
function insertText(value) {
if (value.length === 0)
return;
clampCursorPosition();
const pos = cursorPosition;
root.passwordEdited(text.slice(0, pos) + value + text.slice(pos));
cursorPosition = pos + value.length;
}
function backspace() {
clampCursorPosition();
if (cursorPosition === 0)
return;
const pos = cursorPosition;
root.passwordEdited(text.slice(0, pos - 1) + text.slice(pos));
cursorPosition = pos - 1;
}
function deleteForward() {
clampCursorPosition();
if (cursorPosition === text.length)
return;
const pos = cursorPosition;
root.passwordEdited(text.slice(0, pos) + text.slice(pos + 1));
cursorPosition = pos;
}
function deleteToLineStart() {
clampCursorPosition();
if (cursorPosition === 0)
return;
root.passwordEdited(text.slice(cursorPosition));
cursorPosition = 0;
}
function deleteToLineEnd() {
clampCursorPosition();
if (cursorPosition === text.length)
return;
root.passwordEdited(text.slice(0, cursorPosition));
}
function deleteWordBackward() {
clampCursorPosition();
if (cursorPosition === 0)
return;
let pos = cursorPosition;
while (pos > 0 && text.charAt(pos - 1) === " ")
pos--;
while (pos > 0 && text.charAt(pos - 1) !== " ")
pos--;
root.passwordEdited(text.slice(0, pos) + text.slice(cursorPosition));
cursorPosition = pos;
}
function isPrintableText(value) {
if (value.length === 0)
return false;
const code = value.charCodeAt(0);
return code >= 0x20 && code !== 0x7f;
}
anchors.fill: parent
anchors.leftMargin: lockIconContainer.width + Theme.spacingM * 2
anchors.rightMargin: {
let margin = Theme.spacingM;
if (loadingSpinner.visible) {
margin += loadingSpinner.width;
}
if (enterButton.visible) {
margin += enterButton.width + Theme.spacingXXS;
}
if (securityKeyButton.visible) {
margin += securityKeyButton.width;
}
if (virtualKeyboardButton.visible) {
margin += virtualKeyboardButton.width;
}
if (revealButton.visible) {
margin += revealButton.width;
}
return margin;
}
opacity: 0
focus: true
enabled: !demoMode
activeFocusOnTab: !demoMode
onTextChanged: cursorPosition = text.length
onAccepted: {
if (!demoMode && !root.unlocking && !pam.passwd.active && !pam.u2fPending) {
pam.passwd.start();
}
}
Keys.onPressed: event => handleKey(event)
function handleKey(event) {
if (demoMode) {
return;
}
root.pam.retryFprintOnActivity();
if (root.unlocking) {
event.accepted = true;
return;
}
if (event.key === Qt.Key_Escape) {
if (keyboardController.isKeyboardActive) {
keyboardController.hide();
event.accepted = true;
return;
}
if (pam.u2fPending) {
pam.cancelU2fPending();
event.accepted = true;
return;
}
clear();
event.accepted = true;
return;
}
if (pam.passwd.active) {
log.debug("PAM is active, ignoring input");
event.accepted = true;
return;
}
if ((event.modifiers & Qt.ControlModifier) && !(event.modifiers & (Qt.AltModifier | Qt.MetaModifier))) {
if (securityKeyShortcutMatches(event) && canStartSecurityKeyUnlock()) {
triggerSecurityKeyUnlock();
event.accepted = true;
return;
}
switch (event.key) {
case Qt.Key_A:
cursorPosition = 0;
event.accepted = true;
return;
case Qt.Key_E:
cursorPosition = text.length;
event.accepted = true;
return;
case Qt.Key_B:
clampCursorPosition();
cursorPosition = Math.max(0, cursorPosition - 1);
event.accepted = true;
return;
case Qt.Key_F:
clampCursorPosition();
cursorPosition = Math.min(text.length, cursorPosition + 1);
event.accepted = true;
return;
case Qt.Key_U:
deleteToLineStart();
event.accepted = true;
return;
case Qt.Key_K:
deleteToLineEnd();
event.accepted = true;
return;
case Qt.Key_W:
case Qt.Key_Backspace:
deleteWordBackward();
event.accepted = true;
return;
case Qt.Key_H:
backspace();
event.accepted = true;
return;
case Qt.Key_D:
deleteForward();
event.accepted = true;
return;
}
}
switch (event.key) {
case Qt.Key_Return:
case Qt.Key_Enter:
accepted();
event.accepted = true;
return;
case Qt.Key_Backspace:
backspace();
event.accepted = true;
return;
case Qt.Key_Delete:
deleteForward();
event.accepted = true;
return;
case Qt.Key_Left:
clampCursorPosition();
cursorPosition = Math.max(0, cursorPosition - 1);
event.accepted = true;
return;
case Qt.Key_Right:
clampCursorPosition();
cursorPosition = Math.min(text.length, cursorPosition + 1);
event.accepted = true;
return;
case Qt.Key_Home:
cursorPosition = 0;
event.accepted = true;
return;
case Qt.Key_End:
cursorPosition = text.length;
event.accepted = true;
return;
}
if (isPrintableText(event.text)) {
insertText(event.text);
event.accepted = true;
}
}
// IME commits use a hidden password input: https://github.com/AvengeMedia/DankMaterialShell/issues/2950
TextInput {
id: imeCommitSink
focus: true
width: Theme.dividerWidth
height: 1
opacity: 0
cursorDelegate: Item {}
echoMode: TextInput.Password
inputMethodHints: Qt.ImhHiddenText | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase
KeyNavigation.tab: virtualKeyboardButton.visible ? virtualKeyboardButton : powerButton
KeyNavigation.backtab: powerButton
Keys.onPressed: event => {
passwordField.handleKey(event);
if (!event.accepted && (event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier)))
event.accepted = true;
}
onTextChanged: {
if (text.length === 0)
return;
const committed = text;
text = "";
if (demoMode || root.unlocking || pam.passwd.active)
return;
passwordField.insertText(committed);
}
}
Component.onCompleted: {
if (!demoMode) {
forceActiveFocus();
}
}
onVisibleChanged: {
if (visible && !demoMode) {
forceActiveFocus();
}
}
}
KeyboardController {
id: keyboardController
target: passwordField
rootObject: root
expressive: true
}
StyledText {
id: placeholder
anchors.left: lockIconContainer.right
anchors.leftMargin: Theme.spacingM
anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))))
anchors.rightMargin: Theme.spacingXXS
anchors.verticalCenter: parent.verticalCenter
text: {
if (demoMode) {
return "";
}
if (root.unlocking) {
return I18n.tr("Unlocking...", "lock screen status text while unlocking");
}
if (pam.u2fPending) {
if (pam.u2fState === "insert")
return I18n.tr("Insert your security key...");
return I18n.tr("Touch your security key...");
}
if (pam.passwd.active) {
return I18n.tr("Authenticating...", "lock screen status text while the password is checked");
}
return I18n.tr("Password", "lock screen password field placeholder") + "…";
}
color: root.unlocking ? Theme.primary : (pam.passwd.active ? Theme.primary : Theme.outline)
font.pixelSize: Theme.fontSizeMedium
opacity: (demoMode || root.passwordBuffer.length === 0) ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: LockMetrics.effectsDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
Behavior on color {
ColorAnimation {
duration: LockMetrics.effectsDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
}
Item {
id: passwordViewport
property real scrollX: 0
property real scrollY: 0
function followCursor() {
const rect = passwordDisplay.cursorRectangle;
const contentWidth = passwordDisplay.contentWidth + passwordCursor.width;
const contentHeight = passwordDisplay.contentHeight;
scrollX = followAxis(scrollX, rect.x, rect.x + passwordCursor.width, contentWidth, width);
scrollY = contentHeight < height ? (height - contentHeight) / 2 : followAxis(scrollY, rect.y, rect.y + rect.height, contentHeight, height);
}
function followAxis(offset, start, end, contentSize, viewSize) {
if (contentSize <= viewSize)
return 0;
if (start + offset < 0)
return -start;
if (end + offset > viewSize)
return viewSize - end;
return Math.max(viewSize - contentSize, Math.min(0, offset));
}
anchors.left: lockIconContainer.right
anchors.leftMargin: Theme.spacingM
anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))))
anchors.rightMargin: Theme.spacingXXS
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.topMargin: Theme.spacingXS
anchors.bottomMargin: Theme.spacingXS
clip: true
MouseArea {
anchors.fill: parent
enabled: !root.demoMode
onClicked: passwordField.forceActiveFocus()
}
onWidthChanged: followCursor()
onHeightChanged: followCursor()
TextEdit {
id: passwordDisplay
LayoutMirroring.enabled: false
x: passwordViewport.scrollX
y: passwordViewport.scrollY
width: wrapMode === TextEdit.NoWrap ? implicitWidth : passwordViewport.width
readOnly: true
activeFocusOnPress: false
selectByMouse: false
wrapMode: passwordBox.showPassword ? TextEdit.Wrap : TextEdit.NoWrap
text: {
if (demoMode) {
return "••••••••";
}
if (passwordBox.showPassword) {
return root.passwordBuffer;
}
return "•".repeat(root.passwordBuffer.length);
}
color: Theme.surfaceText
font.family: Theme.fontFamily
font.weight: Theme.fontWeight
font.pixelSize: passwordBox.showPassword ? Theme.fontSizeMedium : Theme.fontSizeLarge
opacity: (demoMode || root.passwordBuffer.length > 0) ? 1 : 0
onTextChanged: cursorPosition = passwordField.cursorPosition
onCursorRectangleChanged: passwordViewport.followCursor()
onContentWidthChanged: passwordViewport.followCursor()
onContentHeightChanged: passwordViewport.followCursor()
Behavior on opacity {
NumberAnimation {
duration: LockMetrics.effectsDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
}
DankTextCursor {
id: passwordCursor
x: passwordDisplay.x + passwordDisplay.cursorRectangle.x
y: passwordDisplay.y + passwordDisplay.cursorRectangle.y
height: passwordDisplay.cursorRectangle.height
shown: !demoMode && passwordField.activeFocus && !pam.passwd.active && !pam.u2fPending && !root.unlocking
readonly property int fieldCursorPosition: passwordField.cursorPosition
readonly property string fieldText: passwordField.text
onFieldCursorPositionChanged: {
passwordDisplay.cursorPosition = fieldCursorPosition;
resetBlink();
}
onFieldTextChanged: resetBlink()
}
}
LockActionButton {
id: revealButton
activeFocusOnTab: false
Accessible.name: parent.showPassword ? I18n.tr("Hide password") : I18n.tr("Show password")
anchors.right: virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))
anchors.rightMargin: 0
anchors.verticalCenter: parent.verticalCenter
iconName: parent.showPassword ? "visibility_off" : "visibility"
buttonSize: Theme.buttonHeightXS
visible: !demoMode && root.passwordBuffer.length > 0 && !pam.passwd.active && !root.unlocking
enabled: visible
onClicked: parent.showPassword = !parent.showPassword
}
LockActionButton {
id: securityKeyButton
activeFocusOnTab: false
anchors.right: enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)
anchors.rightMargin: 0
anchors.verticalCenter: parent.verticalCenter
iconName: "passkey"
buttonSize: Theme.buttonHeightXS
visible: root.canStartSecurityKeyUnlock()
enabled: visible
tooltipText: SettingsData.lockScreenSecurityKeyShortcutEnabled ? I18n.tr("Security key (%1)", "lock screen security key button tooltip with shortcut").arg(SettingsData.lockScreenSecurityKeyShortcut) : I18n.tr("Security key", "lock screen security key button tooltip")
onClicked: root.triggerSecurityKeyUnlock()
}
LockActionButton {
id: virtualKeyboardButton
Keys.onEscapePressed: {
keyboardController.hide();
passwordField.forceActiveFocus();
}
KeyNavigation.tab: powerButton
KeyNavigation.backtab: passwordField
Accessible.name: I18n.tr("On-screen keyboard")
anchors.right: securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))
anchors.rightMargin: securityKeyButton.visible || enterButton.visible ? 0 : Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
iconName: "keyboard"
buttonSize: Theme.buttonHeightXS
visible: !demoMode && !pam.passwd.active && !root.unlocking && !pam.u2fPending
enabled: visible
onClicked: {
if (keyboardController.isKeyboardActive) {
keyboardController.hide();
} else {
keyboardController.show();
}
}
}
Rectangle {
id: loadingSpinner
anchors.right: enterButton.visible ? enterButton.left : parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
width: Theme.iconSize
height: Theme.iconSize
radius: Theme.fullRadius(width, height)
color: "transparent"
visible: !demoMode && (pam.passwd.active || root.unlocking)
DankIcon {
anchors.centerIn: parent
name: "check_circle"
size: Theme.iconSizeSmall
color: Theme.primary
visible: root.unlocking
opacity: root.unlocking ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: LockMetrics.effectsDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
}
Item {
anchors.fill: parent
visible: pam.passwd.active && !root.unlocking
Rectangle {
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
radius: Theme.fullRadius(width, height)
anchors.centerIn: parent
color: "transparent"
border.color: Theme.primarySelected
border.width: Theme.outlineWidthFocused
}
Rectangle {
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
radius: Theme.fullRadius(width, height)
anchors.centerIn: parent
color: "transparent"
border.color: Theme.primary
border.width: Theme.outlineWidthFocused
Rectangle {
width: parent.width
height: parent.height / 2
anchors.top: parent.top
anchors.topMargin: -Theme.outlineWidth
anchors.horizontalCenter: parent.horizontalCenter
color: Theme.cardSurface
}
RotationAnimator on rotation {
running: pam.passwd.active && !root.unlocking
loops: Animation.Infinite
duration: Anims.durLong
from: 0
to: 360
}
}
}
}
LockActionButton {
id: enterButton
activeFocusOnTab: false
Accessible.name: I18n.tr("Unlock", "verb, lock screen submit button")
anchors.right: parent.right
anchors.rightMargin: Theme.spacingXXS
anchors.verticalCenter: parent.verticalCenter
iconName: "keyboard_return"
buttonSize: Theme.buttonHeightXS
visible: (demoMode || (!pam.passwd.active && !root.unlocking && !pam.u2fPending))
enabled: !demoMode
onClicked: {
if (!demoMode && !root.unlocking && !pam.u2fPending) {
pam.passwd.start();
}
}
Behavior on opacity {
NumberAnimation {
duration: LockMetrics.effectsDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
}
Behavior on border.color {
ColorAnimation {
duration: LockMetrics.effectsDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
}
}
StyledText {
id: authFeedbackText
Layout.fillWidth: true
Layout.preferredHeight: text.length > 0 ? Math.min(implicitHeight, Math.ceil(Theme.fontSizeSmall * 4.5)) : 0
text: root.currentAuthFeedbackText()
color: root.authFeedbackIsHint() ? Theme.outline : Theme.error
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
maximumLineCount: 3
elide: Text.ElideRight
opacity: text.length > 0 ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: LockMetrics.effectsDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
}
}
Row {
id: capsLockRow
anchors.top: passwordLayout.bottom
anchors.topMargin: Theme.spacingS
anchors.horizontalCenter: passwordLayout.horizontalCenter
spacing: Theme.spacingXS
opacity: DMSService.capsLockState ? 1 : 0
DankIcon {
name: "shift_lock"
size: Theme.iconSizeSmall
color: Theme.error
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Caps Lock is on")
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
anchors.verticalCenter: parent.verticalCenter
}
Behavior on opacity {
NumberAnimation {
duration: LockMetrics.effectsDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
}
StyledText {
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: Theme.spacingXL
text: I18n.tr("DEMO MODE - Click anywhere to exit")
font.pixelSize: Theme.fontSizeSmall
color: Theme.lockScreenContentColor
opacity: Theme.pendingOpacity
visible: demoMode
}
LockStatusRow {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Theme.spacingXL
visible: SettingsData.lockScreenShowSystemIcons
interactive: !root.demoMode
showMediaPlayer: SettingsData.lockScreenShowMediaPlayer
showWeather: SettingsData.lockScreenShowWeather
useFahrenheit: SettingsData.useFahrenheit
}
LockActionButton {
id: powerButton
KeyNavigation.tab: passwordField
KeyNavigation.backtab: virtualKeyboardButton.visible ? virtualKeyboardButton : passwordField
Accessible.name: I18n.tr("Power Options")
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.margins: Theme.spacingXL
visible: SettingsData.lockScreenShowPowerActions
iconName: "power_settings_new"
iconColor: Theme.onSecondaryContainer
backgroundColor: Theme.secondaryContainer
radius: pressed ? Theme.cornerRadiusS : Theme.fullRadius(width, height)
buttonSize: Theme.buttonHeightM
onClicked: {
if (demoMode) {
log.debug("Demo: Power Menu");
} else {
powerMenu.show();
}
}
}
}
Pam {
id: demoPam
lockSecured: false
}
Connections {
target: root.pam
function onUnlockRequested() {
root.unlocking = true;
lockerReadyArmed = false;
passwordField.clear();
root.unlockRequested();
}
function onStateChanged() {
root.pamState = root.pam.state;
if (root.pam.state === "")
return;
root.unlocking = false;
errorShake.restart();
placeholderDelay.restart();
passwordField.clear();
}
function onU2fPendingChanged() {
if (!root.pam.u2fPending)
return;
passwordField.clear();
if (keyboardController.isKeyboardActive)
keyboardController.hide();
}
function onUnlockInProgressChanged() {
if (!root.pam.unlockInProgress && root.unlocking)
root.unlocking = false;
}
}
SequentialAnimation {
id: errorShake
NumberAnimation {
target: passwordBox
property: "errorOffset"
to: LockMetrics.shakeDistance
duration: LockMetrics.shakeDuration / 3
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial
}
NumberAnimation {
target: passwordBox
property: "errorOffset"
to: -LockMetrics.shakeDistance
duration: LockMetrics.shakeDuration / 3
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial
}
NumberAnimation {
target: passwordBox
property: "errorOffset"
to: 0
duration: LockMetrics.shakeDuration / 3
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial
}
}
Timer {
id: placeholderDelay
interval: 4000
onTriggered: root.pamState = ""
}
MouseArea {
anchors.fill: parent
enabled: demoMode
onClicked: root.unlockRequested()
}
LockPowerMenu {
id: powerMenu
expressive: true
showLogout: true
onClosed: {
if (!demoMode && passwordField && passwordField.forceActiveFocus) {
Qt.callLater(() => passwordField.forceActiveFocus());
}
}
onSwitchUserRequested: {
switchUserPicker.showFromLockScreen();
}
}
SwitchUserModal {
id: switchUserPicker
}
}