diff --git a/0001-Support-for-web-login-and-unified-auth-mechanism.patch b/0001-Support-for-web-login-and-unified-auth-mechanism.patch new file mode 100644 index 0000000..2af0bc9 --- /dev/null +++ b/0001-Support-for-web-login-and-unified-auth-mechanism.patch @@ -0,0 +1,8807 @@ +From 80b45dd1dbea84a6930c5a4a488b2cfdd28e62dd Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 2 Oct 2025 10:59:57 +0200 +Subject: [PATCH 01/48] style: Add common login dialog button styles to avoid + duplication + +This will be used in next commits, when new login buttons are added. + +Also, add a missing insensitive_button_bg_color on lockscren buttons, +without it the button was being dark when insensitive. +-- +2.51.0 + + +From f5323cccc3f0d454eed8e5a93b2faea78d783857 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 12 Feb 2026 16:37:08 +0100 +Subject: [PATCH 02/48] unlockDialog: Vertically center dialog using fixed + height + +Use a fixed estimated height for centering so the position stays +stable regardless of actual content height changes during user +interaction. + +Ensure clock is always on the same position adding vertically center alignment. +--- + js/ui/unlockDialog.js | 26 ++++++++++++++++++++------ + 1 file changed, 20 insertions(+), 6 deletions(-) + +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index 169043b2a6..3f8ddec651 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -27,6 +27,8 @@ const BLUR_SIGMA = 60; + + const SUMMARY_ICON_SIZE = 32; + ++const FIXED_PROMPT_HEIGHT = 400; ++ + var NotificationsBox = GObject.registerClass({ + Signals: { 'wake-up-screen': {} }, + }, class NotificationsBox extends St.BoxLayout { +@@ -326,7 +328,11 @@ var NotificationsBox = GObject.registerClass({ + var Clock = GObject.registerClass( + class UnlockDialogClock extends St.BoxLayout { + _init() { +- super._init({ style_class: 'unlock-dialog-clock', vertical: true }); ++ super._init({ ++ style_class: 'unlock-dialog-clock', ++ orientation: Clutter.Orientation.VERTICAL, ++ y_align: Clutter.ActorAlign.CENTER, ++ }); + + this._time = new St.Label({ + style_class: 'unlock-dialog-clock-time', +@@ -417,8 +423,8 @@ class UnlockDialogLayout extends Clutter.LayoutManager { + vfunc_allocate(container, box) { + let [width, height] = box.get_size(); + +- let tenthOfHeight = height / 10.0; +- let thirdOfHeight = height / 3.0; ++ const tenthOfHeight = height / 10.0; ++ const centerY = height / 2.0; + + let [, , stackWidth, stackHeight] = + this._stack.get_preferred_size(); +@@ -444,9 +450,17 @@ class UnlockDialogLayout extends Clutter.LayoutManager { + this._notifications.allocate(actorBox); + + // Authentication Box +- let stackY = Math.min( +- thirdOfHeight, +- height - stackHeight - maxNotificationsHeight); ++ const dialog = container.get_parent(); ++ let stackY; ++ if (dialog._activePage === dialog._clock) { ++ stackY = Math.min( ++ Math.floor(centerY - stackHeight / 2.0), ++ height - stackHeight - maxNotificationsHeight); ++ } else { ++ stackY = Math.min( ++ Math.floor(centerY - FIXED_PROMPT_HEIGHT / 2.0), ++ height - stackHeight - maxNotificationsHeight); ++ } + + actorBox.x1 = columnX1; + actorBox.y1 = stackY; +-- +2.51.0 + + +From 5cc8c69402a35b9c14c1db1025db5b4f681a261d Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 12 Feb 2026 16:38:04 +0100 +Subject: [PATCH 03/48] unlockDialog: Fix username reuse on reset + +The condition was checking for PROVIDE_USERNAME specifically, but +should also handle REUSE_USERNAME. Check for not DONT_PROVIDE_USERNAME +instead to correctly reuse the username when requested. +--- + js/ui/unlockDialog.js | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index 3f8ddec651..753c070bce 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -802,7 +802,7 @@ var UnlockDialog = GObject.registerClass({ + + _onReset(authPrompt, beginRequest) { + let userName; +- if (beginRequest == AuthPrompt.BeginRequestType.PROVIDE_USERNAME) { ++ if (beginRequest !== AuthPrompt.BeginRequestType.DONT_PROVIDE_USERNAME) { + this._authPrompt.setUser(this._user); + userName = this._userName; + } else { +-- +2.51.0 + + +From 1a7adb6d93257b78e5fc8ab711fd781ed93a9dce Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 12 Feb 2026 16:38:54 +0100 +Subject: [PATCH 04/48] unlockDialog: Wait for authPrompt destruction before + switching VT + +When switching to another user, wait until authPrompt is destroyed +and the clock transition animation completes before switching to +the login session VT. +--- + js/ui/unlockDialog.js | 3 +-- + 1 file changed, 1 insertion(+), 2 deletions(-) + +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index 753c070bce..3bd40c92ac 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -852,8 +852,7 @@ var UnlockDialog = GObject.registerClass({ + } + + _otherUserClicked() { +- Gdm.goto_login_session_sync(null); +- ++ this._authPrompt.connect('destroy', () => Gdm.goto_login_session_sync(null)); + this._authPrompt.cancel(); + } + +-- +2.51.0 + + +From 52e474166a0871545852ce63a1ca667d1f4ad9dd Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 10 Mar 2026 13:56:02 +0100 +Subject: [PATCH 05/48] authPrompt: Separate input well styles from prompt + layout + +Give _inputWell its own style class instead of reusing +login-dialog-prompt-layout. The prompt layout defines the width, +while the input well just fills horizontally within it. +-- +2.51.0 + + +From fc957c7f2c984cfbb48a648f7143e4de772f2489 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 5 Feb 2026 18:55:38 +0100 +Subject: [PATCH 06/48] authPrompt: Use destructured object for + updateSensitivity + +Replace the boolean parameter with a destructured object to make +call sites self-documenting. +--- + js/gdm/authPrompt.js | 18 +++++++++--------- + js/gdm/loginDialog.js | 6 +++--- + js/ui/unlockDialog.js | 2 +- + 3 files changed, 13 insertions(+), 13 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index f9205d41dd..f048aa341e 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -285,7 +285,7 @@ var AuthPrompt = GObject.registerClass({ + + _activateNext(shouldSpin) { + this.verificationStatus = AuthPromptStatus.VERIFICATION_IN_PROGRESS; +- this.updateSensitivity(false); ++ this.updateSensitivity({sensitive: false}); + + if (this._queryingService) { + if (shouldSpin) +@@ -348,7 +348,7 @@ var AuthPrompt = GObject.registerClass({ + else + this.setQuestion(question.replace(/: *$/, '').trim()); + +- this.updateSensitivity(true); ++ this.updateSensitivity({sensitive: true}); + this.emit('prompted'); + } + +@@ -362,7 +362,7 @@ var AuthPrompt = GObject.registerClass({ + this._preemptiveAnswer = null; + + this.setChoiceList(promptMessage, choiceList); +- this.updateSensitivity(true); ++ this.updateSensitivity({sensitive: true}); + this.emit('prompted'); + } + +@@ -404,7 +404,7 @@ var AuthPrompt = GObject.registerClass({ + this.clear(); + } + +- this.updateSensitivity(canRetry); ++ this.updateSensitivity({sensitive: canRetry}); + this.setActorInDefaultButtonWell(null); + + if (!canRetry) +@@ -525,12 +525,12 @@ var AuthPrompt = GObject.registerClass({ + opacity: 0, + visible: true, + }); +- this.updateSensitivity(false); ++ this.updateSensitivity({sensitive: false}); + this._authList.ease({ + opacity: 255, + duration: MESSAGE_FADE_OUT_ANIMATION_TIME, + transition: Clutter.AnimationMode.EASE_OUT_QUAD, +- onComplete: () => this.updateSensitivity(true), ++ onComplete: () => this.updateSensitivity({sensitive: true}), + }); + } + +@@ -605,7 +605,7 @@ var AuthPrompt = GObject.registerClass({ + } + } + +- updateSensitivity(sensitive) { ++ updateSensitivity({sensitive}) { + let authWidget; + + if (this._authList.visible) +@@ -635,7 +635,7 @@ var AuthPrompt = GObject.registerClass({ + + this.setUser(null); + +- this.updateSensitivity(true); ++ this.updateSensitivity({sensitive: true}); + this._entry.set_text(''); + } + +@@ -728,7 +728,7 @@ var AuthPrompt = GObject.registerClass({ + params = Params.parse(params, { userName: null, + hold: null }); + +- this.updateSensitivity(false); ++ this.updateSensitivity({sensitive: false}); + + let hold = params.hold; + if (!hold) +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index 8ed0a0d322..96999d7e6f 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -947,8 +947,8 @@ var LoginDialog = GObject.registerClass({ + () => { + this._authPrompt.disconnect(this._nextSignalId); + this._nextSignalId = 0; +- this._authPrompt.updateSensitivity(false); +- let answer = this._authPrompt.getAnswer(); ++ this._authPrompt.updateSensitivity({sensitive: false}); ++ const answer = this._authPrompt.getAnswer(); + this._user = this._userManager.get_user(answer); + this._authPrompt.clear(); + this._authPrompt.begin({ userName: answer }); +@@ -957,7 +957,7 @@ var LoginDialog = GObject.registerClass({ + this._updateCancelButton(); + + this._sessionMenuButton.updateSensitivity(false); +- this._authPrompt.updateSensitivity(true); ++ this._authPrompt.updateSensitivity({sensitive: true}); + this._showPrompt(); + } + +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index 3bd40c92ac..19df7fc7b2 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -719,7 +719,7 @@ var UnlockDialog = GObject.registerClass({ + case AuthPrompt.AuthPromptStatus.VERIFICATION_FAILED: + this._authPrompt.reset(); + this._authPrompt.updateSensitivity( +- verificationStatus === AuthPrompt.AuthPromptStatus.NOT_VERIFYING); ++ {sensitive: verificationStatus === AuthPrompt.AuthPromptStatus.NOT_VERIFYING}); + } + } + +-- +2.51.0 + + +From 326e650dddb72c20b69a29748cf05a5ee1205699 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 12 Feb 2026 16:47:22 +0100 +Subject: [PATCH 07/48] authPrompt: Use array-based widget lookup in + updateSensitivity + +Replace the if/else widget selection with array-based lookup to +prepare for additional auth widgets in upcoming commits. +--- + js/gdm/authPrompt.js | 9 +++------ + 1 file changed, 3 insertions(+), 6 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index f048aa341e..868d3a592d 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -606,12 +606,9 @@ var AuthPrompt = GObject.registerClass({ + } + + updateSensitivity({sensitive}) { +- let authWidget; +- +- if (this._authList.visible) +- authWidget = this._authList; +- else +- authWidget = this._entry; ++ const authWidget = [ ++ this._authList, ++ ].find(widget => widget.visible) ?? this._entry; + + if (authWidget.reactive === sensitive) + return; +-- +2.51.0 + + +From c59a7bccea69a6e2ed9bbd5e8c858807523038cc Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 10 Mar 2026 17:10:58 +0100 +Subject: [PATCH 08/48] authPrompt: Generalize _fadeInChoiceList to accept any + element + +Rename to _fadeInElement and take the element as a parameter, +allowing the fade-in animation to be reused for other widgets. +Also skip the animation if the element is already visible. +--- + js/gdm/authPrompt.js | 11 +++++++---- + 1 file changed, 7 insertions(+), 4 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 868d3a592d..b76326c490 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -520,13 +520,16 @@ var AuthPrompt = GObject.registerClass({ + this._entry.grab_key_focus(); + } + +- _fadeInChoiceList() { +- this._authList.set({ ++ _fadeInElement(element) { ++ if (element.visible) ++ return; ++ ++ element.set({ + opacity: 0, + visible: true, + }); + this.updateSensitivity({sensitive: false}); +- this._authList.ease({ ++ element.ease({ + opacity: 255, + duration: MESSAGE_FADE_OUT_ANIMATION_TIME, + transition: Clutter.AnimationMode.EASE_OUT_QUAD, +@@ -545,7 +548,7 @@ var AuthPrompt = GObject.registerClass({ + this._entry.hide(); + if (this._message.text === '') + this._message.hide(); +- this._fadeInChoiceList(); ++ this._fadeInElement(this._authList); + } + + getAnswer() { +-- +2.51.0 + + +From 1818770978fe025456807dbf0122f6faebc16f46 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Fri, 9 Feb 2024 09:02:25 -0500 +Subject: [PATCH 09/48] authPrompt: Fade out input buttons/entry after + verification + +It's nice to just see the user image and post login messages +once the user is done with the prompt. The buttons and +to some extent the password entry can disrupt the natural +login flow. +--- + js/gdm/authPrompt.js | 17 +++++++++++++++-- + js/gdm/loginDialog.js | 11 +++++++++++ + 2 files changed, 26 insertions(+), 2 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index b76326c490..613c594a94 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -50,6 +50,7 @@ var AuthPrompt = GObject.registerClass({ + 'next': {}, + 'prompted': {}, + 'reset': { param_types: [GObject.TYPE_UINT] }, ++ 'verification-complete': {}, + }, + }, class AuthPrompt extends St.BoxLayout { + _init(gdmClient, mode) { +@@ -417,8 +418,16 @@ var AuthPrompt = GObject.registerClass({ + _onVerificationComplete() { + this.setActorInDefaultButtonWell(null); + this.verificationStatus = AuthPromptStatus.VERIFICATION_SUCCEEDED; +- this.cancelButton.reactive = false; +- this.cancelButton.can_focus = false; ++ ++ this._mainBox.reactive = false; ++ this._mainBox.can_focus = false; ++ this._mainBox.ease({ ++ opacity: 0, ++ duration: MESSAGE_FADE_OUT_ANIMATION_TIME, ++ mode: Clutter.AnimationMode.EASE_OUT_QUAD, ++ }); ++ ++ this.emit('verification-complete'); + } + + _onReset() { +@@ -505,6 +514,10 @@ var AuthPrompt = GObject.registerClass({ + this.stopSpinning(); + this._authList.clear(); + this._authList.hide(); ++ ++ this._mainBox.opacity = 255; ++ this._mainBox.reactive = true; ++ this._mainBox.can_focus = true; + } + + setQuestion(question) { +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index 96999d7e6f..bc62416e88 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -451,6 +451,7 @@ var LoginDialog = GObject.registerClass({ + this._authPrompt = new AuthPrompt.AuthPrompt(this._gdmClient, AuthPrompt.AuthPromptMode.UNLOCK_OR_LOG_IN); + this._authPrompt.connect('prompted', this._onPrompted.bind(this)); + this._authPrompt.connect('reset', this._onReset.bind(this)); ++ this._authPrompt.connect('verification-complete', this._onVerificationComplete.bind(this)); + this._authPrompt.hide(); + this.add_child(this._authPrompt); + +@@ -890,6 +891,16 @@ var LoginDialog = GObject.registerClass({ + } + } + ++ _onVerificationComplete() { ++ this._bottomButtonGroup.reactive = false; ++ this._bottomButtonGroup.can_focus = false; ++ this._bottomButtonGroup.ease({ ++ opacity: 0, ++ duration: _FADE_ANIMATION_TIME, ++ mode: Clutter.AnimationMode.EASE_OUT_QUAD, ++ }); ++ } ++ + _onDefaultSessionChanged(client, sessionId) { + this._sessionMenuButton.setActiveSession(sessionId); + } +-- +2.51.0 + + +From dcbfeceb6ec5cb6be19530e303908a00018f5e3d Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Wed, 21 Jan 2026 14:23:34 +0100 +Subject: [PATCH 10/48] authPrompt: Don't reset preemptiveAnswer when + VERIFICATION_IN_PROGRESS + +PreemptiveAnswer wasn't being used in the case where verification is in +progress and the smartcard is inserted, triggering a reset. + +This change ensures a preemptive answer will be used once smartcard +service asks for the PIN. +--- + js/gdm/authPrompt.js | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 613c594a94..1b6f67035a 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -682,7 +682,8 @@ var AuthPrompt = GObject.registerClass({ + this.verificationStatus = AuthPromptStatus.NOT_VERIFYING; + this.cancelButton.reactive = this._hasCancelButton; + this.cancelButton.can_focus = this._hasCancelButton; +- this._preemptiveAnswer = null; ++ if (oldStatus !== AuthPromptStatus.VERIFICATION_IN_PROGRESS) ++ this._preemptiveAnswer = null; + + if (this._preemptiveAnswerWatchId) + this._idleMonitor.remove_watch(this._preemptiveAnswerWatchId); +-- +2.51.0 + + +From 74d754e9ce157599449d73838688aab698d257e9 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 12 Feb 2026 17:05:32 +0100 +Subject: [PATCH 11/48] style: Increase hint-text left margin + +The cursor was overlapping the hint-text, making it difficult to read. +Increase the left margin to ensure proper readability. +--- + data/theme/gnome-shell-sass/widgets/_entries.scss | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/data/theme/gnome-shell-sass/widgets/_entries.scss b/data/theme/gnome-shell-sass/widgets/_entries.scss +index 0a43e86f37..d76d17f282 100644 +--- a/data/theme/gnome-shell-sass/widgets/_entries.scss ++++ b/data/theme/gnome-shell-sass/widgets/_entries.scss +@@ -21,7 +21,7 @@ StEntry { + padding: 0 4px; + } + StLabel.hint-text { +- margin-left: 2px; ++ margin-left: $base_margin * 2; + color: transparentize($fg_color, 0.3); + } + } +-- +2.51.0 + + +From a3e17c6c7e0fc5af4232e871fd8c9011b2baf190 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 16 Feb 2026 16:05:17 +0100 +Subject: [PATCH 12/48] authPrompt: Use connectObject for userVerifier signals + +This allows cleanly disconnecting all signals at once when the +authPrompt is destroyed, preventing potential issues from stale +signal handlers. +--- + js/gdm/authPrompt.js | 19 +++++++++++-------- + 1 file changed, 11 insertions(+), 8 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 1b6f67035a..c26b98c024 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -78,14 +78,16 @@ var AuthPrompt = GObject.registerClass({ + + this._userVerifier = new GdmUtil.ShellUserVerifier(this._gdmClient, { reauthenticationOnly }); + +- this._userVerifier.connect('ask-question', this._onAskQuestion.bind(this)); +- this._userVerifier.connect('show-message', this._onShowMessage.bind(this)); +- this._userVerifier.connect('show-choice-list', this._onShowChoiceList.bind(this)); +- this._userVerifier.connect('verification-failed', this._onVerificationFailed.bind(this)); +- this._userVerifier.connect('verification-complete', this._onVerificationComplete.bind(this)); +- this._userVerifier.connect('reset', this._onReset.bind(this)); +- this._userVerifier.connect('smartcard-status-changed', this._onSmartcardStatusChanged.bind(this)); +- this._userVerifier.connect('credential-manager-authenticated', this._onCredentialManagerAuthenticated.bind(this)); ++ this._userVerifier.connectObject( ++ 'ask-question', this._onAskQuestion.bind(this), ++ 'show-message', this._onShowMessage.bind(this), ++ 'show-choice-list', this._onShowChoiceList.bind(this), ++ 'verification-failed', this._onVerificationFailed.bind(this), ++ 'verification-complete', this._onVerificationComplete.bind(this), ++ 'reset', this._onReset.bind(this), ++ 'smartcard-status-changed', this._onSmartcardStatusChanged.bind(this), ++ 'credential-manager-authenticated', this._onCredentialManagerAuthenticated.bind(this), ++ this); + this.smartcardDetected = this._userVerifier.smartcardDetected; + + this.connect('destroy', this._onDestroy.bind(this)); +@@ -132,6 +134,7 @@ var AuthPrompt = GObject.registerClass({ + this._preemptiveAnswerWatchId = 0; + } + ++ this._userVerifier.disconnectObject(this); + this._userVerifier.destroy(); + this._userVerifier = null; + this._entry = null; +-- +2.51.0 + + +From 21e361883a579b235b9076e774156bd42ea888ac Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 12 Feb 2026 17:09:44 +0100 +Subject: [PATCH 13/48] authPrompt: Group animation constants together + +Move all animation-related constants together near the top of the +file to follow the style of the rest of the codebase. +--- + js/gdm/authPrompt.js | 1 - + 1 file changed, 1 deletion(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index c26b98c024..fa0fb8f5af 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -17,7 +17,6 @@ const Util = imports.misc.util; + var DEFAULT_BUTTON_WELL_ICON_SIZE = 16; + var DEFAULT_BUTTON_WELL_ANIMATION_DELAY = 1000; + var DEFAULT_BUTTON_WELL_ANIMATION_TIME = 300; +- + var MESSAGE_FADE_OUT_ANIMATION_TIME = 500; + + const LOCKDOWN_SCHEMA = 'org.gnome.desktop.lockdown'; +-- +2.51.0 + + +From db22c1b01dc865aef97c79d2558aac09285de678 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 12 Feb 2026 17:20:34 +0100 +Subject: [PATCH 14/48] authPrompt: Update entry layout based on mockups + +Implement the new authentication prompt layout based on the design +mockups at: +https://gitlab.gnome.org/Teams/Design/os-mockups/-/blob/master/lock-login/passwordless-login-aday.png + +The entry is now larger and more rounded. The entry area has a richer +UI with the password visibility toggle (already present) and a new +_defaultButtonWell inside it that can show either a next button +(default) or a spinner when authentication is in progress. + +To achieve this, _entry and _defaultButtonWell are wrapped in a new +_entryArea container with a BinLayout, allowing _defaultButtonWell +to overlay _entry aligned to the right. + +The _mainBox now uses a BinLayout to allow it to be horizontally centered +below the username and avatar. This also enables constraining +the cancelButton to the left edge of _mainBox using an AlignConstraint +on the X axis, so it appears offset to the left without affecting +the horizontal center of _mainBox. +--- + js/gdm/authPrompt.js | 69 ++++++++++++++++++++++++++++++++------------ + 1 file changed, 51 insertions(+), 18 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index fa0fb8f5af..7e9469391d 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -1,7 +1,7 @@ + // -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + /* exported AuthPrompt */ + +-const { Clutter, Gio, GLib, GObject, Meta, Pango, Shell, St } = imports.gi; ++const { Clutter, Gio, GLib, GObject, Graphene, Meta, Pango, Shell, St } = imports.gi; + + const Animation = imports.ui.animation; + const AuthList = imports.gdm.authList; +@@ -146,9 +146,11 @@ var AuthPrompt = GObject.registerClass({ + } + + _initInputRow() { +- this._mainBox = new St.BoxLayout({ ++ this._mainBox = new St.Widget({ ++ layout_manager: new Clutter.BinLayout(), + style_class: 'login-dialog-button-box', +- vertical: false, ++ x_expand: true, ++ y_expand: false, + }); + this.add_child(this._mainBox); + +@@ -158,10 +160,17 @@ var AuthPrompt = GObject.registerClass({ + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + reactive: this._hasCancelButton, + can_focus: this._hasCancelButton, ++ x_expand: true, + x_align: Clutter.ActorAlign.START, + y_align: Clutter.ActorAlign.CENTER, + child: new St.Icon({ icon_name: 'go-previous-symbolic' }), + }); ++ this.cancelButton.add_constraint(new Clutter.AlignConstraint({ ++ source: this._mainBox, ++ align_axis: Clutter.AlignAxis.X_AXIS, ++ pivot_point: new Graphene.Point({x: 1, y: 0}), ++ })); ++ + if (this._hasCancelButton) + this.cancelButton.connect('clicked', () => this.cancel()); + else +@@ -187,10 +196,20 @@ var AuthPrompt = GObject.registerClass({ + }); + this._mainBox.add_child(this._authList); + +- let entryParams = { ++ this._entryArea = new St.Widget({ ++ style_class: 'login-dialog-prompt-entry-area', ++ layout_manager: new Clutter.BinLayout(), ++ x_expand: true, ++ y_expand: true, ++ visible: false, ++ }); ++ this._mainBox.add_child(this._entryArea); ++ ++ const entryParams = { + style_class: 'login-dialog-prompt-entry', + can_focus: true, + x_expand: true, ++ y_expand: true, + }; + + this._entry = null; +@@ -202,8 +221,7 @@ var AuthPrompt = GObject.registerClass({ + ShellEntry.addContextMenu(this._passwordEntry, { actionMode: Shell.ActionMode.NONE }); + + this._entry = this._passwordEntry; +- this._mainBox.add_child(this._entry); +- this._entry.grab_key_focus(); ++ this._entryArea.add_child(this._entry); + + this._lockdownSettings = new Gio.Settings({ schema_id: LOCKDOWN_SCHEMA }); + this._lockdownSettings.connect(`changed::${DISABLE_SHOW_PASSWORD_KEY}`, +@@ -232,17 +250,28 @@ var AuthPrompt = GObject.registerClass({ + + this._defaultButtonWell = new St.Widget({ + layout_manager: new Clutter.BinLayout(), ++ style_class: 'login-dialog-default-button-well', ++ x_expand: true, + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.CENTER, + }); +- this._defaultButtonWell.add_constraint(new Clutter.BindConstraint({ +- source: this.cancelButton, +- coordinate: Clutter.BindCoordinate.WIDTH, +- })); +- this._mainBox.add_child(this._defaultButtonWell); ++ this._entryArea.add_child(this._defaultButtonWell); ++ ++ this._nextButton = new St.Button({ ++ style_class: 'login-dialog-button next-button', ++ button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, ++ reactive: true, ++ can_focus: false, ++ icon_name: 'go-next-symbolic', ++ }); ++ this._nextButton.connect('clicked', () => this._activateNext()); ++ this._nextButton.add_style_pseudo_class('default'); ++ this._defaultButtonWell.add_child(this._nextButton); + + this._spinner = new Animation.Spinner(DEFAULT_BUTTON_WELL_ICON_SIZE); + this._defaultButtonWell.add_child(this._spinner); ++ ++ this.setActorInDefaultButtonWell(this._nextButton); + } + + _updateShowPasswordIcon() { +@@ -319,7 +348,7 @@ var AuthPrompt = GObject.registerClass({ + } + + if (newEntry) { +- this._mainBox.replace_child(this._entry, newEntry); ++ this._entryArea.replace_child(this._entry, newEntry); + this._entry = newEntry; + this._inactiveEntry = inactiveEntry; + +@@ -408,17 +437,17 @@ var AuthPrompt = GObject.registerClass({ + } + + this.updateSensitivity({sensitive: canRetry}); +- this.setActorInDefaultButtonWell(null); ++ this.setActorInDefaultButtonWell(this._nextButton); + + if (!canRetry) + this.verificationStatus = AuthPromptStatus.VERIFICATION_FAILED; + + if (wasQueryingService) +- Util.wiggle(this._entry); ++ Util.wiggle(this._entryArea); + } + + _onVerificationComplete() { +- this.setActorInDefaultButtonWell(null); ++ this.setActorInDefaultButtonWell(this._nextButton, true); + this.verificationStatus = AuthPromptStatus.VERIFICATION_SUCCEEDED; + + this._mainBox.reactive = false; +@@ -531,7 +560,8 @@ var AuthPrompt = GObject.registerClass({ + this._entry.hint_text = question; + + this._authList.hide(); +- this._entry.show(); ++ ++ this._entryArea.show(); + this._entry.grab_key_focus(); + } + +@@ -560,7 +590,7 @@ var AuthPrompt = GObject.registerClass({ + this._authList.addItem(key, text); + } + +- this._entry.hide(); ++ this._entryArea.hide(); + if (this._message.text === '') + this._message.hide(); + this._fadeInElement(this._authList); +@@ -631,6 +661,9 @@ var AuthPrompt = GObject.registerClass({ + if (authWidget.reactive === sensitive) + return; + ++ if (authWidget === this._entry) ++ this._nextButton.reactive = sensitive; ++ + authWidget.reactive = sensitive; + + if (sensitive) { +@@ -644,7 +677,7 @@ var AuthPrompt = GObject.registerClass({ + } + + vfunc_hide() { +- this.setActorInDefaultButtonWell(null, true); ++ this.setActorInDefaultButtonWell(this._nextButton, true); + super.vfunc_hide(); + this._message.opacity = 0; + +-- +2.51.0 + + +From fc95866bf3faefd93e5b323876a6bb509dc7d54d Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 12 Feb 2026 17:41:48 +0100 +Subject: [PATCH 15/48] authPrompt: Fade in _entryArea instead of abruptly + showing it + +To support the new fade-in flow, the _entryArea is now hidden by default. +The clear() function has been updated to reset this state to hidden. + +In _onVerificationFailed(), the call to clear() was removed to prevent the +area from being hidden prematurely. This is safe because a reset() (which +calls clear()) is triggered later in the process. + +_fadeInElement() will call updateSensitivity() to ensure the widget is +sensitive when the animation finishes, so there's no need to call it +explicitly in _onAskQuestion(). +--- + js/gdm/authPrompt.js | 9 +++------ + 1 file changed, 3 insertions(+), 6 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 7e9469391d..9503030d3b 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -380,7 +380,6 @@ var AuthPrompt = GObject.registerClass({ + else + this.setQuestion(question.replace(/: *$/, '').trim()); + +- this.updateSensitivity({sensitive: true}); + this.emit('prompted'); + } + +@@ -431,10 +430,8 @@ var AuthPrompt = GObject.registerClass({ + _onVerificationFailed(userVerifier, serviceName, canRetry) { + const wasQueryingService = this._queryingService === serviceName; + +- if (wasQueryingService) { ++ if (wasQueryingService) + this._queryingService = null; +- this.clear(); +- } + + this.updateSensitivity({sensitive: canRetry}); + this.setActorInDefaultButtonWell(this._nextButton); +@@ -540,6 +537,7 @@ var AuthPrompt = GObject.registerClass({ + } + + clear() { ++ this._entryArea.hide(); + this._entry.text = ''; + this._inactiveEntry.text = ''; + this.stopSpinning(); +@@ -561,8 +559,7 @@ var AuthPrompt = GObject.registerClass({ + + this._authList.hide(); + +- this._entryArea.show(); +- this._entry.grab_key_focus(); ++ this._fadeInElement(this._entryArea); + } + + _fadeInElement(element) { +-- +2.51.0 + + +From 64c603f3e601c271ddd32186943186c5a9f52bc7 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 12 Feb 2026 17:36:08 +0100 +Subject: [PATCH 16/48] authPrompt: Show entry area when displaying message + +Since entryArea is hidden by default, we must explicitly make it +visible when showing a message if no other widgets are visible. +This allows getting a preemptive answer. +--- + js/gdm/authPrompt.js | 9 +++++++++ + 1 file changed, 9 insertions(+) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 9503030d3b..2467362fe4 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -424,6 +424,15 @@ var AuthPrompt = GObject.registerClass({ + + _onShowMessage(_userVerifier, serviceName, message, type) { + this.setMessage(serviceName, message, type); ++ ++ // If we're showing a message and no auth widget is currently visible, ++ // show the entry area to allow getting a preemptive answer ++ if (message && ++ type < GdmUtil.MessageType.ERROR && ++ !this._entryArea.visible && ++ !this._authList.visible) ++ this._fadeInElement(this._entryArea); ++ + this.emit('prompted'); + } + +-- +2.51.0 + + +From 88bbd0ee5ae2ce1b47a89d55fa9870145908c8ac Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 12 Feb 2026 17:28:54 +0100 +Subject: [PATCH 17/48] authPrompt: Refactor button-well animation and add + loading signal + +- Add start/stop spinning() methods. +- Add 'loading' signal emitted on start/stop spinning (used in upcoming + commits). +- Replace setActorInDefaultButtonWell() with start/stop spinning() + methods. +- Simplify _activateNext checking internally reactive state and + passwordEntry. +- Simplify setActorInDefaultButtonWell and remove unused + DEFAULT_BUTTON_WELL_ANIMATION_DELAY constant. +--- + js/gdm/authPrompt.js | 113 +++++++++++++++++++------------------------ + 1 file changed, 50 insertions(+), 63 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 2467362fe4..370a0601e8 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -15,7 +15,6 @@ const UserWidget = imports.ui.userWidget; + const Util = imports.misc.util; + + var DEFAULT_BUTTON_WELL_ICON_SIZE = 16; +-var DEFAULT_BUTTON_WELL_ANIMATION_DELAY = 1000; + var DEFAULT_BUTTON_WELL_ANIMATION_TIME = 300; + var MESSAGE_FADE_OUT_ANIMATION_TIME = 500; + +@@ -50,6 +49,7 @@ var AuthPrompt = GObject.registerClass({ + 'prompted': {}, + 'reset': { param_types: [GObject.TYPE_UINT] }, + 'verification-complete': {}, ++ 'loading': {param_types: [GObject.TYPE_BOOLEAN]}, + }, + }, class AuthPrompt extends St.BoxLayout { + _init(gdmClient, mode) { +@@ -241,11 +241,7 @@ var AuthPrompt = GObject.registerClass({ + this._fadeOutMessage(); + }); + +- entry.clutter_text.connect('activate', () => { +- let shouldSpin = entry === this._passwordEntry; +- if (entry.reactive) +- this._activateNext(shouldSpin); +- }); ++ entry.clutter_text.connect('activate', () => this._activateNext()); + }); + + this._defaultButtonWell = new St.Widget({ +@@ -315,13 +311,16 @@ var AuthPrompt = GObject.registerClass({ + this._timedLoginIndicator.scale_x = 0.; + } + +- _activateNext(shouldSpin) { ++ _activateNext() { ++ if (!this._entry.reactive) ++ return; ++ + this.verificationStatus = AuthPromptStatus.VERIFICATION_IN_PROGRESS; + this.updateSensitivity({sensitive: false}); + + if (this._queryingService) { +- if (shouldSpin) +- this.startSpinning(); ++ if (this._entry === this._passwordEntry) ++ this.startSpinning({animate: true}); + + this._userVerifier.answerQuery(this._queryingService, this._entry.text); + } else { +@@ -443,7 +442,7 @@ var AuthPrompt = GObject.registerClass({ + this._queryingService = null; + + this.updateSensitivity({sensitive: canRetry}); +- this.setActorInDefaultButtonWell(this._nextButton); ++ this.stopSpinning(); + + if (!canRetry) + this.verificationStatus = AuthPromptStatus.VERIFICATION_FAILED; +@@ -453,7 +452,7 @@ var AuthPrompt = GObject.registerClass({ + } + + _onVerificationComplete() { +- this.setActorInDefaultButtonWell(this._nextButton, true); ++ this.stopSpinning({animate: true}); + this.verificationStatus = AuthPromptStatus.VERIFICATION_SUCCEEDED; + + this._mainBox.reactive = false; +@@ -473,76 +472,64 @@ var AuthPrompt = GObject.registerClass({ + } + + setActorInDefaultButtonWell(actor, animate) { +- if (!this._defaultButtonWellActor && +- !actor) ++ if (!this._defaultButtonWellActor && !actor) + return; + +- let oldActor = this._defaultButtonWellActor; ++ const oldActor = this._defaultButtonWellActor; ++ const wasSpinner = oldActor === this._spinner; + + if (oldActor) + oldActor.remove_all_transitions(); + +- let wasSpinner; +- if (oldActor == this._spinner) +- wasSpinner = true; +- else +- wasSpinner = false; ++ if (actor === this._spinner) ++ this._spinner.play(); + +- let isSpinner; +- if (actor == this._spinner) +- isSpinner = true; +- else +- isSpinner = false; +- +- if (this._defaultButtonWellActor != actor && oldActor) { +- if (!animate) { ++ if (!animate) { ++ if (oldActor) { + oldActor.opacity = 0; +- +- if (wasSpinner) { +- if (this._spinner) +- this._spinner.stop(); +- } +- } else { +- oldActor.ease({ +- opacity: 0, +- duration: DEFAULT_BUTTON_WELL_ANIMATION_TIME, +- delay: DEFAULT_BUTTON_WELL_ANIMATION_DELAY, +- mode: Clutter.AnimationMode.LINEAR, +- onComplete: () => { +- if (wasSpinner) { +- if (this._spinner) +- this._spinner.stop(); +- } +- }, +- }); ++ if (wasSpinner) ++ this._spinner.stop(); + } ++ if (actor) ++ actor.opacity = 255; ++ ++ this._defaultButtonWellActor = actor; ++ return; + } + ++ if (oldActor) { ++ oldActor.opacity = 255; ++ oldActor.ease({ ++ opacity: 0, ++ duration: DEFAULT_BUTTON_WELL_ANIMATION_TIME, ++ mode: Clutter.AnimationMode.LINEAR, ++ onComplete: () => { ++ if (wasSpinner) ++ this._spinner.stop(); ++ }, ++ }); ++ } + if (actor) { +- if (isSpinner) +- this._spinner.play(); +- +- if (!animate) { +- actor.opacity = 255; +- } else { +- actor.ease({ +- opacity: 255, +- duration: DEFAULT_BUTTON_WELL_ANIMATION_TIME, +- delay: DEFAULT_BUTTON_WELL_ANIMATION_DELAY, +- mode: Clutter.AnimationMode.LINEAR, +- }); +- } ++ actor.opacity = 0; ++ actor.ease({ ++ opacity: 255, ++ duration: DEFAULT_BUTTON_WELL_ANIMATION_TIME, ++ delay: oldActor ? DEFAULT_BUTTON_WELL_ANIMATION_TIME : 0, ++ mode: Clutter.AnimationMode.LINEAR, ++ }); + } + + this._defaultButtonWellActor = actor; + } + +- startSpinning() { +- this.setActorInDefaultButtonWell(this._spinner, true); ++ startSpinning({animate = false} = {}) { ++ this.emit('loading', true); ++ this.setActorInDefaultButtonWell(this._spinner, animate); + } + +- stopSpinning() { +- this.setActorInDefaultButtonWell(null, false); ++ stopSpinning({animate = false} = {}) { ++ this.emit('loading', false); ++ this.setActorInDefaultButtonWell(this._nextButton, animate); + } + + clear() { +@@ -683,7 +670,7 @@ var AuthPrompt = GObject.registerClass({ + } + + vfunc_hide() { +- this.setActorInDefaultButtonWell(this._nextButton, true); ++ this.stopSpinning(); + super.vfunc_hide(); + this._message.opacity = 0; + +-- +2.51.0 + + +From bcff8a5091b13cd0f0e2555ef52e27b3a028cb62 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 10 Mar 2026 18:32:48 +0100 +Subject: [PATCH 18/48] authPrompt: Skip reset after successful verification + +Don't reset the prompt if verification has already succeeded. +--- + js/gdm/authPrompt.js | 3 +++ + 1 file changed, 3 insertions(+) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 370a0601e8..a970f17ca9 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -467,6 +467,9 @@ var AuthPrompt = GObject.registerClass({ + } + + _onReset() { ++ if (this.verificationStatus === AuthPromptStatus.VERIFICATION_SUCCEEDED) ++ return; ++ + this.verificationStatus = AuthPromptStatus.NOT_VERIFYING; + this.reset(); + } +-- +2.51.0 + + +From 26aef7076824f3e50c1b3ffb9e7f3141f1ac826e Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 13:06:32 -0500 +Subject: [PATCH 19/48] authPrompt: Parameterize reset function + +In the future, userVerifier will request a partial reset where +some state is carried over or explicitly specified. + +This commit prepares for that by allowing the request type and +reusing entry text to be specified at reset time. +--- + js/gdm/authPrompt.js | 32 ++++++++++++++++++++++---------- + 1 file changed, 22 insertions(+), 10 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index a970f17ca9..80c65eef90 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -466,12 +466,11 @@ var AuthPrompt = GObject.registerClass({ + this.emit('verification-complete'); + } + +- _onReset() { ++ _onReset(_userVerifier, resetParams) { + if (this.verificationStatus === AuthPromptStatus.VERIFICATION_SUCCEEDED) + return; + +- this.verificationStatus = AuthPromptStatus.NOT_VERIFYING; +- this.reset(); ++ this.reset(resetParams); + } + + setActorInDefaultButtonWell(actor, animate) { +@@ -535,10 +534,17 @@ var AuthPrompt = GObject.registerClass({ + this.setActorInDefaultButtonWell(this._nextButton, animate); + } + +- clear() { ++ clear(params) { ++ const {reuseEntryText} = Params.parse(params, { ++ reuseEntryText: false, ++ }); ++ ++ if (!reuseEntryText) { ++ this._entry.text = ''; ++ this._inactiveEntry.text = ''; ++ } ++ + this._entryArea.hide(); +- this._entry.text = ''; +- this._inactiveEntry.text = ''; + this.stopSpinning(); + this._authList.clear(); + this._authList.hide(); +@@ -708,8 +714,13 @@ var AuthPrompt = GObject.registerClass({ + this.updateSensitivity(false); + } + +- reset() { +- let oldStatus = this.verificationStatus; ++ reset(params) { ++ const {reuseEntryText, softReset} = Params.parse(params, { ++ reuseEntryText: false, ++ softReset: false, ++ }); ++ ++ const oldStatus = this.verificationStatus; + this.verificationStatus = AuthPromptStatus.NOT_VERIFYING; + this.cancelButton.reactive = this._hasCancelButton; + this.cancelButton.can_focus = this._hasCancelButton; +@@ -725,7 +736,7 @@ var AuthPrompt = GObject.registerClass({ + this._userVerifier.cancel(); + + this._queryingService = null; +- this.clear(); ++ this.clear({reuseEntryText}); + this._message.opacity = 0; + this.setUser(null); + this._updateEntry(true); +@@ -750,7 +761,8 @@ var AuthPrompt = GObject.registerClass({ + // We don't need to know the username if the user preempted the login screen + // with a smartcard or with preauthenticated oVirt credentials + beginRequestType = BeginRequestType.DONT_PROVIDE_USERNAME; +- } else if (oldStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS) { ++ } else if (oldStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS || ++ softReset) { + // We're going back to retry with current user + beginRequestType = BeginRequestType.REUSE_USERNAME; + } else { +-- +2.51.0 + + +From 17de39832ff7a953840f898a8bac829ab6f560e6 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 12 Feb 2026 16:56:48 +0100 +Subject: [PATCH 20/48] authPrompt: Rename BeginRequestType to ResetType + +The enum is emitted with the 'reset' signal and describes the type +of reset being performed, so ResetType is a clearer name. +--- + js/gdm/authPrompt.js | 15 ++++++++------- + js/gdm/loginDialog.js | 6 +++--- + js/ui/unlockDialog.js | 4 ++-- + 3 files changed, 13 insertions(+), 12 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 80c65eef90..15e82fc212 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -35,7 +35,8 @@ var AuthPromptStatus = { + VERIFICATION_IN_PROGRESS: 5, + }; + +-var BeginRequestType = { ++/** @enum {number} */ ++var ResetType = { + PROVIDE_USERNAME: 0, + DONT_PROVIDE_USERNAME: 1, + REUSE_USERNAME: 2, +@@ -747,30 +748,30 @@ var AuthPrompt = GObject.registerClass({ + else if (oldStatus === AuthPromptStatus.VERIFICATION_CANCELLED) + this.emit('cancelled'); + +- let beginRequestType; ++ let resetType; + + if (this._mode == AuthPromptMode.UNLOCK_ONLY) { + // The user is constant at the unlock screen, so it will immediately + // respond to the request with the username + if (oldStatus === AuthPromptStatus.VERIFICATION_CANCELLED) + return; +- beginRequestType = BeginRequestType.PROVIDE_USERNAME; ++ resetType = ResetType.PROVIDE_USERNAME; + } else if (this._userVerifier.serviceIsForeground(OVirt.SERVICE_NAME) || + this._userVerifier.serviceIsForeground(Vmware.SERVICE_NAME) || + this._userVerifier.serviceIsForeground(GdmUtil.SMARTCARD_SERVICE_NAME)) { + // We don't need to know the username if the user preempted the login screen + // with a smartcard or with preauthenticated oVirt credentials +- beginRequestType = BeginRequestType.DONT_PROVIDE_USERNAME; ++ resetType = ResetType.DONT_PROVIDE_USERNAME; + } else if (oldStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS || + softReset) { + // We're going back to retry with current user +- beginRequestType = BeginRequestType.REUSE_USERNAME; ++ resetType = ResetType.REUSE_USERNAME; + } else { + // In all other cases, we should get the username up front. +- beginRequestType = BeginRequestType.PROVIDE_USERNAME; ++ resetType = ResetType.PROVIDE_USERNAME; + } + +- this.emit('reset', beginRequestType); ++ this.emit('reset', resetType); + } + + addCharacter(unichar) { +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index bc62416e88..3f3d8702cb 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -865,7 +865,7 @@ var LoginDialog = GObject.registerClass({ + } + } + +- _onReset(authPrompt, beginRequest) { ++ _onReset(authPrompt, resetType) { + this._resetGreeterProxy(); + this._sessionMenuButton.updateSensitivity(true); + +@@ -877,11 +877,11 @@ var LoginDialog = GObject.registerClass({ + this._nextSignalId = 0; + } + +- if (previousUser && beginRequest === AuthPrompt.BeginRequestType.REUSE_USERNAME) { ++ if (previousUser && resetType === AuthPrompt.ResetType.REUSE_USERNAME) { + this._user = previousUser; + this._authPrompt.setUser(this._user); + this._authPrompt.begin({ userName: previousUser.get_user_name() }); +- } else if (beginRequest === AuthPrompt.BeginRequestType.PROVIDE_USERNAME) { ++ } else if (resetType === AuthPrompt.ResetType.PROVIDE_USERNAME) { + if (!this._disableUserList) + this._showUserList(); + else +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index 19df7fc7b2..ba32d45d5d 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -800,9 +800,9 @@ var UnlockDialog = GObject.registerClass({ + this.emit('failed'); + } + +- _onReset(authPrompt, beginRequest) { ++ _onReset(authPrompt, resetType) { + let userName; +- if (beginRequest !== AuthPrompt.BeginRequestType.DONT_PROVIDE_USERNAME) { ++ if (resetType !== AuthPrompt.ResetType.DONT_PROVIDE_USERNAME) { + this._authPrompt.setUser(this._user); + userName = this._userName; + } else { +-- +2.51.0 + + +From 7780db5e45adadb74998adf5937badd9c601a4b2 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Wed, 11 Feb 2026 16:11:59 +0100 +Subject: [PATCH 21/48] unlockDialog: Use isprint() instead of isgraph() for + preemptive input + +isgraph() returns true for printable characters except space, while +isprint() includes space as a valid character. This allows users to +type passwords containing spaces during preemptive input. +--- + js/ui/unlockDialog.js | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index ba32d45d5d..941bcf7b51 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -647,7 +647,7 @@ var UnlockDialog = GObject.registerClass({ + + this._showPrompt(); + +- if (GLib.unichar_isgraph(unichar)) ++ if (GLib.unichar_isprint(unichar)) + this._authPrompt.addCharacter(unichar); + + return Clutter.EVENT_PROPAGATE; +-- +2.51.0 + + +From 47dfb8390cb132ece1832b17754aaf56808111e5 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Wed, 21 Jan 2026 14:34:11 +0100 +Subject: [PATCH 22/48] authPrompt: Capture preemptive input before entry is + sensitive + +The previous changes made the entry invisible and insensitive until +askQuestion is called. During the lock screen, when the user starts +typing before the PAM service is ready, keystrokes were being lost. + +Replace addCharacter() with startPreemptiveInput() which buffers +keystrokes and handles Enter key presses while waiting for the entry +to become sensitive. + +Also, remove unsused addCharacter() in loginDialog.js. +--- + js/gdm/authPrompt.js | 43 +++++++++++++++++++++++++++++++++++-------- + js/gdm/loginDialog.js | 4 ---- + js/ui/unlockDialog.js | 2 +- + 3 files changed, 36 insertions(+), 13 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 15e82fc212..8f5751ec99 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -143,6 +143,10 @@ var AuthPrompt = GObject.registerClass({ + vfunc_key_press_event(keyPressEvent) { + if (keyPressEvent.keyval == Clutter.KEY_Escape) + this.cancel(); ++ ++ if (this._preemptiveInput && !this._pendingActivate) ++ return this._entry.clutter_text.event(keyPressEvent, false); ++ + return super.vfunc_key_press_event(keyPressEvent); + } + +@@ -242,7 +246,16 @@ var AuthPrompt = GObject.registerClass({ + this._fadeOutMessage(); + }); + +- entry.clutter_text.connect('activate', () => this._activateNext()); ++ entry.clutter_text.connect('activate', () => { ++ if (this._preemptiveInput && ++ !this._pendingActivate && ++ this._entry.clutter_text.text) { ++ this._pendingActivate = true; ++ return; ++ } ++ ++ this._activateNext(); ++ }); + }); + + this._defaultButtonWell = new St.Widget({ +@@ -581,10 +594,24 @@ var AuthPrompt = GObject.registerClass({ + opacity: 255, + duration: MESSAGE_FADE_OUT_ANIMATION_TIME, + transition: Clutter.AnimationMode.EASE_OUT_QUAD, +- onComplete: () => this.updateSensitivity({sensitive: true}), ++ onComplete: () => { ++ this.updateSensitivity({sensitive: true}); ++ this._completePreemptiveInput({canActivate: element === this._entryArea}); ++ }, ++ onStopped: (isFinished) => { ++ if (!isFinished) ++ this._completePreemptiveInput(); ++ }, + }); + } + ++ _completePreemptiveInput({canActivate = false} = {}) { ++ if (this._pendingActivate && canActivate) ++ this._activateNext(); ++ this._preemptiveInput = false; ++ this._pendingActivate = false; ++ } ++ + setChoiceList(promptMessage, choiceList) { + this._authList.clear(); + this._authList.label.text = promptMessage; +@@ -716,7 +743,7 @@ var AuthPrompt = GObject.registerClass({ + } + + reset(params) { +- const {reuseEntryText, softReset} = Params.parse(params, { ++ let {reuseEntryText, softReset} = Params.parse(params, { + reuseEntryText: false, + softReset: false, + }); +@@ -736,6 +763,8 @@ var AuthPrompt = GObject.registerClass({ + if (this._userVerifier) + this._userVerifier.cancel(); + ++ reuseEntryText = reuseEntryText || this._preemptiveInput; ++ + this._queryingService = null; + this.clear({reuseEntryText}); + this._message.opacity = 0; +@@ -774,12 +803,10 @@ var AuthPrompt = GObject.registerClass({ + this.emit('reset', resetType); + } + +- addCharacter(unichar) { +- if (!this._entry.visible) +- return; +- +- this._entry.grab_key_focus(); ++ startPreemptiveInput(unichar) { ++ this._preemptiveInput = true; + this._entry.clutter_text.insert_unichar(unichar); ++ this.grab_key_focus(); + } + + begin(params) { +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index 3f3d8702cb..36a727f01f 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -1329,10 +1329,6 @@ var LoginDialog = GObject.registerClass({ + this._authPrompt.cancel(); + } + +- addCharacter(_unichar) { +- // Don't allow type ahead at the login screen +- } +- + finish(onComplete) { + this._authPrompt.finish(onComplete); + } +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index 941bcf7b51..07b2acaf45 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -648,7 +648,7 @@ var UnlockDialog = GObject.registerClass({ + this._showPrompt(); + + if (GLib.unichar_isprint(unichar)) +- this._authPrompt.addCharacter(unichar); ++ this._authPrompt.startPreemptiveInput(unichar); + + return Clutter.EVENT_PROPAGATE; + } +-- +2.51.0 + + +From d04618600781d432113ce68b4fed56fbb6f06878 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 10 Feb 2026 18:51:41 +0100 +Subject: [PATCH 23/48] authPrompt: On verificationFailed ensure input + sensitivity is disabled + +There's a time window between the verification failing and a new +verification request being started. + +Input is not accepted during this time, so disable input sensitivity +when verification fails. UserVerifier will be in charge of restarting the +verification process, reenabling sensitivity. +--- + js/gdm/authPrompt.js | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 8f5751ec99..48f2c8cdd6 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -455,7 +455,7 @@ var AuthPrompt = GObject.registerClass({ + if (wasQueryingService) + this._queryingService = null; + +- this.updateSensitivity({sensitive: canRetry}); ++ this.updateSensitivity({sensitive: false}); + this.stopSpinning(); + + if (!canRetry) +-- +2.51.0 + + +From e20104b165e40b34282b1559a046061d55327513 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 30 Sep 2025 17:43:22 +0200 +Subject: [PATCH 24/48] authPrompt: Update authList style + +Make the AuthListItem buttons a bit bigger and more rounded. + +AuthListItem now accepts a content object with title, subtitle, iconName, +iconTitle, and iconSubtitle properties. This allows displaying richer +item content. The icon button shows a popup with additional details. + +Now the title is an insensitive button that is in _mainBox to ensure +back button is visible and properly aligned. + +Use accessible_name. + +Register classes with the new style. +--- + js/gdm/authList.js | 169 +++++++++++++++++++++++++++++++++++-------- + js/gdm/authPrompt.js | 30 ++++++-- + js/gdm/util.js | 6 +- + 3 files changed, 167 insertions(+), 38 deletions(-) + +diff --git a/js/gdm/authList.js b/js/gdm/authList.js +index fb223a9727..3f1b2817f9 100644 +--- a/js/gdm/authList.js ++++ b/js/gdm/authList.js +@@ -17,29 +17,134 @@ + */ + /* exported AuthList */ + +-const { Clutter, GObject, Meta, St } = imports.gi; ++const { Clutter, Graphene, Shell, GObject, Meta, St } = imports.gi; ++ ++const Main = imports.ui.main; ++const PopupMenu = imports.ui.popupMenu; + + const SCROLL_ANIMATION_TIME = 500; + +-const AuthListItem = GObject.registerClass({ +- Signals: { 'activate': {} }, +-}, class AuthListItem extends St.Button { +- _init(key, text) { +- this.key = key; +- const label = new St.Label({ +- text, +- style_class: 'login-dialog-auth-list-label', +- y_align: Clutter.ActorAlign.CENTER, +- x_expand: false, ++const ItemIconPopup = class extends PopupMenu.PopupMenu { ++ constructor(sourceActor, title, subtitle) { ++ super(sourceActor, 0.5, St.Side.TOP); ++ ++ this.box.add_style_class_name('login-dialog-item-icon-popup-box'); ++ this.actor.add_style_class_name('login-dialog-item-icon-popup'); ++ ++ const labels = new St.BoxLayout({ ++ orientation: Clutter.Orientation.VERTICAL, ++ style_class: 'login-dialog-item-icon-popup-labels', ++ }); ++ labels.add_child(new St.Label({text: title})); ++ labels.add_child(new St.Label({text: subtitle})); ++ ++ const item = new PopupMenu.PopupBaseMenuItem({ ++ reactive: false, ++ can_focus: false, ++ }); ++ item.add_child(labels); ++ this.addMenuItem(item); ++ ++ sourceActor.connect('clicked', () => this.toggle()); ++ sourceActor.connect('destroy', () => this.destroy()); ++ ++ this.actor.hide(); ++ ++ this._menuManager = new PopupMenu.PopupMenuManager(sourceActor, { ++ actionMode: Shell.ActionMode.NONE, + }); ++ this._menuManager.addMenu(this); ++ } ++}; ++ ++class ItemIcon extends St.Button { ++ static { ++ GObject.registerClass(this); ++ } ++ ++ constructor(iconName, iconTitle, iconSubtitle) { ++ super({ ++ style_class: 'login-dialog-item-icon', ++ iconName, ++ }); ++ ++ this._popup = new ItemIconPopup(this, iconTitle, iconSubtitle); ++ Main.uiGroup.add_child(this._popup.actor); ++ } ++} ++ ++class AuthListItem extends St.Button { ++ static [GObject.signals] = { ++ 'activate': {}, ++ }; ++ ++ static { ++ GObject.registerClass(this); ++ } + +- super._init({ ++ constructor(key, content) { ++ const {title, subtitle, iconName, iconTitle, iconSubtitle} = content; ++ ++ super({ + style_class: 'login-dialog-auth-list-item', + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + can_focus: true, +- child: label, + reactive: true, ++ accessible_name: [title, subtitle, iconTitle, iconSubtitle] ++ .filter(p => p) ++ .join(', '), ++ }); ++ ++ this.key = key; ++ ++ this._container = new St.Widget({ ++ layout_manager: new Clutter.BinLayout(), ++ x_expand: true, + }); ++ this._labelBox = new St.BoxLayout({ ++ orientation: Clutter.Orientation.VERTICAL, ++ y_align: Clutter.ActorAlign.CENTER, ++ x_expand: true, ++ }); ++ this._container.add_child(this._labelBox); ++ ++ if (title) { ++ const label = new St.Label({ ++ text: title, ++ style_class: 'login-dialog-auth-list-item-title', ++ y_align: Clutter.ActorAlign.CENTER, ++ x_expand: true, ++ }); ++ this._labelBox.add_child(label); ++ } ++ ++ if (subtitle) { ++ const label = new St.Label({ ++ text: subtitle, ++ style_class: 'login-dialog-auth-list-item-subtitle', ++ y_align: Clutter.ActorAlign.CENTER, ++ x_expand: true, ++ }); ++ this._labelBox.add_child(label); ++ } ++ ++ if (iconName && iconTitle && iconSubtitle) { ++ const icon = new ItemIcon(iconName, iconTitle, iconSubtitle); ++ icon.add_constraint(new Clutter.AlignConstraint({ ++ source: this._container, ++ align_axis: Clutter.AlignAxis.X_AXIS, ++ factor: 0.5, ++ })); ++ icon.add_constraint(new Clutter.AlignConstraint({ ++ source: this._container, ++ align_axis: Clutter.AlignAxis.Y_AXIS, ++ factor: 0.0, ++ pivot_point: new Graphene.Point({x: 0.0, y: 0.35}), ++ })); ++ this._container.add_child(icon); ++ } ++ ++ this.set_child(this._container); + + this.connect('key-focus-in', + () => this._setSelected(true)); +@@ -63,25 +168,26 @@ const AuthListItem = GObject.registerClass({ + this.remove_style_pseudo_class('selected'); + } + } +-}); ++} + +-var AuthList = GObject.registerClass({ +- Signals: { +- 'activate': { param_types: [GObject.TYPE_STRING] }, +- 'item-added': { param_types: [AuthListItem.$gtype] }, +- }, +-}, class AuthList extends St.BoxLayout { +- _init() { +- super._init({ +- vertical: true, ++export class AuthList extends St.BoxLayout { ++ static [GObject.signals] = { ++ 'activate': {param_types: [GObject.TYPE_STRING]}, ++ 'item-added': {param_types: [AuthListItem.$gtype]}, ++ }; ++ ++ static { ++ GObject.registerClass(this); ++ } ++ ++ constructor() { ++ super({ ++ orientation: Clutter.Orientation.VERTICAL, + style_class: 'login-dialog-auth-list-layout', +- x_align: Clutter.ActorAlign.START, + y_align: Clutter.ActorAlign.CENTER, ++ x_expand: true, + }); + +- this.label = new St.Label({ style_class: 'login-dialog-auth-list-title' }); +- this.add_child(this.label); +- + this._scrollView = new St.ScrollView({ + style_class: 'login-dialog-auth-list-view', + }); +@@ -135,11 +241,11 @@ var AuthList = GObject.registerClass({ + }); + } + +- addItem(key, text) { ++ addItem(key, content) { + this.removeItem(key); + +- let item = new AuthListItem(key, text); +- this._box.add(item); ++ const item = new AuthListItem(key, content); ++ this._box.add_child(item); + + this._items.set(key, item); + +@@ -169,8 +275,7 @@ var AuthList = GObject.registerClass({ + } + + clear() { +- this.label.text = ''; + this._box.destroy_all_children(); + this._items.clear(); + } +-}); ++} +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 48f2c8cdd6..84c4575518 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -193,13 +193,32 @@ var AuthPrompt = GObject.registerClass({ + duration: MESSAGE_FADE_OUT_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { ++ this._authListTitle.child.text = ''; + this._authList.clear(); + this._authList.hide(); + this._userVerifier.selectChoice(this._queryingService, key); + }, + }); + }); +- this._mainBox.add_child(this._authList); ++ this._inputWell.add_child(this._authList); ++ ++ // Use an insensitive button for the auth list title ++ // to get the same style as the auth list buttons ++ this._authListTitle = new St.Button({ ++ style_class: 'login-dialog-auth-list-title', ++ x_expand: true, ++ y_expand: true, ++ child: new St.Label({style_class: 'login-dialog-auth-list-title-label'}), ++ reactive: false, ++ can_focus: false, ++ }); ++ this._authList.bind_property('visible', ++ this._authListTitle, 'visible', ++ GObject.BindingFlags.SYNC_CREATE); ++ this._authList.bind_property('opacity', ++ this._authListTitle, 'opacity', ++ GObject.BindingFlags.SYNC_CREATE); ++ this._mainBox.add_child(this._authListTitle); + + this._entryArea = new St.Widget({ + style_class: 'login-dialog-prompt-entry-area', +@@ -560,6 +579,7 @@ var AuthPrompt = GObject.registerClass({ + + this._entryArea.hide(); + this.stopSpinning(); ++ this._authListTitle.child.text = ''; + this._authList.clear(); + this._authList.hide(); + +@@ -614,10 +634,10 @@ var AuthPrompt = GObject.registerClass({ + + setChoiceList(promptMessage, choiceList) { + this._authList.clear(); +- this._authList.label.text = promptMessage; +- for (let key in choiceList) { +- let text = choiceList[key]; +- this._authList.addItem(key, text); ++ this._authListTitle.child.text = promptMessage; ++ for (const key in choiceList) { ++ const content = choiceList[key]; ++ this._authList.addItem(key, content); + } + + this._entryArea.hide(); +diff --git a/js/gdm/util.js b/js/gdm/util.js +index 38a7015de8..b6327c5729 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -762,7 +762,11 @@ var ShellUserVerifier = class { + if (!this.serviceIsForeground(serviceName)) + return; + +- this.emit('show-choice-list', serviceName, promptMessage, list.deep_unpack()); ++ const choiceList = {}; ++ for (const [key, value] of Object.entries(list.deepUnpack())) ++ choiceList[key] = {title: value}; ++ ++ this.emit('show-choice-list', serviceName, promptMessage, choiceList); + } + + _onInfo(client, serviceName, info) { +-- +2.51.0 + + +From f9b334f264d5d04b9bb460a28b245a72ddd3fc24 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Wed, 8 Oct 2025 18:48:07 +0200 +Subject: [PATCH 25/48] authPrompt: Let back button go back to step 1 instead + of full reset + +There can be some auth methods that would require multiple steps. In the +original behaviour when pressing back button, it would do a full reset +and display the user list. Now if we're in a multi-step flow (step > 1), +go back to step 1 instead of full reset. + +Show backButton in unlockDialog for these cases. +--- + js/gdm/authPrompt.js | 38 ++++++++++++++++++++++++++++---------- + 1 file changed, 28 insertions(+), 10 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 84c4575518..ad7b890c7c 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -67,6 +67,7 @@ var AuthPrompt = GObject.registerClass({ + this._mode = mode; + this._defaultButtonWellActor = null; + this._cancelledRetries = 0; ++ this._promptStep = 0; + + this._idleMonitor = Meta.IdleMonitor.get_core(); + +@@ -98,8 +99,6 @@ var AuthPrompt = GObject.registerClass({ + }); + this.add_child(this._userWell); + +- this._hasCancelButton = this._mode === AuthPromptMode.UNLOCK_OR_LOG_IN; +- + this._initInputRow(); + + let capsLockPlaceholder = new St.Label(); +@@ -163,8 +162,8 @@ var AuthPrompt = GObject.registerClass({ + style_class: 'modal-dialog-button button cancel-button', + accessible_name: _('Cancel'), + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, +- reactive: this._hasCancelButton, +- can_focus: this._hasCancelButton, ++ reactive: true, ++ can_focus: true, + x_expand: true, + x_align: Clutter.ActorAlign.START, + y_align: Clutter.ActorAlign.CENTER, +@@ -176,10 +175,8 @@ var AuthPrompt = GObject.registerClass({ + pivot_point: new Graphene.Point({x: 1, y: 0}), + })); + +- if (this._hasCancelButton) +- this.cancelButton.connect('clicked', () => this.cancel()); +- else +- this.cancelButton.opacity = 0; ++ this.cancelButton.connect('clicked', () => this.cancel()); ++ this._updateCancelButton(); + this._mainBox.add_child(this.cancelButton); + + this._authList = new AuthList.AuthList(); +@@ -310,6 +307,16 @@ var AuthPrompt = GObject.registerClass({ + } catch (e) {} + } + ++ _updateCancelButton() { ++ if (this._mode === AuthPromptMode.UNLOCK_OR_LOG_IN) ++ return; ++ ++ const cancelVisible = this._promptStep > 1; ++ this.cancelButton.visible = cancelVisible; ++ this.cancelButton.reactive = cancelVisible; ++ this.cancelButton.can_focus = cancelVisible; ++ } ++ + showTimedLoginIndicator(time) { + let hold = new Batch.Hold(); + +@@ -396,6 +403,9 @@ var AuthPrompt = GObject.registerClass({ + this.clear(); + + this._queryingService = serviceName; ++ this._promptStep++; ++ this._updateCancelButton(); ++ + if (this._preemptiveAnswer) { + this._userVerifier.answerQuery(this._queryingService, this._preemptiveAnswer); + this._preemptiveAnswer = null; +@@ -420,6 +430,8 @@ var AuthPrompt = GObject.registerClass({ + this.clear(); + + this._queryingService = serviceName; ++ this._promptStep++; ++ this._updateCancelButton(); + + if (this._preemptiveAnswer) + this._preemptiveAnswer = null; +@@ -770,10 +782,10 @@ var AuthPrompt = GObject.registerClass({ + + const oldStatus = this.verificationStatus; + this.verificationStatus = AuthPromptStatus.NOT_VERIFYING; +- this.cancelButton.reactive = this._hasCancelButton; +- this.cancelButton.can_focus = this._hasCancelButton; + if (oldStatus !== AuthPromptStatus.VERIFICATION_IN_PROGRESS) + this._preemptiveAnswer = null; ++ this._promptStep = 0; ++ this._updateCancelButton(); + + if (this._preemptiveAnswerWatchId) + this._idleMonitor.remove_watch(this._preemptiveAnswerWatchId); +@@ -861,6 +873,12 @@ var AuthPrompt = GObject.registerClass({ + if (this.verificationStatus == AuthPromptStatus.VERIFICATION_SUCCEEDED) + return; + ++ // If we're in a multi-step flow (step > 1), go back to step 1 instead of full reset ++ if (this._promptStep > 1) { ++ this.reset({softReset: true}); ++ return; ++ } ++ + if (this.verificationStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS) { + this._cancelledRetries++; + if (this._cancelledRetries > this._userVerifier.allowedFailures) +-- +2.51.0 + + +From 54e917b7974c0f1cdc12a5c00ff903912408b721 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 20 Oct 2025 17:24:40 +0200 +Subject: [PATCH 26/48] loginDialog: Vertically center authPrompt using fixed + height + +Use a fixed estimated height for centering so the position stays +stable regardless of actual content height changes during user +interaction. +--- + js/gdm/loginDialog.js | 25 ++++++++++++++++++++++++- + 1 file changed, 24 insertions(+), 1 deletion(-) + +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index 36a727f01f..8b0402ffdc 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -34,6 +34,7 @@ const UserWidget = imports.ui.userWidget; + + const _FADE_ANIMATION_TIME = 250; + const _SCROLL_ANIMATION_TIME = 500; ++const _FIXED_TOP_ACTOR_HEIGHT = 400; + const _TIMED_LOGIN_IDLE_THRESHOLD = 5.0; + + var UserListItem = GObject.registerClass({ +@@ -582,6 +583,28 @@ var LoginDialog = GObject.registerClass({ + return actorBox; + } + ++ _getFixedTopActorAllocation(dialogBox, actor) { ++ const actorBox = new Clutter.ActorBox(); ++ ++ let [, , natWidth, natHeight] = actor.get_preferred_size(); ++ const dialogWidth = dialogBox.x2 - dialogBox.x1; ++ const dialogHeight = dialogBox.y2 - dialogBox.y1; ++ const centerX = dialogBox.x1 + dialogWidth / 2; ++ const centerY = dialogBox.y1 + dialogHeight / 2; ++ ++ const top = centerY - _FIXED_TOP_ACTOR_HEIGHT / 2; ++ ++ natWidth = Math.min(natWidth, dialogWidth); ++ natHeight = Math.min(natHeight, dialogHeight); ++ ++ actorBox.x1 = Math.floor(centerX - natWidth / 2); ++ actorBox.y1 = Math.floor(top); ++ actorBox.x2 = actorBox.x1 + natWidth; ++ actorBox.y2 = actorBox.y1 + natHeight; ++ ++ return actorBox; ++ } ++ + _getCenterActorAllocation(dialogBox, actor) { + let actorBox = new Clutter.ActorBox(); + +@@ -620,7 +643,7 @@ var LoginDialog = GObject.registerClass({ + let authPromptAllocation = null; + let authPromptWidth = 0; + if (this._authPrompt.visible) { +- authPromptAllocation = this._getCenterActorAllocation(dialogBox, this._authPrompt); ++ authPromptAllocation = this._getFixedTopActorAllocation(dialogBox, this._authPrompt); + authPromptWidth = authPromptAllocation.x2 - authPromptAllocation.x1; + } + +-- +2.51.0 + + +From 440192763a323db9e1b23d2d0e467d8c004f075f Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 12 Nov 2024 14:26:30 -0500 +Subject: [PATCH 27/48] data: Add fingerprint and vcard icons + +Fingerprint icon will be used to inform when it's being run un the +background. + +Vcard icon will be used to inform on smartcard certificates list when they +have an Organization field in its subject. +--- + .../status/fingerprint-auth-symbolic.svg | 28 +++++++++++++++++++ + data/icons/scalable/status/vcard-symbolic.svg | 9 ++++++ + 2 files changed, 37 insertions(+) + create mode 100644 data/icons/scalable/status/fingerprint-auth-symbolic.svg + create mode 100644 data/icons/scalable/status/vcard-symbolic.svg + +diff --git a/data/icons/scalable/status/fingerprint-auth-symbolic.svg b/data/icons/scalable/status/fingerprint-auth-symbolic.svg +new file mode 100644 +index 0000000000..67cce39bc1 +--- /dev/null ++++ b/data/icons/scalable/status/fingerprint-auth-symbolic.svg +@@ -0,0 +1,28 @@ ++ ++ ++ ++image/svg+xmlauth-fingerprint +diff --git a/data/icons/scalable/status/vcard-symbolic.svg b/data/icons/scalable/status/vcard-symbolic.svg +new file mode 100644 +index 0000000000..1694f23645 +--- /dev/null ++++ b/data/icons/scalable/status/vcard-symbolic.svg +@@ -0,0 +1,9 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ +-- +2.51.0 + + +From 833e3979f1823836188043420f6cc44885c3c02f Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 18 Aug 2025 12:30:50 +0200 +Subject: [PATCH 28/48] gdm: Extract authentication service and role constants + to constants.js + +Create a dedicated const.js module to centralize GDM authentication-related +constants that are shared across multiple files. + +This prepares the codebase for upcoming commits that will use these +constants. +--- + js/gdm/authPrompt.js | 3 ++- + js/gdm/constants.js | 10 ++++++++++ + js/gdm/util.js | 32 +++++++++++++++----------------- + js/js-resources.gresource.xml | 1 + + 4 files changed, 28 insertions(+), 18 deletions(-) + create mode 100644 js/gdm/constants.js + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index ad7b890c7c..0aca776324 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -6,6 +6,7 @@ const { Clutter, Gio, GLib, GObject, Graphene, Meta, Pango, Shell, St } = import + const Animation = imports.ui.animation; + const AuthList = imports.gdm.authList; + const Batch = imports.gdm.batch; ++const Constants = imports.gdm.constants; + const GdmUtil = imports.gdm.util; + const OVirt = imports.gdm.oVirt; + const Vmware = imports.gdm.vmware; +@@ -456,7 +457,7 @@ var AuthPrompt = GObject.registerClass({ + // with a smartcard + // 2) Don't reset if we've already succeeded at verification and + // the user is getting logged in. +- if (this._userVerifier.serviceIsDefault(GdmUtil.SMARTCARD_SERVICE_NAME) && ++ if (this._userVerifier.serviceIsDefault(Constants.SMARTCARD_SERVICE_NAME) && + (this.verificationStatus === AuthPromptStatus.VERIFYING || + this.verificationStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS) && + this.smartcardDetected) +diff --git a/js/gdm/constants.js b/js/gdm/constants.js +new file mode 100644 +index 0000000000..2f37446c8a +--- /dev/null ++++ b/js/gdm/constants.js +@@ -0,0 +1,10 @@ ++// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- ++ ++export const PASSWORD_ROLE_NAME = 'password'; ++export const SMARTCARD_ROLE_NAME = 'smartcard'; ++export const FINGERPRINT_ROLE_NAME = 'fingerprint'; ++ ++export const PASSWORD_SERVICE_NAME = 'gdm-password'; ++export const SMARTCARD_SERVICE_NAME = 'gdm-smartcard'; ++export const FINGERPRINT_SERVICE_NAME = 'gdm-fingerprint'; ++export const SWITCHABLE_AUTH_SERVICE_NAME = 'gdm-switchable-auth'; +diff --git a/js/gdm/util.js b/js/gdm/util.js +index b6327c5729..4a8bf09d18 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -6,6 +6,7 @@ const { Clutter, Gdm, Gio, GLib } = imports.gi; + const Signals = imports.signals; + + const Batch = imports.gdm.batch; ++const Constants = imports.gdm.constants; + const OVirt = imports.gdm.oVirt; + const Vmware = imports.gdm.vmware; + const Main = imports.ui.main; +@@ -29,9 +30,6 @@ Gio._promisify(Gdm.UserVerifierProxy.prototype, + Gio._promisify(Gio.DBusProxy.prototype, + 'call', 'call_finish'); + +-var PASSWORD_SERVICE_NAME = 'gdm-password'; +-var FINGERPRINT_SERVICE_NAME = 'gdm-fingerprint'; +-var SMARTCARD_SERVICE_NAME = 'gdm-smartcard'; + var FADE_ANIMATION_TIME = 160; + var CLONE_FADE_ANIMATION_TIME = 250; + +@@ -476,7 +474,7 @@ var ShellUserVerifier = class { + this._updateDefaultService(); + + if (this._userVerifier && +- !this._activeServices.has(FINGERPRINT_SERVICE_NAME)) { ++ !this._activeServices.has(Constants.FINGERPRINT_SERVICE_NAME)) { + if (!this._hold?.isAcquired()) + this._hold = new Batch.Hold(); + await this._maybeStartFingerprintVerification(); +@@ -529,8 +527,8 @@ var ShellUserVerifier = class { + this.smartcardDetected = smartcardDetected; + + if (this.smartcardDetected) +- this._preemptingService = SMARTCARD_SERVICE_NAME; +- else if (this._preemptingService == SMARTCARD_SERVICE_NAME) ++ this._preemptingService = Constants.SMARTCARD_SERVICE_NAME; ++ else if (this._preemptingService === Constants.SMARTCARD_SERVICE_NAME) + this._preemptingService = null; + + this._updateDefaultService(); +@@ -654,7 +652,7 @@ var ShellUserVerifier = class { + + serviceIsFingerprint(serviceName) { + return this._fingerprintReaderType !== FingerprintReaderType.NONE && +- serviceName === FINGERPRINT_SERVICE_NAME; ++ serviceName === Constants.FINGERPRINT_SERVICE_NAME; + } + + _onSettingsChanged() { +@@ -671,7 +669,7 @@ var ShellUserVerifier = class { + this._fingerprintManager = null; + this._fingerprintReaderType = FingerprintReaderType.NONE; + +- if (this._activeServices.has(FINGERPRINT_SERVICE_NAME)) ++ if (this._activeServices.has(Constants.FINGERPRINT_SERVICE_NAME)) + needsReset = true; + } + +@@ -682,7 +680,7 @@ var ShellUserVerifier = class { + this._smartcardManager.disconnect(this._smartcardRemovedId); + this._smartcardManager = null; + +- if (this._activeServices.has(SMARTCARD_SERVICE_NAME)) ++ if (this._activeServices.has(Constants.SMARTCARD_SERVICE_NAME)) + needsReset = true; + } + +@@ -692,13 +690,13 @@ var ShellUserVerifier = class { + + _getDetectedDefaultService() { + if (this._smartcardManager?.loggedInWithToken()) +- return SMARTCARD_SERVICE_NAME; ++ return Constants.SMARTCARD_SERVICE_NAME; + else if (this._settings.get_boolean(PASSWORD_AUTHENTICATION_KEY)) +- return PASSWORD_SERVICE_NAME; ++ return Constants.PASSWORD_SERVICE_NAME; + else if (this._smartcardManager) +- return SMARTCARD_SERVICE_NAME; ++ return Constants.SMARTCARD_SERVICE_NAME; + else if (this._fingerprintReaderType !== FingerprintReaderType.NONE) +- return FINGERPRINT_SERVICE_NAME; ++ return Constants.FINGERPRINT_SERVICE_NAME; + return null; + } + +@@ -708,7 +706,7 @@ var ShellUserVerifier = class { + + if (!this._defaultService) { + log("no authentication service is enabled, using password authentication"); +- this._defaultService = PASSWORD_SERVICE_NAME; ++ this._defaultService = Constants.PASSWORD_SERVICE_NAME; + } + + if (oldDefaultService && +@@ -754,8 +752,8 @@ var ShellUserVerifier = class { + async _maybeStartFingerprintVerification() { + if (this._userName && + this._fingerprintReaderType !== FingerprintReaderType.NONE && +- !this.serviceIsForeground(FINGERPRINT_SERVICE_NAME)) +- await this._startService(FINGERPRINT_SERVICE_NAME); ++ !this.serviceIsForeground(Constants.FINGERPRINT_SERVICE_NAME)) ++ await this._startService(Constants.FINGERPRINT_SERVICE_NAME); + } + + _onChoiceListQuery(client, serviceName, promptMessage, list) { +@@ -880,7 +878,7 @@ var ShellUserVerifier = class { + } + + _verificationFailed(serviceName, shouldRetry) { +- if (serviceName === FINGERPRINT_SERVICE_NAME) { ++ if (serviceName === Constants.FINGERPRINT_SERVICE_NAME) { + if (this._fingerprintFailedId) + GLib.source_remove(this._fingerprintFailedId); + } +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index b2c603a55a..ec6dee70b2 100644 +--- a/js/js-resources.gresource.xml ++++ b/js/js-resources.gresource.xml +@@ -4,6 +4,7 @@ + gdm/authList.js + gdm/authPrompt.js + gdm/batch.js ++ gdm/constants.js + gdm/loginDialog.js + gdm/oVirt.js + gdm/credentialManager.js +-- +2.51.0 + + +From 7763c1b3b91810be91ce2be4d8df05b2aabb38e4 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Wed, 11 Mar 2026 11:41:40 +0100 +Subject: [PATCH 29/48] gdm/util: Add helper functions to get future mechanisms + metadata + +1. isSelectable() to detect when a mechanism is selectable or runs in + background. +2. getNonSelectableIconName() to get the icon name for a non selectable mechanism + +Next commits will use these functions. +--- + js/gdm/util.js | 34 ++++++++++++++++++++++++++++++++++ + 1 file changed, 34 insertions(+) + +diff --git a/js/gdm/util.js b/js/gdm/util.js +index 4a8bf09d18..691442ad90 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -137,6 +137,40 @@ function cloneAndFadeOutActor(actor) { + return hold; + } + ++/** ++ * @param {object} mechanism ++ * @returns {boolean} ++ */ ++export function isSelectable(mechanism) { ++ switch (mechanism.role) { ++ case Constants.PASSWORD_ROLE_NAME: ++ case Constants.SMARTCARD_ROLE_NAME: ++ return true; ++ case Constants.FINGERPRINT_ROLE_NAME: ++ return false; ++ default: ++ throw new Error(`Failed checking mechanism is selectable: ${mechanism.role}`); ++ } ++} ++ ++/** ++ * @param {object} mechanism ++ * @returns {string} ++ */ ++export function getNonSelectableIconName(mechanism) { ++ // This is only used for non selectable mechanisms. ++ // Currently only fingerprint is non selectable ++ if (isSelectable(mechanism)) ++ throw new Error(`Failed getting mechanism icon: ${mechanism.role}, is selectable`); ++ ++ switch (mechanism.role) { ++ case Constants.FINGERPRINT_ROLE_NAME: ++ return 'fingerprint-auth-symbolic'; ++ default: ++ throw new Error(`Failed getting mechanism icon: ${mechanism.role}`); ++ } ++} ++ + var ShellUserVerifier = class { + constructor(client, params) { + params = Params.parse(params, { reauthenticationOnly: false }); +-- +2.51.0 + + +From a8303da3be45bec905d11e7e8a64ffa9088f6887 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 11:04:20 -0500 +Subject: [PATCH 30/48] gdm: Add new AuthMenuButton control + +The latest login screen designs show a new "Login Options" menu in the +corner for session selection and login methods. + +As a first step toward acheiving that goal, this commit adds a new +AuthMenuButton class. It's a fairly generic control derived from +the code used to show the sessions menu button. One key difference +is it allows a multi-valued, partial key for its entries. This provides +flexibility that we'll need to leverage later. + +Also add AuthMenuButtonIndicator which derives from AuthMenuButton to +display icons to inform about non selectable background authentication +methods in use, it's non-interactive. + +Nothing uses these new classes yet. A subsequent commit will change the +sessions menu button code over to use it, and a commit after that will +use it for Login Options. +--- + js/gdm/authMenuButton.js | 532 ++++++++++++++++++++++++++++++++++ + js/js-resources.gresource.xml | 1 + + 2 files changed, 533 insertions(+) + create mode 100644 js/gdm/authMenuButton.js + +diff --git a/js/gdm/authMenuButton.js b/js/gdm/authMenuButton.js +new file mode 100644 +index 0000000000..9b7cebf6d8 +--- /dev/null ++++ b/js/gdm/authMenuButton.js +@@ -0,0 +1,532 @@ ++// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- ++/* ++ * Copyright 2024 Red Hat, Inc ++ * ++ * This program is free software; you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation; either version 2, or (at your option) ++ * any later version. ++ * ++ * This program is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU General Public License for more details. ++ * ++ * You should have received a copy of the GNU General Public License ++ * along with this program; if not, see . ++ */ ++ ++/* ++ * AuthMenuButton implements a menu button that is used to manage login options ++ * (authentication methods, session) ++ * ++ * Item objects are mapped to menu items internally using the item objects ++ * themselves as their own key. ++ * Each item can have arbitrary properties, but certain properties have special ++ * meaning: ++ * - name: (required) Display text for the item ++ * - iconName: Icon to show for the item and optionally in the button ++ * - description: Additional descriptive text shown below the name ++ * - sectionName: Groups items into labeled sections ++ * ++ * The button supports searching for items using partial criteria objects. Any ++ * item matching all non-null properties in the criteria will be returned. ++ * For example: ++ * {sectionName: 'foo'} matches all items in section 'foo' ++ * {name: 'bar', sectionName: 'foo'} matches the item named 'bar' in ++ * section 'foo' ++ * ++ * Items within sections can be ordered using the sectionOrder parameter during ++ * initialization. Sections not in sectionOrder appear at the end. ++ * ++ * Only one item per section can be active at a time. Setting a new active item ++ * deactivates any other active item in the same section. ++ */ ++ ++import Atk from 'gi://Atk'; ++import Clutter from 'gi://Clutter'; ++import GObject from 'gi://GObject'; ++import Shell from 'gi://Shell'; ++import St from 'gi://St'; ++ ++import * as BoxPointer from '../ui/boxpointer.js'; ++import * as Main from '../ui/main.js'; ++import * as PopupMenu from '../ui/popupMenu.js'; ++ ++const VISIBILITY_ANIMATION_TIME = 200; ++ ++class AuthMenuItemSection extends PopupMenu.PopupMenuSection { ++ constructor(sectionName) { ++ super(); ++ ++ this.actor.add_style_class_name('login-dialog-auth-menu-item-section'); ++ ++ if (sectionName) { ++ const itemSection = new PopupMenu.PopupMenuItem(sectionName, { ++ reactive: false, ++ can_focus: false, ++ }); ++ itemSection.label.style_class = 'login-dialog-auth-menu-item-section-label'; ++ itemSection.label.x_expand = true; ++ itemSection.label.x_align = Clutter.ActorAlign.CENTER; ++ this.addMenuItem(itemSection); ++ } ++ } ++} ++ ++class AuthMenuItem extends PopupMenu.PopupBaseMenuItem { ++ static { ++ GObject.registerClass(this); ++ } ++ ++ constructor(item, params = {}) { ++ super(params); ++ ++ if (item.description && item.iconName) { ++ const icon = new St.Icon({ ++ style_class: 'login-dialog-auth-menu-item-icon', ++ icon_name: item.iconName, ++ x_align: Clutter.ActorAlign.CENTER, ++ }); ++ this.add_child(icon); ++ ++ const box = new St.BoxLayout({ ++ vertical: true, ++ style_class: 'login-dialog-auth-menu-item-box', ++ }); ++ const nameLabel = new St.Label({ ++ text: item.name, ++ style_class: 'login-dialog-auth-menu-item-box-name', ++ }); ++ const descriptionLabel = new St.Label({ ++ text: item.description, ++ style_class: 'login-dialog-auth-menu-item-box-description', ++ }); ++ box.add_child(nameLabel); ++ box.add_child(descriptionLabel); ++ this.add_child(box); ++ ++ this.label_actor = nameLabel; ++ } else { ++ // When no extra info is provided, use the default style ++ const nameLabel = new St.Label({ ++ text: item.name, ++ y_expand: true, ++ y_align: Clutter.ActorAlign.CENTER, ++ }); ++ this.add_child(nameLabel); ++ this.label_actor = nameLabel; ++ } ++ ++ this.setOrnament(PopupMenu.Ornament.HIDDEN); ++ } ++} ++ ++export class AuthMenuButton extends St.Button { ++ static [GObject.properties] = { ++ 'title': GObject.ParamSpec.string( ++ 'title', null, null, ++ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, ++ ''), ++ 'icon-name': GObject.ParamSpec.string( ++ 'icon-name', null, null, ++ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, ++ ''), ++ 'read-only': GObject.ParamSpec.boolean( ++ 'read-only', null, null, ++ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, ++ false), ++ 'section-order': GObject.ParamSpec.jsobject( ++ 'section-order', null, null, ++ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY), ++ 'animate-visibility': GObject.ParamSpec.boolean( ++ 'animate-visibility', null, null, ++ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, ++ false), ++ }; ++ ++ static [GObject.signals] = { ++ 'active-item-changed': {param_types: [GObject.TYPE_STRING]}, ++ }; ++ ++ static { ++ GObject.registerClass(this); ++ } ++ ++ constructor(params) { ++ params.sectionOrder ??= []; ++ super({ ++ ...params, ++ style_class: 'login-dialog-button login-dialog-auth-menu-button', ++ reactive: true, ++ track_hover: true, ++ can_focus: true, ++ accessible_name: params.title, ++ accessible_role: Atk.Role.MENU, ++ x_align: Clutter.ActorAlign.CENTER, ++ y_align: Clutter.ActorAlign.CENTER, ++ }); ++ this.child = this._createChild(); ++ ++ this._menu = new PopupMenu.PopupMenu(this, 0, St.Side.BOTTOM); ++ this._menu.box.add_style_class_name('login-dialog-auth-menu-button-popup'); ++ Main.uiGroup.add_child(this._menu.actor); ++ this._menu.actor.hide(); ++ ++ this._manager = new PopupMenu.PopupMenuManager(this, ++ {actionMode: Shell.ActionMode.NONE}); ++ this._manager.addMenu(this._menu); ++ ++ this.connect('clicked', () => { ++ if (this.readOnly && this._getVisibleItemsCount() === 1) ++ return; ++ this._menu.toggle(); ++ }); ++ ++ this._items = new Map(); ++ this._sections = new Map(); ++ this._activeItems = new Set(); ++ this.updateSensitivity(true); ++ } ++ ++ _createChild() { ++ return new St.Icon({icon_name: this.iconName}); ++ } ++ ++ _getMenuItem(item) { ++ if (!item) ++ return null; ++ ++ return this._items.get(JSON.stringify(item)); ++ } ++ ++ _getVisibleItemsCount() { ++ return Array.from(this._items.values()).filter(item => item.visible).length; ++ } ++ ++ updateReactive(reactive) { ++ this.reactive = reactive; ++ this.can_focus = reactive; ++ } ++ ++ updateSensitivity(sensitive) { ++ this._sensitive = sensitive; ++ ++ const visibleItems = this._getVisibleItemsCount(); ++ if (visibleItems === 0 || (visibleItems <= 1 && !this.readOnly)) ++ sensitive = false; ++ ++ this.reactive = sensitive; ++ this.can_focus = sensitive; ++ this._menu.close(BoxPointer.PopupAnimation.NONE); ++ ++ if (this.animateVisibility) { ++ if (sensitive) { ++ this.opacity = 0; ++ this.visible = true; ++ this.ease({ ++ opacity: 255, ++ duration: VISIBILITY_ANIMATION_TIME, ++ mode: Clutter.AnimationMode.EASE_OUT_QUAD, ++ }); ++ } else { ++ this.ease({ ++ opacity: 0, ++ duration: VISIBILITY_ANIMATION_TIME, ++ mode: Clutter.AnimationMode.EASE_OUT_QUAD, ++ onComplete: () => { ++ this.visible = false; ++ }, ++ }); ++ } ++ } else { ++ this.opacity = sensitive ? 255 : 0; ++ this.visible = sensitive; ++ } ++ } ++ ++ _updateOrnament() { ++ for (const menuItem of this._items.values()) ++ menuItem.setOrnament(PopupMenu.Ornament.NO_DOT); ++ ++ for (const itemKey of this._activeItems) { ++ const menuItem = this._getMenuItem(JSON.parse(itemKey)); ++ if (menuItem) ++ menuItem.setOrnament(PopupMenu.Ornament.DOT); ++ } ++ } ++ ++ _deepEquals(a, b) { ++ if (a === b) ++ return true; ++ ++ if (a == null || b == null) ++ return false; ++ ++ const keysA = Object.keys(a); ++ const keysB = Object.keys(b); ++ ++ if (keysA.length !== keysB.length) ++ return false; ++ ++ for (const key of keysA) { ++ if (!keysB.includes(key)) ++ return false; ++ ++ if (!this._deepEquals(a[key], b[key])) ++ return false; ++ } ++ ++ return true; ++ } ++ ++ _findItems(searchCriteria) { ++ const items = []; ++ for (const itemKey of this._items.keys()) { ++ const item = JSON.parse(itemKey); ++ ++ let criteriaMismatch = false; ++ for (const key of Object.keys(searchCriteria)) { ++ if (!searchCriteria[key]) ++ continue; ++ ++ if (this._deepEquals(item[key], searchCriteria[key])) ++ continue; ++ ++ criteriaMismatch = true; ++ break; ++ } ++ ++ if (criteriaMismatch) ++ continue; ++ ++ items.push(itemKey); ++ } ++ ++ return items; ++ } ++ ++ getItems(searchCriteria = {}) { ++ return this._findItems(searchCriteria).map(itemKey => JSON.parse(itemKey)); ++ } ++ ++ clearItems(searchCriteria = {}) { ++ this._findItems(searchCriteria).forEach(itemKey => { ++ const menuItem = this._items.get(itemKey); ++ this._activeItems.delete(itemKey); ++ menuItem.destroy(); ++ this._items.delete(itemKey); ++ }); ++ ++ this._sections.keys().forEach(sectionName => { ++ const itemsInSection = this._findItems({sectionName}); ++ if (itemsInSection.length === 0) { ++ const section = this._sections.get(sectionName); ++ if (section) { ++ section.destroy(); ++ this._sections.delete(sectionName); ++ } ++ } ++ }); ++ ++ this._updateVisibility(); ++ this.updateSensitivity(this._sensitive); ++ } ++ ++ addItem(item) { ++ const itemKey = JSON.stringify(item); ++ if (this._items.has(itemKey)) ++ throw new Error(`Duplicate item ${itemKey}`); ++ ++ if (!item.name) ++ throw new Error(`item ${itemKey} lacks name`); ++ ++ const menuItem = new AuthMenuItem(item, {reactive: !this.readOnly}); ++ menuItem.connect('activate', () => { ++ this.setActiveItem(item); ++ }); ++ ++ const section = this._getSection(item.sectionName); ++ section.addMenuItem(menuItem); ++ ++ this._items.set(itemKey, menuItem); ++ this._updateVisibility(); ++ this.updateSensitivity(this._sensitive); ++ } ++ ++ _getSection(sectionName) { ++ const key = sectionName ?? null; ++ let section = this._sections.get(key); ++ if (section) ++ return section; ++ ++ section = new AuthMenuItemSection(sectionName); ++ ++ if (key === null) { ++ // Empty section is always last ++ this._menu.addMenuItem(section); ++ } else { ++ const orderIndex = this.sectionOrder.indexOf(sectionName); ++ if (orderIndex !== -1) { ++ // Section is in sectionOrder, add at its position ++ this._menu.addMenuItem(section, orderIndex); ++ } else { ++ // Section not in sectionOrder, add before empty section if it exists ++ const emptySection = this._sections.get(null); ++ if (emptySection) { ++ const emptyIndex = this._menu._getMenuItems().indexOf(emptySection); ++ this._menu.addMenuItem(section, emptyIndex); ++ } else { ++ this._menu.addMenuItem(section); ++ } ++ } ++ } ++ ++ this._sections.set(key, section); ++ ++ return section; ++ } ++ ++ _updateVisibility() { ++ const visibleSections = this._getVisibleSections(); ++ ++ for (const [sectionName, section] of this._sections) ++ section.actor.visible = visibleSections.includes(sectionName); ++ ++ this.visible = visibleSections.length > 0; ++ } ++ ++ _getVisibleSections() { ++ return Array.from(this._sections.keys()).filter(sectionName => ++ this._getSectionItemCount(sectionName) > 1 ++ ); ++ } ++ ++ _getSectionItemCount(sectionName) { ++ return this._findItems({sectionName}).length; ++ } ++ ++ _resolveItem(searchCriteria) { ++ const itemKeys = this._findItems(searchCriteria); ++ ++ if (!itemKeys.length) ++ throw new Error(`Unknown item ${JSON.stringify(searchCriteria)}`); ++ ++ if (itemKeys.length > 1) ++ throw new Error(`Matched multiple items with criteria ${JSON.stringify(searchCriteria)}`); ++ ++ const item = JSON.parse(itemKeys[0]); ++ const menuItem = this._items.get(itemKeys[0]); ++ return {item, menuItem}; ++ } ++ ++ setActiveItem(searchCriteria) { ++ const {item} = this._resolveItem(searchCriteria); ++ const itemKey = JSON.stringify(item); ++ ++ if (this._activeItems.has(itemKey)) ++ return; ++ ++ const sectionName = item.sectionName ?? null; ++ const activeInSection = this._findItems({sectionName}) ++ .filter(key => this._activeItems.has(key)); ++ ++ for (const key of activeInSection) ++ this._activeItems.delete(key); ++ ++ this._activeItems.add(itemKey); ++ this._updateOrnament(); ++ this.emit('active-item-changed', sectionName); ++ } ++ ++ getActiveItem(searchCriteria = {}) { ++ const activeKeys = this._findItems(searchCriteria) ++ .filter(key => this._activeItems.has(key)); ++ ++ if (activeKeys.length === 0) ++ return null; ++ ++ if (activeKeys.length > 1) ++ throw new Error(`Multiple active items found with criteria ${JSON.stringify(searchCriteria)}`); ++ ++ return JSON.parse(activeKeys[0]); ++ } ++ ++ closeMenu() { ++ this._menu.close(); ++ } ++} ++ ++export class AuthMenuButtonIndicator extends AuthMenuButton { ++ static { ++ GObject.registerClass(this); ++ } ++ ++ constructor(params = {}) { ++ params.readOnly ??= true; ++ super(params); ++ ++ this.add_style_class_name('login-dialog-auth-menu-button-indicator'); ++ } ++ ++ _createChild() { ++ const container = new St.BoxLayout({ ++ x_align: Clutter.ActorAlign.START, ++ }); ++ ++ this._iconsBox = new St.BoxLayout({ ++ style_class: 'login-dialog-auth-menu-button-indicator-icons', ++ x_expand: true, ++ x_align: Clutter.ActorAlign.CENTER, ++ }); ++ container.add_child(this._iconsBox); ++ ++ this._descriptionLabel = new St.Label({ ++ style_class: 'login-dialog-auth-menu-button-indicator-description', ++ y_align: Clutter.ActorAlign.CENTER, ++ }); ++ this._descriptionLabel.bind_property_full('text', ++ this._descriptionLabel, 'visible', ++ GObject.BindingFlags.SYNC_CREATE, ++ (bind, source) => [true, !!source], ++ null); ++ container.add_child(this._descriptionLabel); ++ ++ return container; ++ } ++ ++ addItem(item) { ++ if (item.iconName) { ++ const icon = new St.Icon({ ++ icon_name: item.iconName, ++ style_class: 'login-dialog-auth-menu-button-indicator-icon', ++ }); ++ this._iconsBox.add_child(icon); ++ } ++ ++ super.addItem(item); ++ } ++ ++ clearItems(searchCriteria = {}) { ++ this._findItems(searchCriteria).forEach(itemKey => { ++ const item = JSON.parse(itemKey); ++ if (item.iconName) { ++ this._iconsBox.get_children() ++ .find(icon => icon.icon_name === item.iconName) ++ ?.destroy(); ++ } ++ }); ++ ++ super.clearItems(searchCriteria); ++ this.updateDescriptionLabel(); ++ } ++ ++ updateDescriptionLabel() { ++ const [item] = this._items.size === 1 ? this.getItems() : []; ++ this._descriptionLabel.text = item?.description ?? ''; ++ } ++ ++ // Override to force visibility even when there's only one item ++ _updateVisibility() { ++ } ++} +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index ec6dee70b2..105076b40e 100644 +--- a/js/js-resources.gresource.xml ++++ b/js/js-resources.gresource.xml +@@ -2,6 +2,7 @@ + + + gdm/authList.js ++ gdm/authMenuButton.js + gdm/authPrompt.js + gdm/batch.js + gdm/constants.js +-- +2.51.0 + + +From b016701668a727633c2a0684d72d12861da03597 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 11:11:32 -0500 +Subject: [PATCH 31/48] loginDialog: Port sessions menu over to AuthMenuButton + +Now that AuthMenuButton exists, we should use it. + +This commit changes the session menu over to use the new +control. +--- + js/gdm/loginDialog.js | 178 +++++++++++++++--------------------------- + 1 file changed, 64 insertions(+), 114 deletions(-) + +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index 8b0402ffdc..c85706f1d4 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -20,9 +20,9 @@ + const { AccountsService, Atk, Clutter, Gdm, Gio, + GLib, GObject, Meta, Pango, Shell, St } = imports.gi; + ++const AuthMenuButton = imports.gdm.authMenuButton; + const AuthPrompt = imports.gdm.authPrompt; + const Batch = imports.gdm.batch; +-const BoxPointer = imports.ui.boxpointer; + const CtrlAltTab = imports.ui.ctrlAltTab; + const GdmUtil = imports.gdm.util; + const Layout = imports.ui.layout; +@@ -36,6 +36,7 @@ const _FADE_ANIMATION_TIME = 250; + const _SCROLL_ANIMATION_TIME = 500; + const _FIXED_TOP_ACTOR_HEIGHT = 400; + const _TIMED_LOGIN_IDLE_THRESHOLD = 5.0; ++const _SESSION_TYPE_SECTION_NAME = _('Session Type'); + + var UserListItem = GObject.registerClass({ + Signals: { 'activate': {} }, +@@ -305,102 +306,6 @@ var UserList = GObject.registerClass({ + } + }); + +-var SessionMenuButton = GObject.registerClass({ +- Signals: { 'session-activated': { param_types: [GObject.TYPE_STRING] } }, +-}, class SessionMenuButton extends St.Bin { +- _init() { +- let gearIcon = new St.Icon({ icon_name: 'emblem-system-symbolic' }); +- let button = new St.Button({ +- style_class: 'modal-dialog-button button login-dialog-session-list-button', +- reactive: true, +- track_hover: true, +- can_focus: true, +- accessible_name: _("Choose Session"), +- accessible_role: Atk.Role.MENU, +- x_align: Clutter.ActorAlign.CENTER, +- y_align: Clutter.ActorAlign.CENTER, +- child: gearIcon, +- }); +- +- super._init({ child: button }); +- this._button = button; +- +- this._menu = new PopupMenu.PopupMenu(this._button, 0, St.Side.BOTTOM); +- Main.uiGroup.add_actor(this._menu.actor); +- this._menu.actor.hide(); +- +- this._menu.connect('open-state-changed', (menu, isOpen) => { +- if (isOpen) +- this._button.add_style_pseudo_class('active'); +- else +- this._button.remove_style_pseudo_class('active'); +- }); +- +- this._manager = new PopupMenu.PopupMenuManager(this._button, +- { actionMode: Shell.ActionMode.NONE }); +- this._manager.addMenu(this._menu); +- +- this._button.connect('clicked', () => this._menu.toggle()); +- +- this._items = {}; +- this._activeSessionId = null; +- this._populate(); +- } +- +- updateSensitivity(sensitive) { +- this._button.reactive = sensitive; +- this._button.can_focus = sensitive; +- this.opacity = sensitive ? 255 : 0; +- this._menu.close(BoxPointer.PopupAnimation.NONE); +- } +- +- _updateOrnament() { +- let itemIds = Object.keys(this._items); +- for (let i = 0; i < itemIds.length; i++) { +- if (itemIds[i] == this._activeSessionId) +- this._items[itemIds[i]].setOrnament(PopupMenu.Ornament.DOT); +- else +- this._items[itemIds[i]].setOrnament(PopupMenu.Ornament.NONE); +- } +- } +- +- setActiveSession(sessionId) { +- if (sessionId == this._activeSessionId) +- return; +- +- this._activeSessionId = sessionId; +- this._updateOrnament(); +- } +- +- close() { +- this._menu.close(); +- } +- +- _populate() { +- let ids = Gdm.get_session_ids(); +- ids.sort(); +- +- if (ids.length <= 1) { +- this._button.hide(); +- return; +- } +- +- for (let i = 0; i < ids.length; i++) { +- let [sessionName, sessionDescription_] = Gdm.get_session_name_and_description(ids[i]); +- +- let id = ids[i]; +- let item = new PopupMenu.PopupMenuItem(sessionName); +- this._menu.addMenuItem(item); +- this._items[id] = item; +- +- item.connect('activate', () => { +- this.setActiveSession(id); +- this.emit('session-activated', this._activeSessionId); +- }); +- } +- } +-}); +- + var LoginDialog = GObject.registerClass({ + Signals: { + 'failed': {}, +@@ -453,6 +358,7 @@ var LoginDialog = GObject.registerClass({ + this._authPrompt.connect('prompted', this._onPrompted.bind(this)); + this._authPrompt.connect('reset', this._onReset.bind(this)); + this._authPrompt.connect('verification-complete', this._onVerificationComplete.bind(this)); ++ this._authPrompt.connect('loading', this._onLoading.bind(this)); + this._authPrompt.hide(); + this.add_child(this._authPrompt); + +@@ -500,14 +406,7 @@ var LoginDialog = GObject.registerClass({ + }); + this.add_child(this._bottomButtonGroup); + +- this._sessionMenuButton = new SessionMenuButton(); +- this._sessionMenuButton.connect('session-activated', +- (list, sessionId) => { +- this._greeter.call_select_session_sync(sessionId, null); +- }); +- this._sessionMenuButton.opacity = 0; +- this._sessionMenuButton.show(); +- this._bottomButtonGroup.add_child(this._sessionMenuButton); ++ this._createAuthMenuButton(); + + this._logoBin = new St.Widget({ style_class: 'login-dialog-logo-bin', + x_align: Clutter.ActorAlign.CENTER, +@@ -538,6 +437,54 @@ var LoginDialog = GObject.registerClass({ + this._updateDisableUserList.bind(this)); + } + ++ _createAuthMenuButton() { ++ this._authMenuButton = new AuthMenuButton.AuthMenuButton({ ++ title: _('Login Options'), ++ iconName: 'cog-wheel-symbolic', ++ sectionOrder: [_SESSION_TYPE_SECTION_NAME], ++ }); ++ this._authMenuButton.updateSensitivity(false); ++ ++ this._updateSessions(); ++ ++ this._authMenuButton.connect('active-item-changed', (_button, sectionName) => { ++ const item = this._authMenuButton.getActiveItem({sectionName}); ++ if (!item) ++ return; ++ ++ if (sectionName === _SESSION_TYPE_SECTION_NAME) ++ this._greeter.call_select_session_sync(item.id, null); ++ ++ this._authMenuButton.closeMenu(); ++ }); ++ this._bottomButtonGroup.add_child(this._authMenuButton); ++ } ++ ++ _updateSessions() { ++ this._authMenuButton.clearItems({ ++ sectionName: _SESSION_TYPE_SECTION_NAME, ++ }); ++ ++ const ids = Gdm.get_session_ids(); ++ if (ids.length <= 1) ++ return; ++ ++ const sessions = ids.map(id => { ++ const [sessionName] = Gdm.get_session_name_and_description(id); ++ return {id, sessionName}; ++ }); ++ ++ sessions.sort((a, b) => a.sessionName.localeCompare(b.sessionName)); ++ ++ for (const {id, sessionName} of sessions) { ++ this._authMenuButton.addItem({ ++ sectionName: _SESSION_TYPE_SECTION_NAME, ++ name: sessionName, ++ id, ++ }); ++ } ++ } ++ + _getBannerAllocation(dialogBox) { + let actorBox = new Clutter.ActorBox(); + +@@ -865,10 +812,8 @@ var LoginDialog = GObject.registerClass({ + } + + _onPrompted() { +- const showSessionMenu = this._shouldShowSessionMenuButton(); ++ this._authMenuButton.updateSensitivity(this._shouldShowAuthMenu()); + +- this._sessionMenuButton.updateSensitivity(showSessionMenu); +- this._sessionMenuButton.visible = showSessionMenu; + this._showPrompt(); + } + +@@ -890,7 +835,6 @@ var LoginDialog = GObject.registerClass({ + + _onReset(authPrompt, resetType) { + this._resetGreeterProxy(); +- this._sessionMenuButton.updateSensitivity(true); + + const previousUser = this._user; + this._user = null; +@@ -924,11 +868,18 @@ var LoginDialog = GObject.registerClass({ + }); + } + ++ _onLoading(_authPrompt, isLoading) { ++ this._authMenuButton.updateReactive(!isLoading); ++ } ++ + _onDefaultSessionChanged(client, sessionId) { +- this._sessionMenuButton.setActiveSession(sessionId); ++ this._authMenuButton.setActiveItem({ ++ sectionName: _SESSION_TYPE_SECTION_NAME, ++ id: sessionId, ++ }); + } + +- _shouldShowSessionMenuButton() { ++ _shouldShowAuthMenu() { + const visibleStatuses = [ + AuthPrompt.AuthPromptStatus.VERIFYING, + AuthPrompt.AuthPromptStatus.VERIFICATION_FAILED, +@@ -990,7 +941,7 @@ var LoginDialog = GObject.registerClass({ + }); + this._updateCancelButton(); + +- this._sessionMenuButton.updateSensitivity(false); ++ this._authMenuButton.updateSensitivity(false); + this._authPrompt.updateSensitivity({sensitive: true}); + this._showPrompt(); + } +@@ -1213,8 +1164,7 @@ var LoginDialog = GObject.registerClass({ + this._ensureUserListLoaded(); + this._authPrompt.hide(); + this._hideBannerView(); +- this._sessionMenuButton.close(); +- this._sessionMenuButton.hide(); ++ this._authMenuButton.updateSensitivity(false); + this._setUserListExpanded(true); + this._notListedButton.show(); + this._userList.grab_key_focus(); +-- +2.51.0 + + +From 22aca4284e6e61d2e7ddede78a96484a3bab1225 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 13:40:26 -0500 +Subject: [PATCH 32/48] loginDialog: Add login options menu to AuthMenuButton +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Now the authMenuButton allows selecting a session type and a login +mechanism. + +authPrompt is in charge of: + * Informing of the available login mechanisms via the "mechanisms-changed" + signal, it also provides one between them that is selected by default. + * Getting the selected login mechanism with "selectMechanism" method. + +In future commits the "mechanisms-selected" signal and the "selectMechanism" +method of UserVerifier will be implemented. + +There are some mechanisms that are not selectable but run in the +background, e.g. fingerprint. These ones won't be at _authMenuButton but +at the future _authIndicatorButton to inform when they are enabled. + +Co-authored-by: Marco Trevisan (Treviño) +--- + js/gdm/authPrompt.js | 22 +++++++++++++++++++++- + js/gdm/loginDialog.js | 43 +++++++++++++++++++++++++++++++++++++++++-- + js/gdm/util.js | 5 +++++ + 3 files changed, 67 insertions(+), 3 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 0aca776324..74828a2c42 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -49,7 +49,8 @@ var AuthPrompt = GObject.registerClass({ + 'failed': {}, + 'next': {}, + 'prompted': {}, +- 'reset': { param_types: [GObject.TYPE_UINT] }, ++ 'mechanisms-changed': {param_types: [GObject.TYPE_JSOBJECT, GObject.TYPE_JSOBJECT]}, ++ 'reset': {param_types: [GObject.TYPE_UINT]}, + 'verification-complete': {}, + 'loading': {param_types: [GObject.TYPE_BOOLEAN]}, + }, +@@ -84,6 +85,7 @@ var AuthPrompt = GObject.registerClass({ + 'ask-question', this._onAskQuestion.bind(this), + 'show-message', this._onShowMessage.bind(this), + 'show-choice-list', this._onShowChoiceList.bind(this), ++ 'mechanisms-changed', (_, ...args) => this.emit('mechanisms-changed', ...args), + 'verification-failed', this._onVerificationFailed.bind(this), + 'verification-complete', this._onVerificationComplete.bind(this), + 'reset', this._onReset.bind(this), +@@ -775,6 +777,24 @@ var AuthPrompt = GObject.registerClass({ + this.updateSensitivity(false); + } + ++ selectMechanism(mechanism) { ++ const invalidStatus = [ ++ AuthPromptStatus.VERIFICATION_SUCCEEDED, ++ AuthPromptStatus.VERIFICATION_IN_PROGRESS, ++ ]; ++ if (invalidStatus.includes(this.verificationStatus)) ++ return false; ++ ++ const oldPromptStep = this._promptStep; ++ this._promptStep = 0; ++ if (!this._userVerifier.selectMechanism(mechanism)) ++ this._promptStep = oldPromptStep; ++ ++ this._updateCancelButton(); ++ ++ return true; ++ } ++ + reset(params) { + let {reuseEntryText, softReset} = Params.parse(params, { + reuseEntryText: false, +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index c85706f1d4..ab085fae49 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -36,6 +36,7 @@ const _FADE_ANIMATION_TIME = 250; + const _SCROLL_ANIMATION_TIME = 500; + const _FIXED_TOP_ACTOR_HEIGHT = 400; + const _TIMED_LOGIN_IDLE_THRESHOLD = 5.0; ++const _PRIMARY_LOGIN_METHOD_SECTION_NAME = _('Login Options'); + const _SESSION_TYPE_SECTION_NAME = _('Session Type'); + + var UserListItem = GObject.registerClass({ +@@ -359,6 +360,7 @@ var LoginDialog = GObject.registerClass({ + this._authPrompt.connect('reset', this._onReset.bind(this)); + this._authPrompt.connect('verification-complete', this._onVerificationComplete.bind(this)); + this._authPrompt.connect('loading', this._onLoading.bind(this)); ++ this._authPrompt.connect('mechanisms-changed', this._onMechanismsChanged.bind(this)); + this._authPrompt.hide(); + this.add_child(this._authPrompt); + +@@ -441,7 +443,7 @@ var LoginDialog = GObject.registerClass({ + this._authMenuButton = new AuthMenuButton.AuthMenuButton({ + title: _('Login Options'), + iconName: 'cog-wheel-symbolic', +- sectionOrder: [_SESSION_TYPE_SECTION_NAME], ++ sectionOrder: [_PRIMARY_LOGIN_METHOD_SECTION_NAME, _SESSION_TYPE_SECTION_NAME], + }); + this._authMenuButton.updateSensitivity(false); + +@@ -452,7 +454,9 @@ var LoginDialog = GObject.registerClass({ + if (!item) + return; + +- if (sectionName === _SESSION_TYPE_SECTION_NAME) ++ if (sectionName === _PRIMARY_LOGIN_METHOD_SECTION_NAME) ++ this._selectAuthMechanism(item); ++ else if (sectionName === _SESSION_TYPE_SECTION_NAME) + this._greeter.call_select_session_sync(item.id, null); + + this._authMenuButton.closeMenu(); +@@ -485,6 +489,20 @@ var LoginDialog = GObject.registerClass({ + } + } + ++ _selectAuthMechanism(authMechanism) { ++ const oldMechanism = this._selectedAuthMechanism; ++ ++ if (authMechanism === oldMechanism) ++ return; ++ ++ if (!this._authPrompt.selectMechanism(authMechanism)) { ++ this._authMenuButton.setActiveItem(oldMechanism); ++ return; ++ } ++ ++ this._selectedAuthMechanism = authMechanism; ++ } ++ + _getBannerAllocation(dialogBox) { + let actorBox = new Clutter.ActorBox(); + +@@ -872,6 +890,27 @@ var LoginDialog = GObject.registerClass({ + this._authMenuButton.updateReactive(!isLoading); + } + ++ _onMechanismsChanged(_authPrompt, mechanisms, selectedMechanism) { ++ this._authMenuButton.clearItems({ ++ sectionName: _PRIMARY_LOGIN_METHOD_SECTION_NAME, ++ }); ++ ++ if (mechanisms.length === 0) ++ return; ++ ++ for (const m of mechanisms) { ++ if (GdmUtil.isSelectable(m)) { ++ this._authMenuButton.addItem({ ++ sectionName: _PRIMARY_LOGIN_METHOD_SECTION_NAME, ++ ...m, ++ }); ++ } ++ } ++ ++ if (Object.keys(selectedMechanism).length > 0) ++ this._authMenuButton.setActiveItem(selectedMechanism); ++ } ++ + _onDefaultSessionChanged(client, sessionId) { + this._authMenuButton.setActiveItem({ + sectionName: _SESSION_TYPE_SECTION_NAME, +diff --git a/js/gdm/util.js b/js/gdm/util.js +index 691442ad90..0ff74b4fa1 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -244,6 +244,11 @@ var ShellUserVerifier = class { + this._getUserVerifier(); + } + ++ selectMechanism(mechanism) { ++ // TODO: Implement mechanism selection ++ return false; ++ } ++ + cancel() { + if (this._cancellable) + this._cancellable.cancel(); +-- +2.51.0 + + +From 5794aa112c8fab1ed14fa68758a74e6d4a714aad Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 13:41:39 -0500 +Subject: [PATCH 33/48] unlockDialog: Add _authMenuButton and + _authIndicatorButton + +_authMenuButton is used to select an available auth mechanism from the +unlock screen. It's in the bottom right corner of the screen. + +_authIndicatorButton is used to show indicators for background (non +selectable) auth mechanisms. It's in the bottom left corner of the screen. + +Make _otherUserButton written out, instead of using an icon. And move it to +the bottom right corner of the screen, with _authMenuButton. +Only show _otherUserButton when clock is dismissed and authentication +prompt visible. +--- + js/ui/unlockDialog.js | 157 +++++++++++++++++++++++++++++++++++++----- + 1 file changed, 140 insertions(+), 17 deletions(-) + +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index 07b2acaf45..306b64b55d 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -4,6 +4,9 @@ + const { AccountsService, Atk, Clutter, Gdm, Gio, + GnomeDesktop, GLib, GObject, Meta, Shell, St } = imports.gi; + ++const AuthMenuButton = imports.gdm.authMenuButton; ++const GdmConstants = imports.gdm.constants; ++const GdmUtil = imports.gdm.util; + const Background = imports.ui.background; + const Layout = imports.ui.layout; + const Main = imports.ui.main; +@@ -12,6 +15,8 @@ const SwipeTracker = imports.ui.swipeTracker; + + const AuthPrompt = imports.gdm.authPrompt; + ++const PRIMARY_UNLOCK_METHOD_SECTION_NAME = _('Unlock Options'); ++ + // The timeout before going back automatically to the lock screen (in seconds) + const IDLE_TIMEOUT = 2 * 60; + +@@ -404,12 +409,13 @@ class UnlockDialogClock extends St.BoxLayout { + + var UnlockDialogLayout = GObject.registerClass( + class UnlockDialogLayout extends Clutter.LayoutManager { +- _init(stack, notifications, switchUserButton) { ++ _init(stack, notifications, authIndicatorButton, bottomButtonGroup) { + super._init(); + + this._stack = stack; + this._notifications = notifications; +- this._switchUserButton = switchUserButton; ++ this._authIndicatorButton = authIndicatorButton; ++ this._bottomButtonGroup = bottomButtonGroup; + } + + vfunc_get_preferred_width(container, forHeight) { +@@ -469,22 +475,40 @@ class UnlockDialogLayout extends Clutter.LayoutManager { + + this._stack.allocate(actorBox); + +- // Switch User button +- if (this._switchUserButton.visible) { +- let [, , natWidth, natHeight] = +- this._switchUserButton.get_preferred_size(); ++ // Auth Indicator button (left bottom) ++ if (this._authIndicatorButton.visible) { ++ const [, , natWidth, natHeight] = ++ this._authIndicatorButton.get_preferred_size(); + +- const textDirection = this._switchUserButton.get_text_direction(); ++ const textDirection = this._authIndicatorButton.get_text_direction(); + if (textDirection === Clutter.TextDirection.RTL) +- actorBox.x1 = box.x1 + natWidth; ++ actorBox.x1 = box.x2 - natWidth; + else +- actorBox.x1 = box.x2 - (natWidth * 2); ++ actorBox.x1 = box.x1; + +- actorBox.y1 = box.y2 - (natHeight * 2); ++ actorBox.y1 = box.y2 - natHeight; + actorBox.x2 = actorBox.x1 + natWidth; + actorBox.y2 = actorBox.y1 + natHeight; + +- this._switchUserButton.allocate(actorBox); ++ this._authIndicatorButton.allocate(actorBox); ++ } ++ ++ // bottom button group, (has login options and switch user buttons) (right bottom) ++ if (this._bottomButtonGroup.visible) { ++ const [, , natWidth, natHeight] = ++ this._bottomButtonGroup.get_preferred_size(); ++ ++ const textDirection = this._bottomButtonGroup.get_text_direction(); ++ if (textDirection === Clutter.TextDirection.RTL) ++ actorBox.x1 = box.x1; ++ else ++ actorBox.x1 = box.x2 - natWidth; ++ ++ actorBox.y1 = box.y2 - natHeight; ++ actorBox.x2 = actorBox.x1 + natWidth; ++ actorBox.y2 = actorBox.y1 + natHeight; ++ ++ this._bottomButtonGroup.allocate(actorBox); + } + } + }); +@@ -589,19 +613,47 @@ var UnlockDialog = GObject.registerClass({ + this._notificationsBox = new NotificationsBox(); + this._notificationsBox.connect('wake-up-screen', () => this.emit('wake-up-screen')); + ++ this._bottomButtonGroup = new St.BoxLayout({ ++ style_class: 'login-dialog-bottom-button-group', ++ }); ++ + // Switch User button + this._otherUserButton = new St.Button({ + style_class: 'modal-dialog-button button switch-user-button', + accessible_name: _('Log in as another user'), + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + reactive: false, +- opacity: 0, + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.END, +- child: new St.Icon({ icon_name: 'system-users-symbolic' }), ++ label: _('Switch User…'), + }); + this._otherUserButton.set_pivot_point(0.5, 0.5); + this._otherUserButton.connect('clicked', this._otherUserClicked.bind(this)); ++ this._bottomButtonGroup.add_child(this._otherUserButton); ++ ++ // Login Options button ++ this._authMenuButton = new AuthMenuButton.AuthMenuButton({ ++ title: _('Login Options'), ++ iconName: 'cog-wheel-symbolic', ++ }); ++ this._authMenuButton.connect('active-item-changed', () => { ++ const authMechanism = this._authMenuButton.getActiveItem(); ++ if (!authMechanism) ++ return; ++ ++ this._selectAuthMechanism(authMechanism); ++ this._authMenuButton.closeMenu(); ++ }); ++ this._authMenuButton.updateSensitivity(true); ++ this._bottomButtonGroup.add_child(this._authMenuButton); ++ ++ // Auth Indicators ++ this._authIndicatorButton = new AuthMenuButton.AuthMenuButtonIndicator({ ++ title: _('Background Authentication Methods'), ++ animateVisibility: true, ++ }); ++ this._authIndicatorButton.set_pivot_point(0.5, 0.5); ++ this._authIndicatorButton.updateSensitivity(true); + + this._screenSaverSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.screensaver' }); + +@@ -618,11 +670,13 @@ var UnlockDialog = GObject.registerClass({ + mainBox.add_constraint(new Layout.MonitorConstraint({ primary: true })); + mainBox.add_child(this._stack); + mainBox.add_child(this._notificationsBox); +- mainBox.add_child(this._otherUserButton); ++ mainBox.add_child(this._authIndicatorButton); ++ mainBox.add_child(this._bottomButtonGroup); + mainBox.layout_manager = new UnlockDialogLayout( + this._stack, + this._notificationsBox, +- this._otherUserButton); ++ this._authIndicatorButton, ++ this._bottomButtonGroup); + this.add_child(mainBox); + + this._idleMonitor = Meta.IdleMonitor.get_core(); +@@ -653,6 +707,20 @@ var UnlockDialog = GObject.registerClass({ + return Clutter.EVENT_PROPAGATE; + } + ++ _selectAuthMechanism(authMechanism) { ++ const oldMechanism = this._selectedAuthMechanism; ++ ++ if (authMechanism === oldMechanism) ++ return; ++ ++ if (!this._authPrompt.selectMechanism(authMechanism)) { ++ this._authMenuButton.setActiveItem(oldMechanism); ++ return; ++ } ++ ++ this._selectedAuthMechanism = authMechanism; ++ } ++ + _createBackground(monitorIndex) { + let monitor = Main.layoutManager.monitors[monitorIndex]; + let widget = new St.Widget({ +@@ -709,6 +777,8 @@ var UnlockDialog = GObject.registerClass({ + this._authPrompt.connect('failed', this._fail.bind(this)); + this._authPrompt.connect('cancelled', this._fail.bind(this)); + this._authPrompt.connect('reset', this._onReset.bind(this)); ++ this._authPrompt.connect('loading', this._onLoading.bind(this)); ++ this._authPrompt.connect('mechanisms-changed', this._onMechanismsChanged.bind(this)); + this._promptBox.add_child(this._authPrompt); + } + +@@ -771,6 +841,12 @@ var UnlockDialog = GObject.registerClass({ + reactive: progress > 0, + can_focus: progress > 0, + }); ++ this._authIndicatorButton.set({ ++ opacity: 255 * progress, ++ scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, ++ scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, ++ }); ++ this._updateUserSwitchVisibility(); + + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + +@@ -788,7 +864,7 @@ var UnlockDialog = GObject.registerClass({ + translation_y: -FADE_OUT_TRANSLATION * progress * scaleFactor, + }); + +- this._otherUserButton.set({ ++ this._bottomButtonGroup.set({ + opacity: 255 * progress, + scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, + scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, +@@ -812,6 +888,52 @@ var UnlockDialog = GObject.registerClass({ + this._authPrompt.begin({ userName }); + } + ++ _onLoading(_authPrompt, isLoading) { ++ this._authMenuButton.updateReactive(!isLoading); ++ } ++ ++ _onMechanismsChanged(_authPrompt, mechanisms, selectedMechanism) { ++ this._authMenuButton.clearItems({ ++ sectionName: PRIMARY_UNLOCK_METHOD_SECTION_NAME, ++ }); ++ ++ this._authIndicatorButton.clearItems(); ++ ++ if (mechanisms.length === 0) ++ return; ++ ++ for (const m of mechanisms) { ++ if (GdmUtil.isSelectable(m)) { ++ this._authMenuButton.addItem({ ++ sectionName: PRIMARY_UNLOCK_METHOD_SECTION_NAME, ++ ...m, ++ }); ++ } else { ++ this._authIndicatorButton.addItem({ ++ iconName: GdmUtil.getNonSelectableIconName(m), ++ description: this._getUnlockDescription(m), ++ ...m, ++ }); ++ } ++ } ++ ++ if (Object.keys(selectedMechanism).length > 0) ++ this._authMenuButton.setActiveItem(selectedMechanism); ++ ++ this._authIndicatorButton.updateDescriptionLabel(); ++ } ++ ++ _getUnlockDescription(mechanism) { ++ // This is only used for non selectable mechanisms. ++ // Currently only fingerprint is non selectable ++ switch (mechanism.role) { ++ case GdmConstants.FINGERPRINT_ROLE_NAME: ++ return _('Unlock with fingerprint'); ++ default: ++ throw new Error(`Failed getting unlock description: ${mechanism.role}`); ++ } ++ } ++ + _escape() { + if (this._authPrompt && this.allowCancel) + this._authPrompt.cancel(); +@@ -893,7 +1015,8 @@ var UnlockDialog = GObject.registerClass({ + + _updateUserSwitchVisibility() { + this._otherUserButton.visible = this._userManager.can_switch() && +- this._screenSaverSettings.get_boolean('user-switch-enabled'); ++ this._screenSaverSettings.get_boolean('user-switch-enabled') && ++ this._promptBox.visible; + } + + cancel() { +-- +2.51.0 + + +From 3725f050c3be47873916e27a5ddee471b16d3764 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 3 Dec 2024 07:39:32 -0500 +Subject: [PATCH 34/48] unlockDialog: Update hint text based on mockup + +This considers future mechanisms which might be the default ones, i.e. +smartcard and passkey. And have special hint texts. +--- + js/ui/unlockDialog.js | 14 +++++++++++--- + 1 file changed, 11 insertions(+), 3 deletions(-) + +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index 306b64b55d..db6f1d4ba1 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -393,9 +393,17 @@ class UnlockDialogClock extends St.BoxLayout { + } + + _updateHint() { +- this._hint.text = this._seat.touch_mode +- ? _('Swipe up to unlock') +- : _('Click or press a key to unlock'); ++ const authMechanism = this._selectedAuthMechanism; ++ let text; ++ ++ if (authMechanism?.role === GdmConstants.SMARTCARD_ROLE_NAME) ++ text = _('Insert smartcard'); ++ else if (this._seat.touch_mode) ++ text = _('Swipe up'); ++ else ++ text = _('Click or press a key'); ++ ++ this._hint.text = text; + } + + _onDestroy() { +-- +2.51.0 + + +From 01988c6b13a40b2383edaf3cca1be0bc402e3102 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 14 Oct 2025 19:19:15 +0200 +Subject: [PATCH 35/48] gdm/util: Increase time of messages based on new + environment variable + +called 'GDM_MESSAGE_TIME_MULTIPLIER'. This is used for testing +purposes. When no set, the multiplier is 1 which does nothing. +--- + js/gdm/util.js | 7 ++++++- + 1 file changed, 6 insertions(+), 1 deletion(-) + +diff --git a/js/gdm/util.js b/js/gdm/util.js +index 0ff74b4fa1..ae35e5bc5c 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -46,6 +46,11 @@ var DISABLE_USER_LIST_KEY = 'disable-user-list'; + + // Give user 48ms to read each character of a PAM message + var USER_READ_TIME = 48; ++const MESSAGE_TIME_MULTIPLIER = (() => { ++ const value = Number.parseFloat(GLib.getenv('GDM_MESSAGE_TIME_MULTIPLIER')); ++ return Number.isFinite(value) && value > 0 ? value : 1; ++})(); ++ + const FINGERPRINT_SERVICE_PROXY_TIMEOUT = 5000; + const FINGERPRINT_ERROR_TIMEOUT_WAIT = 15; + +@@ -323,7 +328,7 @@ var ShellUserVerifier = class { + return 0; + + // We probably could be smarter here +- return message.length * USER_READ_TIME; ++ return message.length * USER_READ_TIME * MESSAGE_TIME_MULTIPLIER; + } + + finishMessageQueue() { +-- +2.51.0 + + +From 8329dbcdeaa0f5dc1b3e37a994d7396a3b508ce1 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 15 Sep 2025 17:13:17 +0200 +Subject: [PATCH 36/48] gdm/util: Allow null _hold and don't recreate dummy + holds + +_hold property is used to inform the caller of begin method (authPrompt) +when it's started the verification (calling acquire) and when it +finished starting it, either successfully or with an error (calling +release). + +There's no need to create dummy holds that won't be used anywhere +externally. + +In the next commits the use of _hold will be simplified considerably, +but for now, just accept getting null and dont create dummy ones. +--- + js/gdm/authPrompt.js | 6 +----- + js/gdm/util.js | 15 ++++++--------- + 2 files changed, 7 insertions(+), 14 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 74828a2c42..a7ec1dcda4 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -868,11 +868,7 @@ var AuthPrompt = GObject.registerClass({ + + this.updateSensitivity({sensitive: false}); + +- let hold = params.hold; +- if (!hold) +- hold = new Batch.Hold(); +- +- this._userVerifier.begin(params.userName, hold); ++ this._userVerifier.begin(params.userName, params.hold); + this.verificationStatus = AuthPromptStatus.VERIFYING; + } + +diff --git a/js/gdm/util.js b/js/gdm/util.js +index ae35e5bc5c..e862e2c0e7 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -519,8 +519,6 @@ var ShellUserVerifier = class { + + if (this._userVerifier && + !this._activeServices.has(Constants.FINGERPRINT_SERVICE_NAME)) { +- if (!this._hold?.isAcquired()) +- this._hold = new Batch.Hold(); + await this._maybeStartFingerprintVerification(); + } + } +@@ -583,7 +581,7 @@ var ShellUserVerifier = class { + + _reportInitError(where, error, serviceName) { + logError(error, where); +- this._hold.release(); ++ this._hold?.release(); + + this._queueMessage(serviceName, _('Authentication error'), MessageType.ERROR); + this._failCounter++; +@@ -619,7 +617,7 @@ var ShellUserVerifier = class { + this.reauthenticating = true; + this._connectSignals(); + this._beginVerification(); +- this._hold.release(); ++ this._hold?.release(); + } + + async _getUserVerifier() { +@@ -641,7 +639,7 @@ var ShellUserVerifier = class { + + this._connectSignals(); + this._beginVerification(); +- this._hold.release(); ++ this._hold?.release(); + } + + _connectSignals() { +@@ -760,7 +758,7 @@ var ShellUserVerifier = class { + } + + async _startService(serviceName) { +- this._hold.acquire(); ++ this._hold?.acquire(); + try { + this._activeServices.add(serviceName); + if (this._userName) { +@@ -776,7 +774,7 @@ var ShellUserVerifier = class { + return; + if (!this.serviceIsForeground(serviceName)) { + logError(e, 'Failed to start %s for %s'.format(serviceName, this._userName)); +- this._hold.release(); ++ this._hold?.release(); + return; + } + this._reportInitError(this._userName +@@ -785,7 +783,7 @@ var ShellUserVerifier = class { + serviceName); + return; + } +- this._hold.release(); ++ this._hold?.release(); + } + + _beginVerification() { +@@ -911,7 +909,6 @@ var ShellUserVerifier = class { + } + + _retry(serviceName) { +- this._hold = new Batch.Hold(); + this._connectSignals(); + this._startService(serviceName); + } +-- +2.51.0 + + +From 43d82e9611eb899c345b972633ad279a4b71d6df Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Wed, 20 Aug 2025 10:54:33 +0200 +Subject: [PATCH 37/48] misc: Add FingerprintManager + +Move fingerprint bits to new fingerprintManager class. + +This helps keeping util simpler and tries to encapsulate fingerprint +functionality inside fingerprintManager. + +This will be improved in the next commits when moving fingerprint +authentication to a specific class instead of util. +--- + js/gdm/util.js | 159 ++++++++-------------------------- + js/js-resources.gresource.xml | 1 + + js/misc/fingerprintManager.js | 138 +++++++++++++++++++++++++++++ + 3 files changed, 174 insertions(+), 124 deletions(-) + create mode 100644 js/misc/fingerprintManager.js + +diff --git a/js/gdm/util.js b/js/gdm/util.js +index e862e2c0e7..1176f77500 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -7,6 +7,7 @@ const Signals = imports.signals; + + const Batch = imports.gdm.batch; + const Constants = imports.gdm.constants; ++const {FingerprintManager, FingerprintReaderType} = imports.misc.fingerprintManager; + const OVirt = imports.gdm.oVirt; + const Vmware = imports.gdm.vmware; + const Main = imports.ui.main; +@@ -14,11 +15,6 @@ const { loadInterfaceXML } = imports.misc.fileUtils; + const Params = imports.misc.params; + const SmartcardManager = imports.misc.smartcardManager; + +-const FprintManagerInfo = Gio.DBusInterfaceInfo.new_for_xml( +- loadInterfaceXML('net.reactivated.Fprint.Manager')); +-const FprintDeviceInfo = Gio.DBusInterfaceInfo.new_for_xml( +- loadInterfaceXML('net.reactivated.Fprint.Device')); +- + Gio._promisify(Gdm.Client.prototype, + 'open_reauthentication_channel', 'open_reauthentication_channel_finish'); + Gio._promisify(Gdm.Client.prototype, +@@ -62,12 +58,6 @@ var MessageType = { + ERROR: 3, + }; + +-const FingerprintReaderType = { +- NONE: 0, +- PRESS: 1, +- SWIPE: 2, +-}; +- + function fadeInActor(actor) { + if (actor.opacity == 255 && actor.visible) + return null; +@@ -187,6 +177,7 @@ var ShellUserVerifier = class { + this._defaultService = null; + this._preemptingService = null; + this._fingerprintReaderType = FingerprintReaderType.NONE; ++ this._fingerprintReaderFound = false; + + this._messageQueue = []; + this._messageQueueTimeoutId = 0; +@@ -238,8 +229,7 @@ var ShellUserVerifier = class { + this._userName = userName; + this.reauthenticating = false; + +- this._checkForFingerprintReader().catch(e => +- this._handleFingerprintError(e)); ++ this._fingerprintManager?.checkReaderType(this._cancellable); + + // If possible, reauthenticate an already running session, + // so any session specific credentials get updated appropriately +@@ -297,6 +287,7 @@ var ShellUserVerifier = class { + this._smartcardManager?.disconnect(this._smartcardRemovedId); + this._smartcardManager = null; + ++ this._fingerprintManager?.disconnectObject(this); + this._fingerprintManager = null; + + for (let service in this._credentialManagers) { +@@ -414,121 +405,39 @@ var ShellUserVerifier = class { + } + + async _initFingerprintManager() { +- if (this._fprintManager) +- return; +- +- const fprintManager = new Gio.DBusProxy({ +- g_connection: Gio.DBus.system, +- g_name: 'net.reactivated.Fprint', +- g_object_path: '/net/reactivated/Fprint/Manager', +- g_interface_name: FprintManagerInfo.name, +- g_interface_info: FprintManagerInfo, +- g_flags: Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES | +- Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION | +- Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS, +- }); +- +- try { +- if (!this._getDetectedDefaultService()) { +- // Other authentication methods would have already been detected by +- // now as possibilities if they were available. +- // If we're here it means that FINGERPRINT_AUTHENTICATION_KEY is +- // true and so fingerprint authentication is our last potential +- // option, so go ahead a synchronously look for a fingerprint device +- // during startup or default service update. +- fprintManager.init(null); +- // Do not wait too much for fprintd to reply, as in case it hangs +- // we should fail early without having the shell to misbehave +- fprintManager.set_default_timeout(FINGERPRINT_SERVICE_PROXY_TIMEOUT); +- +- const result = fprintManager.call_sync( +- 'GetDefaultDevice', +- null, +- Gio.DBusCallFlags.NONE, +- FINGERPRINT_SERVICE_PROXY_TIMEOUT, +- null); +- const [devicePath] = result.deep_unpack(); +- this._fprintManager = fprintManager; +- +- const fprintDeviceProxy = this._getFingerprintDeviceProxy(devicePath); +- fprintDeviceProxy.init(null); +- this._setFingerprintReaderType(fprintDeviceProxy['scan-type']); +- } else { +- // Ensure fingerprint service starts, but do not wait for it +- const cancellable = this._cancellable; +- await fprintManager.init_async(GLib.PRIORITY_DEFAULT, cancellable); +- await this._updateFingerprintReaderType(fprintManager, cancellable); +- this._fprintManager = fprintManager; +- } +- } catch (e) { +- this._handleFingerprintError(e); +- } +- } +- +- _getFingerprintDeviceProxy(devicePath) { +- return new Gio.DBusProxy({ +- g_connection: Gio.DBus.system, +- g_name: 'net.reactivated.Fprint', +- g_object_path: devicePath, +- g_interface_name: FprintDeviceInfo.name, +- g_interface_info: FprintDeviceInfo, +- g_flags: Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS, +- }); +- } +- +- _handleFingerprintError(e) { +- this._fingerprintReaderType = FingerprintReaderType.NONE; +- +- if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) +- return; +- if (e.matches(Gio.DBusError, Gio.DBusError.SERVICE_UNKNOWN)) ++ if (this._fingerprintManager) + return; + +- if (Gio.DBusError.is_remote_error(e) && +- Gio.DBusError.get_remote_error(e) === +- 'net.reactivated.Fprint.Error.NoSuchDevice') +- return; +- +- logError(e, 'Failed to interact with fprintd service'); +- } +- +- async _checkForFingerprintReader() { +- if (!this._fprintManager) { +- this._updateDefaultService(); +- return; ++ this._fingerprintManager = new FingerprintManager(this._cancellable); ++ this._fingerprintManager.connectObject( ++ 'reader-type-changed', () => this._onFingerprintReaderTypeChanged(), ++ this); ++ ++ if (!this._getDetectedDefaultService()) { ++ // Other authentication methods would have already been detected by ++ // now as possibilities if they were available. ++ // If we're here it means that FINGERPRINT_AUTHENTICATION_KEY is ++ // true and so fingerprint authentication is our last potential ++ // option, so go ahead a synchronously look for a fingerprint device ++ // during startup or default service update. ++ // Do not wait too much for fprintd to reply, as in case it hangs ++ // we should fail early without having the shell to misbehave ++ this._fingerprintManager.setDefaultTimeout(FINGERPRINT_SERVICE_PROXY_TIMEOUT); ++ await this._fingerprintManager.checkReaderType(this._cancellable); ++ } else { ++ // Ensure fingerprint service starts, but do not wait for it ++ this._fingerprintManager.checkReaderType(this._cancellable); + } +- +- if (this._fingerprintReaderType !== FingerprintReaderType.NONE) +- return; +- +- await this._updateFingerprintReaderType(this._fprintManager, this._cancellable); + } + +- async _updateFingerprintReaderType(fprintManager, cancellable) { +- const result = await fprintManager.call( +- 'GetDefaultDevice', +- null, +- Gio.DBusCallFlags.NONE, +- -1, +- cancellable); +- const [devicePath] = result.deep_unpack(); +- const fprintDeviceProxy = this._getFingerprintDeviceProxy(devicePath); +- await fprintDeviceProxy.init_async(GLib.PRIORITY_DEFAULT, cancellable); +- this._setFingerprintReaderType(fprintDeviceProxy['scan-type']); ++ _onFingerprintReaderTypeChanged() { ++ this._fingerprintReaderType = this._fingerprintManager.readerType; ++ this._fingerprintReaderFound = this._fingerprintManager.readerFound; + this._updateDefaultService(); + + if (this._userVerifier && +- !this._activeServices.has(Constants.FINGERPRINT_SERVICE_NAME)) { +- await this._maybeStartFingerprintVerification(); +- } +- } +- +- _setFingerprintReaderType(fprintDeviceType) { +- this._fingerprintReaderType = +- FingerprintReaderType[fprintDeviceType.toUpperCase()]; +- +- if (this._fingerprintReaderType === undefined) +- throw new Error(`Unexpected fingerprint device type '${fprintDeviceType}'`); ++ !this._activeServices.has(Constants.FINGERPRINT_SERVICE_NAME)) ++ this._maybeStartFingerprintVerification(); + } + + _onCredentialManagerAuthenticated(credentialManager, _token) { +@@ -693,7 +602,7 @@ var ShellUserVerifier = class { + } + + serviceIsFingerprint(serviceName) { +- return this._fingerprintReaderType !== FingerprintReaderType.NONE && ++ return this._fingerprintReaderFound && + serviceName === Constants.FINGERPRINT_SERVICE_NAME; + } + +@@ -706,9 +615,11 @@ var ShellUserVerifier = class { + let needsReset = false; + + if (this._settings.get_boolean(FINGERPRINT_AUTHENTICATION_KEY)) { +- this._initFingerprintManager().catch(logError); ++ this._initFingerprintManager(); + } else if (this._fingerprintManager) { ++ this._fingerprintManager.disconnectObject(this); + this._fingerprintManager = null; ++ this._fingerprintReaderFound = false; + this._fingerprintReaderType = FingerprintReaderType.NONE; + + if (this._activeServices.has(Constants.FINGERPRINT_SERVICE_NAME)) +@@ -737,7 +648,7 @@ var ShellUserVerifier = class { + return Constants.PASSWORD_SERVICE_NAME; + else if (this._smartcardManager) + return Constants.SMARTCARD_SERVICE_NAME; +- else if (this._fingerprintReaderType !== FingerprintReaderType.NONE) ++ else if (this._fingerprintReaderFound) + return Constants.FINGERPRINT_SERVICE_NAME; + return null; + } +@@ -793,7 +704,7 @@ var ShellUserVerifier = class { + + async _maybeStartFingerprintVerification() { + if (this._userName && +- this._fingerprintReaderType !== FingerprintReaderType.NONE && ++ this._fingerprintReaderFound && + !this.serviceIsForeground(Constants.FINGERPRINT_SERVICE_NAME)) + await this._startService(Constants.FINGERPRINT_SERVICE_NAME); + } +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index 105076b40e..ad4057d585 100644 +--- a/js/js-resources.gresource.xml ++++ b/js/js-resources.gresource.xml +@@ -15,6 +15,7 @@ + + misc/config.js + misc/extensionUtils.js ++ misc/fingerprintManager.js + misc/fileUtils.js + misc/gnomeSession.js + misc/history.js +diff --git a/js/misc/fingerprintManager.js b/js/misc/fingerprintManager.js +new file mode 100644 +index 0000000000..6ef2d68d3a +--- /dev/null ++++ b/js/misc/fingerprintManager.js +@@ -0,0 +1,138 @@ ++import Gio from 'gi://Gio'; ++import GLib from 'gi://GLib'; ++import GObject from 'gi://GObject'; ++ ++import {loadInterfaceXML} from './fileUtils.js'; ++ ++const FprintManagerInfo = Gio.DBusInterfaceInfo.new_for_xml( ++ loadInterfaceXML('net.reactivated.Fprint.Manager')); ++const FprintDeviceInfo = Gio.DBusInterfaceInfo.new_for_xml( ++ loadInterfaceXML('net.reactivated.Fprint.Device')); ++ ++export const FingerprintReaderType = { ++ NONE: 0, ++ PRESS: 1, ++ SWIPE: 2, ++}; ++ ++let _fingerprintManager = null; ++ ++/** ++ * @returns {FingerprintManager} ++ */ ++export function getFingerprintManager() { ++ if (_fingerprintManager == null) ++ _fingerprintManager = new FingerprintManager(); ++ ++ return _fingerprintManager; ++} ++ ++class FingerprintManager extends GObject.Object { ++ static [GObject.properties] = { ++ 'reader-type': GObject.ParamSpec.uint( ++ 'reader-type', null, null, ++ GObject.ParamFlags.READWRITE, ++ FingerprintReaderType.NONE, FingerprintReaderType.SWIPE, ++ FingerprintReaderType.NONE), ++ }; ++ ++ static [GObject.signals] = { ++ 'reader-type-changed': {}, ++ }; ++ ++ static { ++ GObject.registerClass(this); ++ } ++ ++ constructor() { ++ super(); ++ ++ this._fingerprintManagerProxy = new Gio.DBusProxy({ ++ g_connection: Gio.DBus.system, ++ g_name: 'net.reactivated.Fprint', ++ g_object_path: '/net/reactivated/Fprint/Manager', ++ g_interface_name: FprintManagerInfo.name, ++ g_interface_info: FprintManagerInfo, ++ g_flags: Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES | ++ Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION | ++ Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS, ++ }); ++ ++ this._initFingerprintManagerProxy(); ++ } ++ ++ get readerFound() { ++ return this.readerType !== FingerprintReaderType.NONE; ++ } ++ ++ setDefaultTimeout(timeout) { ++ this._fingerprintManagerProxy.set_default_timeout(timeout); ++ } ++ ++ async checkReaderType(cancellable = null) { ++ try { ++ // Wrappers don't support null cancellable, so let's ignore it in case ++ const args = cancellable ? [cancellable] : []; ++ const [devicePath] = ++ await this._fingerprintManagerProxy.GetDefaultDeviceAsync(...args); ++ ++ const fprintDeviceProxy = this._getFprintDeviceProxy(devicePath); ++ await fprintDeviceProxy.init_async(GLib.PRIORITY_DEFAULT, cancellable ?? null); ++ ++ const fingerprintReaderKey = fprintDeviceProxy['scan-type'].toUpperCase(); ++ const fingerprintReaderType = FingerprintReaderType[fingerprintReaderKey]; ++ this._setFingerprintReaderType(fingerprintReaderType); ++ } catch (e) { ++ if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) ++ return; ++ this._handleFingerprintError(e); ++ } ++ } ++ ++ async _initFingerprintManagerProxy() { ++ try { ++ await this._fingerprintManagerProxy.init_async( ++ GLib.PRIORITY_DEFAULT, null); ++ await this.checkReaderType(); ++ } catch (e) { ++ this._handleFingerprintError(e); ++ } ++ } ++ ++ _getFprintDeviceProxy(devicePath) { ++ return new Gio.DBusProxy({ ++ g_connection: Gio.DBus.system, ++ g_name: 'net.reactivated.Fprint', ++ g_object_path: devicePath, ++ g_interface_name: FprintDeviceInfo.name, ++ g_interface_info: FprintDeviceInfo, ++ g_flags: Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS, ++ }); ++ } ++ ++ _setFingerprintReaderType(fingerprintReaderType) { ++ if (fingerprintReaderType === undefined) ++ throw new Error(`Unexpected fingerprint device type '${fingerprintReaderType}'`); ++ ++ if (this.readerType === fingerprintReaderType) ++ return; ++ ++ this.readerType = fingerprintReaderType; ++ this.emit('reader-type-changed'); ++ } ++ ++ _handleFingerprintError(e) { ++ this._setFingerprintReaderType(FingerprintReaderType.NONE); ++ ++ if (e instanceof GLib.Error) { ++ if (e.matches(Gio.DBusError, Gio.DBusError.SERVICE_UNKNOWN)) ++ return; ++ if (Gio.DBusError.is_remote_error(e) && ++ Gio.DBusError.get_remote_error(e) === ++ 'net.reactivated.Fprint.Error.NoSuchDevice') ++ return; ++ } ++ ++ logError(e, 'Failed to interact with fprintd service'); ++ } ++} +-- +2.51.0 + + +From 17023fb0e31b0b18ef5cc47eeca2f08f38132ee7 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 23 Sep 2025 16:45:59 +0200 +Subject: [PATCH 38/48] misc: Add PasskeyDeviceManager + +This utility will be used in the next commits, when passkey authentication +is implemented, to detect when a passkey has been inserted or removed. + +To detect if a sysfs device is a passkey (fido2), it's been used the +implementation of systemd in fido_id_desc.c. +--- + js/js-resources.gresource.xml | 1 + + js/misc/passkeyDeviceManager.js | 69 +++++++++++++++++++++++++++++++++ + 2 files changed, 70 insertions(+) + create mode 100644 js/misc/passkeyDeviceManager.js + +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index ad4057d585..df6dbf5070 100644 +--- a/js/js-resources.gresource.xml ++++ b/js/js-resources.gresource.xml +@@ -29,6 +29,7 @@ + misc/objectManager.js + misc/params.js + misc/parentalControlsManager.js ++ misc/passkeyDeviceManager.js + misc/permissionStore.js + misc/smartcardManager.js + misc/systemActions.js +diff --git a/js/misc/passkeyDeviceManager.js b/js/misc/passkeyDeviceManager.js +new file mode 100644 +index 0000000000..20799975aa +--- /dev/null ++++ b/js/misc/passkeyDeviceManager.js +@@ -0,0 +1,69 @@ ++import GObject from 'gi://GObject'; ++import GUdev from 'gi://GUdev'; ++ ++let _passkeyDeviceManager = null; ++ ++/** ++ * @returns {PasskeyDeviceManager} ++ */ ++export function getPasskeyDeviceManager() { ++ if (_passkeyDeviceManager == null) ++ _passkeyDeviceManager = new PasskeyDeviceManager(); ++ ++ return _passkeyDeviceManager; ++} ++ ++class PasskeyDeviceManager extends GObject.Object { ++ static [GObject.signals] = { ++ 'passkey-inserted': {param_types: [GObject.TYPE_JSOBJECT]}, ++ 'passkey-removed': {param_types: [GObject.TYPE_JSOBJECT]}, ++ }; ++ ++ static { ++ GObject.registerClass(this); ++ } ++ ++ constructor() { ++ super(); ++ ++ this._insertedPasskeys = new Map(); ++ this._udevClient = new GUdev.Client({subsystems: ['hidraw']}); ++ ++ this._onLoaded(); ++ } ++ ++ get hasInsertedPasskeys() { ++ return this._insertedPasskeys.size > 0; ++ } ++ ++ _onLoaded() { ++ this._udevClient.query_by_subsystem('hidraw') ++ .forEach(d => this._addPasskey(d)); ++ ++ this._udevClient.connect('uevent', (_, action, device) => { ++ if (action === 'add') ++ this._addPasskey(device); ++ else if (action === 'remove') ++ this._removePasskey(device); ++ }); ++ } ++ ++ _addPasskey(device) { ++ const sysfsPath = device.get_sysfs_path(); ++ const isFido = device.get_property_as_int('ID_FIDO_TOKEN') === 1; ++ if (!isFido || this._insertedPasskeys.has(sysfsPath)) ++ return; ++ ++ this._insertedPasskeys.set(sysfsPath, device); ++ this.emit('passkey-inserted', device); ++ } ++ ++ _removePasskey(device) { ++ const sysfsPath = device.get_sysfs_path(); ++ if (!this._insertedPasskeys.has(sysfsPath)) ++ return; ++ ++ this._insertedPasskeys.delete(sysfsPath); ++ this.emit('passkey-removed', device); ++ } ++} +-- +2.51.0 + + +From 9d32a080ea06fcdc7b653b7984d80ee7dfe62fcb Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 10 Mar 2026 14:27:50 +0100 +Subject: [PATCH 39/48] misc/smartcardManager: Skip login_token aliases to + avoid duplicate events + +Tokens with '/login_token' paths are aliases mirroring already +connected tokens. Connecting to both causes duplicate events. +--- + js/misc/smartcardManager.js | 6 ++++++ + 1 file changed, 6 insertions(+) + +diff --git a/js/misc/smartcardManager.js b/js/misc/smartcardManager.js +index 26f9f5aaa9..cd95741b6c 100644 +--- a/js/misc/smartcardManager.js ++++ b/js/misc/smartcardManager.js +@@ -66,6 +66,12 @@ var SmartcardManager = class { + } + + _addToken(token) { ++ // This is an alias, mirroring the real token. ++ // Skip it to avoid duplicate events ++ const objectPath = token.get_object_path(); ++ if (objectPath.endsWith('/login_token')) ++ return; ++ + this._updateToken(token); + + token.connect('g-properties-changed', (proxy, properties) => { +-- +2.51.0 + + +From fe23b7b14fa5c4c2f42e48603b6030e3e7e5f51d Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 18 Aug 2025 12:08:21 +0200 +Subject: [PATCH 40/48] gdm: Add AuthServices +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +util.js had become overly complex, handling too many decoupled responsibilities. +To improve readability and maintainability, it's been introduced a dedicated +class to manage authentication services and their respective logic that +was previously in util.js. + +AuthServices is the base class that defines the common interface (signals +like "ask-question", "verification-complete", "mechanisms-changed", etc.) and +shared logic for authentication handling, while its childs like AuthServicesLegacy +are derived implementations that handle different authentication backends. + +The Legacy naming in AuthServicesLegacy distinguishes the traditional PAM-based +architecture (rigid PAM messages and different services) from the upcoming +unified approach (JSON PAM messages and unified single service). It is important +to note that it's not deprecated; it remains the primary handler for standard GDM/PAM +services (gdm-password, gdm-smartcard, gdm-fingerprint). + +The default workflow of authServices is: +1. beginVerification method is called with userName (optional) and + new userVerifierProxies. +2. 'mechanisms-changed' signal is emitted with the current enabled + mechanisms and a default selected one. +3. Calling selectMechanism method makes the selected authentication + mechanism start (will do nothing if mechanism was already selected). +4. When a mechanism is started, depending on the mechanism, it will + emit signals like 'ask-question', 'show-choice-list'..., and receive + feedback with methods like answerQuery or selectChoice. +5. After the back and forth of signals and methods, the authentication + can succeed ('verification-complete') or fail ('verification-failed'). +6. If the verification fails, authServices is in charge of requesting a reset, + either soft (reusing the username and the selected mechanism) or hard + (full reset). + +The main properties of authServices are: +* _enabledRoles: Contains the roles that are currently enabled in GDM settings. + Valid roles are defined in constants.js (PASSWORD_ROLE_NAME, SMARTCARD_ROLE_NAME...). + This determines which mechanisms are included in _enabledMechanisms. +* _enabledMechanisms: Contains the mechanisms that are currently enabled. These + are the ones sent on 'mechanisms-changed'. +* _selectedMechanism: It's the currently selected mechanism. Similar of + what foregroundService was in UserVerifier. It must be one mechanism of the + ones in _enabledMechanisms. +* _userVerifier, and its siblings: They're the proxies used to have PAM + conversations with different PAM services trough the GDM API. + This is the interface that requests a password, a selection from a choice + list, etc. and gets the responses. + +The authentication of password, smartcard and fingerprint now is +implemented in the child class called AuthServicesLegacy. +1. It implements a hardcoded array of mechanisms based on roles (password, + smartcard and fingerprint). +2. Each mechanism is driven by a different PAM service, and _roleToService + is used to decide which service to use when starting a selected mechanism. +3. When a mechanism is selected, it's emited 'reset' signal with softReset: true + which will start a conversation reusing the username and the selected + mechanism. +4. Fingerprint mechanism is non selectable, so it will be started + automatically on beginVerification, if it's enabled. +5. Authentication services from credentialManagers are also supported, they + won't be shown in the mechanisms list, but they will be automatically started + if they have preauthenticated credentials. + +Based on the previous work done by: + - Marco Trevisan (Treviño) + - Ray Strode +--- + js/gdm/authPrompt.js | 39 +- + js/gdm/authServices.js | 468 +++++++++++++++++++++ + js/gdm/authServicesLegacy.js | 334 +++++++++++++++ + js/gdm/util.js | 742 ++++++++-------------------------- + js/js-resources.gresource.xml | 2 + + po/POTFILES.in | 1 + + 6 files changed, 988 insertions(+), 598 deletions(-) + create mode 100644 js/gdm/authServices.js + create mode 100644 js/gdm/authServicesLegacy.js + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index a7ec1dcda4..5a92c725b5 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -6,7 +6,6 @@ const { Clutter, Gio, GLib, GObject, Graphene, Meta, Pango, Shell, St } = import + const Animation = imports.ui.animation; + const AuthList = imports.gdm.authList; + const Batch = imports.gdm.batch; +-const Constants = imports.gdm.constants; + const GdmUtil = imports.gdm.util; + const OVirt = imports.gdm.oVirt; + const Vmware = imports.gdm.vmware; +@@ -89,10 +88,7 @@ var AuthPrompt = GObject.registerClass({ + 'verification-failed', this._onVerificationFailed.bind(this), + 'verification-complete', this._onVerificationComplete.bind(this), + 'reset', this._onReset.bind(this), +- 'smartcard-status-changed', this._onSmartcardStatusChanged.bind(this), +- 'credential-manager-authenticated', this._onCredentialManagerAuthenticated.bind(this), + this); +- this.smartcardDetected = this._userVerifier.smartcardDetected; + + this.connect('destroy', this._onDestroy.bind(this)); + +@@ -444,31 +440,6 @@ var AuthPrompt = GObject.registerClass({ + this.emit('prompted'); + } + +- _onCredentialManagerAuthenticated() { +- if (this.verificationStatus != AuthPromptStatus.VERIFICATION_SUCCEEDED) +- this.reset(); +- } +- +- _onSmartcardStatusChanged() { +- this.smartcardDetected = this._userVerifier.smartcardDetected; +- +- // Most of the time we want to reset if the user inserts or removes +- // a smartcard. Smartcard insertion "preempts" what the user was +- // doing, and smartcard removal aborts the preemption. +- // The exceptions are: 1) Don't reset on smartcard insertion if we're already verifying +- // with a smartcard +- // 2) Don't reset if we've already succeeded at verification and +- // the user is getting logged in. +- if (this._userVerifier.serviceIsDefault(Constants.SMARTCARD_SERVICE_NAME) && +- (this.verificationStatus === AuthPromptStatus.VERIFYING || +- this.verificationStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS) && +- this.smartcardDetected) +- return; +- +- if (this.verificationStatus != AuthPromptStatus.VERIFICATION_SUCCEEDED) +- this.reset(); +- } +- + _onShowMessage(_userVerifier, serviceName, message, type) { + this.setMessage(serviceName, message, type); + +@@ -813,8 +784,10 @@ var AuthPrompt = GObject.registerClass({ + this._preemptiveAnswerWatchId = this._idleMonitor.add_idle_watch(500, + this._onUserStoppedTypePreemptiveAnswer.bind(this)); + +- if (this._userVerifier) +- this._userVerifier.cancel(); ++ if (softReset) ++ this._userVerifier?.cancel(); ++ else ++ this._userVerifier?.reset(); + + reuseEntryText = reuseEntryText || this._preemptiveInput; + +@@ -838,9 +811,7 @@ var AuthPrompt = GObject.registerClass({ + if (oldStatus === AuthPromptStatus.VERIFICATION_CANCELLED) + return; + resetType = ResetType.PROVIDE_USERNAME; +- } else if (this._userVerifier.serviceIsForeground(OVirt.SERVICE_NAME) || +- this._userVerifier.serviceIsForeground(Vmware.SERVICE_NAME) || +- this._userVerifier.serviceIsForeground(GdmUtil.SMARTCARD_SERVICE_NAME)) { ++ } else if (!this._userVerifier.needsUsername()) { + // We don't need to know the username if the user preempted the login screen + // with a smartcard or with preauthenticated oVirt credentials + resetType = ResetType.DONT_PROVIDE_USERNAME; +diff --git a/js/gdm/authServices.js b/js/gdm/authServices.js +new file mode 100644 +index 0000000000..d522ee352e +--- /dev/null ++++ b/js/gdm/authServices.js +@@ -0,0 +1,468 @@ ++// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- ++ ++import * as FingerprintManager from '../misc/fingerprintManager.js'; ++import * as Params from '../misc/params.js'; ++import {registerDestroyableType} from '../misc/signalTracker.js'; ++import * as SmartcardManager from '../misc/smartcardManager.js'; ++import {logErrorUnlessCancelled} from '../misc/errorUtils.js'; ++import * as Util from './util.js'; ++import Gdm from 'gi://Gdm'; ++import GLib from 'gi://GLib'; ++import Gio from 'gi://Gio'; ++import GObject from 'gi://GObject'; ++ ++Gio._promisify(Gdm.Client.prototype, 'open_reauthentication_channel'); ++Gio._promisify(Gdm.Client.prototype, 'get_user_verifier'); ++Gio._promisify(Gdm.UserVerifierProxy.prototype, 'call_begin_verification_for_user'); ++Gio._promisify(Gdm.UserVerifierProxy.prototype, 'call_begin_verification'); ++Gio._promisify(Gdm.UserVerifierProxy.prototype, 'call_answer_query'); ++Gio._promisify(Gdm.UserVerifierChoiceListProxy.prototype, 'call_select_choice'); ++ ++export class AuthServices extends GObject.Object { ++ static [GObject.signals] = { ++ 'destroy': {}, ++ 'queue-message': { ++ param_types: [GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_UINT], ++ }, ++ 'queue-priority-message': { ++ param_types: [GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_UINT], ++ }, ++ 'wait-pending-messages': { ++ param_types: [GObject.TYPE_JSOBJECT], ++ }, ++ 'filter-messages': { ++ param_types: [GObject.TYPE_STRING, GObject.TYPE_UINT], ++ }, ++ 'verification-failed': { ++ param_types: [GObject.TYPE_STRING, GObject.TYPE_BOOLEAN], ++ }, ++ 'verification-complete': {}, ++ 'ask-question': { ++ param_types: [GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_BOOLEAN], ++ }, ++ 'reset': { ++ param_types: [GObject.TYPE_JSOBJECT], ++ }, ++ 'show-choice-list': { ++ param_types: [GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_JSOBJECT], ++ }, ++ 'mechanisms-changed': {}, ++ }; ++ ++ static { ++ GObject.registerClass(this); ++ registerDestroyableType(this); ++ } ++ ++ static SupportedRoles = []; ++ static RoleToService = {}; ++ ++ static supportsAny(roles) { ++ return roles.some(r => this.SupportedRoles.includes(r)); ++ } ++ ++ constructor(params) { ++ super(); ++ params = Params.parse(params, { ++ client: null, ++ enabledRoles: [], ++ allowedFailures: 3, ++ reauthOnly: false, ++ }); ++ ++ this._client = params.client; ++ this._enabledRoles = params.enabledRoles; ++ this._allowedFailures = params.allowedFailures; ++ this._reauthOnly = params.reauthOnly; ++ ++ this._failCounter = 0; ++ this._activeServices = new Set(); ++ this._unavailableServices = new Set(); ++ ++ this._cancellable = null; ++ ++ this._connectSmartcardManager(); ++ this._connectFingerprintManager(); ++ } ++ ++ get selectedMechanism() { ++ return this._selectedMechanism; ++ } ++ ++ get enabledMechanisms() { ++ return this._enabledMechanisms; ++ } ++ ++ get _roleToService() { ++ return this.constructor.RoleToService; ++ } ++ ++ get supportedRoles() { ++ return this.constructor.SupportedRoles; ++ } ++ ++ selectChoice(serviceName, key) { ++ this._handleSelectChoice(serviceName, key); ++ } ++ ++ answerQuery(serviceName, answer) { ++ this._handleAnswerQuery(serviceName, answer); ++ } ++ ++ async beginVerification(userName, userVerifierProxies) { ++ this._cancellable?.cancel(); ++ this._cancellable = new Gio.Cancellable(); ++ this._userName = userName; ++ ++ try { ++ this._updateUserVerifier(userVerifierProxies); ++ await this._startServices(this._cancellable); ++ } catch (e) { ++ if (e.error?.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) ++ return; ++ ++ this._failCounter++; ++ throw e; ++ } ++ ++ this._handleBeginVerification(); ++ } ++ ++ selectMechanism(mechanism) { ++ if (this._selectedMechanism?.role === mechanism.role && ++ this._selectedMechanism?.serviceName === mechanism.serviceName) ++ return false; ++ ++ this._selectedMechanism = this._enabledMechanisms?.find(m => ++ m.role === mechanism.role && ++ m.serviceName === mechanism.serviceName ++ ); ++ ++ this._handleSelectMechanism(); ++ ++ return !!this._selectedMechanism; ++ } ++ ++ needsUsername() { ++ return this._handleNeedsUsername(); ++ } ++ ++ reset() { ++ this._failCounter = 0; ++ ++ this._handleReset(); ++ } ++ ++ cancel() { ++ this._handleCancel(); ++ } ++ ++ destroy() { ++ this.reset(); ++ this.clear(); ++ this.emit('destroy'); ++ } ++ ++ clear() { ++ this._cancellable?.cancel(); ++ this._cancellable = null; ++ ++ this._unavailableServices.clear(); ++ this._activeServices.clear(); ++ ++ this._verificationComplete = false; ++ ++ this._clearUserVerifier(); ++ ++ this._handleClear(); ++ } ++ ++ _clearUserVerifier() { ++ this._disconnectUserVerifierSignals(); ++ this._userVerifier = null; ++ this._userVerifierChoiceList = null; ++ this._userVerifierCustomJSON = null; ++ } ++ ++ _disconnectUserVerifierSignals() { ++ this._userVerifier?.get_connection().disconnectObject(this); ++ this._userVerifier?.disconnectObject(this); ++ this._userVerifierChoiceList?.disconnectObject(this); ++ this._userVerifierCustomJSON?.disconnectObject(this); ++ } ++ ++ _updateEnabledMechanisms() { ++ this._enabledMechanisms = []; ++ ++ this._handleUpdateEnabledMechanisms(); ++ ++ this.emit('mechanisms-changed'); ++ } ++ ++ _connectSmartcardManager() { ++ this._smartcardManager = SmartcardManager.getSmartcardManager(); ++ this._smartcardManager.connectObject( ++ 'smartcard-inserted', () => this._handleSmartcardChanged(), ++ 'smartcard-removed', () => this._handleSmartcardChanged(), ++ this); ++ } ++ ++ _connectFingerprintManager() { ++ // Fingerprint can only work on lockscreen ++ if (!this._reauthOnly) ++ return; ++ ++ this._fingerprintManager = FingerprintManager.getFingerprintManager(); ++ this._fingerprintManager.connectObject( ++ 'reader-type-changed', () => this._handleFingerprintChanged(), ++ this); ++ } ++ ++ _waitPendingMessages() { ++ const cancellable = this._cancellable; ++ const timeoutId = GLib.timeout_add_seconds_once(GLib.PRIORITY_DEFAULT, 10, ++ () => cancellable.cancel()); ++ ++ const {promise, resolve, reject} = Promise.withResolvers(); ++ const task = Gio.Task.new(this, cancellable, () => { ++ try { ++ const res = task.propagate_boolean(); ++ if (!res) ++ throw new GLib.Error(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED, 'Operation failed'); ++ resolve(); ++ } catch (e) { ++ reject(e); ++ } finally { ++ GLib.source_remove(timeoutId); ++ } ++ }); ++ ++ this.emit('wait-pending-messages', task); ++ ++ return promise; ++ } ++ ++ _updateUserVerifier(proxies) { ++ this._disconnectUserVerifierSignals(); ++ this._userVerifier = proxies.userVerifier; ++ this._userVerifierChoiceList = proxies.userVerifierChoiceList; ++ this._userVerifierCustomJSON = proxies.userVerifierCustomJSON; ++ this._connectUserVerifierSignals(); ++ } ++ ++ _connectUserVerifierSignals() { ++ this._userVerifier.get_connection().connectObject( ++ 'closed', () => this._clearUserVerifier(), ++ this); ++ ++ this._userVerifier.connectObject( ++ 'info', (_, ...args) => this._onInfo(...args), ++ 'problem', (_, ...args) => this._onProblem(...args), ++ 'info-query', (_, ...args) => this._onInfoQuery(...args), ++ 'secret-info-query', (_, ...args) => this._onSecretInfoQuery(...args), ++ 'conversation-started', (_, ...args) => this._onConversationStarted(...args), ++ 'conversation-stopped', (_, ...args) => this._onConversationStopped(...args), ++ 'service-unavailable', (_, ...args) => this._onServiceUnavailable(...args), ++ 'reset', () => this.emit('reset', {}), ++ 'verification-complete', (_, ...args) => this._onVerificationComplete(...args), ++ this); ++ ++ this._userVerifierChoiceList?.connectObject( ++ 'choice-query', (_, ...args) => this._onChoiceListQuery(...args), ++ this); ++ ++ this._userVerifierCustomJSON?.connectObject( ++ 'request', (_, ...args) => this._onCustomJSONRequest(...args), ++ this); ++ } ++ ++ _onInfo(serviceName, info) { ++ this._handleOnInfo(serviceName, info); ++ } ++ ++ _onProblem(serviceName, problem) { ++ this._handleOnProblem(serviceName, problem); ++ } ++ ++ _onInfoQuery(serviceName, question) { ++ this._handleOnInfoQuery(serviceName, question); ++ } ++ ++ _onSecretInfoQuery(serviceName, secretQuestion) { ++ this._handleOnSecretInfoQuery(serviceName, secretQuestion); ++ } ++ ++ _onConversationStarted(serviceName) { ++ this._activeServices.add(serviceName); ++ ++ this._handleOnConversationStarted(serviceName); ++ } ++ ++ _onConversationStopped(serviceName) { ++ this._activeServices.delete(serviceName); ++ ++ this.emit('filter-messages', serviceName, Util.MessageType.ERROR); ++ ++ this._handleOnConversationStopped(serviceName); ++ } ++ ++ _onServiceUnavailable(serviceName, errorMessage) { ++ this._unavailableServices.add(serviceName); ++ ++ if (this._selectedMechanism?.serviceName === serviceName && errorMessage) { ++ this.emit('queue-message', ++ serviceName, ++ errorMessage, ++ Util.MessageType.ERROR); ++ } ++ ++ this._handleOnServiceUnavailable(serviceName, errorMessage); ++ } ++ ++ _onVerificationComplete(serviceName) { ++ this._handleOnVerificationComplete(serviceName); ++ this.emit('verification-complete'); ++ } ++ ++ _onChoiceListQuery(serviceName, promptMessage, list) { ++ this._handleOnChoiceListQuery(serviceName, promptMessage, list); ++ } ++ ++ _onCustomJSONRequest(serviceName, protocol, version, json) { ++ this._handleOnCustomJSONRequest(serviceName, protocol, version, json); ++ } ++ ++ _canRetry() { ++ return this._userName && ++ (this._reauthOnly || this._failCounter < this._allowedFailures); ++ } ++ ++ async _verificationFailed(serviceName, shouldRetry) { ++ this._handleVerificationFailed(serviceName); ++ ++ const doneTrying = !shouldRetry || !this._canRetry(); ++ ++ this.emit('verification-failed', serviceName, !doneTrying); ++ ++ try { ++ await this._waitPendingMessages(); ++ this.emit('reset', {softReset: !doneTrying}); ++ } catch (e) { ++ logErrorUnlessCancelled(e); ++ } ++ } ++ ++ async _startServices(cancellable) { ++ for (const serviceName of this._getEnabledServices()) { ++ if (this._canStartService(serviceName)) { ++ // eslint-disable-next-line no-await-in-loop ++ await this._startService(serviceName, cancellable); ++ } ++ } ++ } ++ ++ _getEnabledServices() { ++ const services = this._enabledRoles ++ .map(r => this._roleToService[r]) ++ .filter(s => s); // filter undefined ++ ++ services.push(...this._getCredentialManagerServices()); ++ ++ // Remove duplicates ++ return [...new Set(services)]; ++ } ++ ++ _getCredentialManagerServices() { ++ return this._handleGetCredentialManagerServices(); ++ } ++ ++ _canStartService(serviceName) { ++ return !this._activeServices.has(serviceName) && ++ !this._unavailableServices.has(serviceName) && ++ this._handleCanStartService(serviceName); ++ } ++ ++ async _startService(serviceName, cancellable) { ++ try { ++ this._activeServices.add(serviceName); ++ if (this._userName) { ++ await this._userVerifier.call_begin_verification_for_user( ++ serviceName, this._userName, cancellable); ++ } else { ++ await this._userVerifier.call_begin_verification( ++ serviceName, cancellable); ++ } ++ } catch (e) { ++ this._activeServices.delete(serviceName); ++ if (e instanceof GLib.Error && ++ Gio.DBusError.is_remote_error(e) && ++ Gio.DBusError.get_remote_error(e) === ++ 'org.gnome.DisplayManager.SessionWorker.Error.ServiceUnavailable') ++ this._unavailableServices.add(serviceName); ++ ++ throw new Util.InitError(e, ++ this._userName ++ ? `Failed to start ${serviceName} verification for user` ++ : `Failed to start ${serviceName} verification`, ++ serviceName); ++ } ++ } ++ ++ _handleSelectChoice() {} ++ ++ _handleAnswerQuery() {} ++ ++ _handleBeginVerification() {} ++ ++ _handleSelectMechanism() {} ++ ++ _handleNeedsUsername() { ++ return true; ++ } ++ ++ _handleReset() {} ++ ++ _handleCancel() {} ++ ++ _handleClear() {} ++ ++ _handleUpdateEnabledMechanisms() { ++ throw new GObject.NotImplementedError( ++ `_handleUpdateEnabledMechanisms in ${this.constructor.name}`); ++ } ++ ++ _handleSmartcardChanged() {} ++ ++ _handleFingerprintChanged() {} ++ ++ _handleOnInfo() {} ++ ++ _handleOnProblem() {} ++ ++ _handleOnInfoQuery() {} ++ ++ _handleOnSecretInfoQuery() {} ++ ++ _handleOnConversationStarted() {} ++ ++ _handleOnConversationStopped() {} ++ ++ _handleOnServiceUnavailable() {} ++ ++ _handleOnVerificationComplete() {} ++ ++ _handleOnChoiceListQuery() {} ++ ++ _handleOnCustomJSONRequest() {} ++ ++ _handleVerificationFailed() {} ++ ++ _handleGetCredentialManagerServices() { ++ return []; ++ } ++ ++ _handleCanStartService() { ++ throw new GObject.NotImplementedError( ++ `_handleCanStartService in ${this.constructor.name}`); ++ } ++} +diff --git a/js/gdm/authServicesLegacy.js b/js/gdm/authServicesLegacy.js +new file mode 100644 +index 0000000000..e27107c85e +--- /dev/null ++++ b/js/gdm/authServicesLegacy.js +@@ -0,0 +1,334 @@ ++import GLib from 'gi://GLib'; ++import GObject from 'gi://GObject'; ++ ++import * as Constants from './constants.js'; ++import {FingerprintReaderType} from '../misc/fingerprintManager.js'; ++import {logErrorUnlessCancelled} from '../misc/errorUtils.js'; ++import * as OVirt from './oVirt.js'; ++import * as Util from './util.js'; ++import * as Vmware from './vmware.js'; ++import {AuthServices} from './authServices.js'; ++ ++const FINGERPRINT_ERROR_TIMEOUT_WAIT = 15; ++ ++const Mechanisms = [ ++ { ++ serviceName: Constants.PASSWORD_SERVICE_NAME, ++ role: Constants.PASSWORD_ROLE_NAME, ++ name: _('Password'), ++ }, ++ { ++ serviceName: Constants.SMARTCARD_SERVICE_NAME, ++ role: Constants.SMARTCARD_ROLE_NAME, ++ name: _('Smartcard'), ++ }, ++ { ++ serviceName: Constants.FINGERPRINT_SERVICE_NAME, ++ role: Constants.FINGERPRINT_ROLE_NAME, ++ name: _('Fingerprint'), ++ }, ++]; ++ ++export class AuthServicesLegacy extends AuthServices { ++ static SupportedRoles = [ ++ Constants.PASSWORD_ROLE_NAME, ++ Constants.SMARTCARD_ROLE_NAME, ++ Constants.FINGERPRINT_ROLE_NAME, ++ ]; ++ ++ static RoleToService = { ++ [Constants.PASSWORD_ROLE_NAME]: Constants.PASSWORD_SERVICE_NAME, ++ [Constants.SMARTCARD_ROLE_NAME]: Constants.SMARTCARD_SERVICE_NAME, ++ [Constants.FINGERPRINT_ROLE_NAME]: Constants.FINGERPRINT_SERVICE_NAME, ++ }; ++ ++ static { ++ GObject.registerClass(this); ++ } ++ ++ constructor(params) { ++ super(params); ++ ++ this._updateEnabledMechanisms(); ++ ++ this._credentialManagers = {}; ++ this.addCredentialManager(OVirt.SERVICE_NAME, OVirt.getOVirtCredentialsManager()); ++ this.addCredentialManager(Vmware.SERVICE_NAME, Vmware.getVmwareCredentialsManager()); ++ } ++ ++ _handleSelectChoice(serviceName, key) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ this._userVerifierChoiceList.call_select_choice( ++ serviceName, key, this._cancellable).catch(logErrorUnlessCancelled); ++ } ++ ++ _handleAnswerQuery(serviceName, answer) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ if (this._selectedMechanism.role === Constants.SMARTCARD_ROLE_NAME) ++ this._smartcardInProgress = true; ++ ++ this._userVerifier.call_answer_query( ++ serviceName, answer, this._cancellable).catch(logErrorUnlessCancelled); ++ } ++ ++ _handleBeginVerification() { ++ this._fingerprintManager?.checkReaderType(this._cancellable); ++ ++ this.emit('mechanisms-changed'); ++ } ++ ++ _handleSelectMechanism() { ++ if (this._selectedMechanism) ++ this.emit('reset', {softReset: true}); ++ } ++ ++ _handleNeedsUsername() { ++ // Username won't be needed when there's only one mechanism and is ++ // Smartcard, or if the selected mechanism is a credential manager ++ return !(this._enabledMechanisms.length === 1 && ++ this._enabledMechanisms[0].role === Constants.SMARTCARD_ROLE_NAME || ++ Object.keys(this._credentialManagers).includes(this._selectedMechanism?.serviceName)); ++ } ++ ++ _handleReset() { ++ this._selectedMechanism = null; ++ } ++ ++ _handleClear() { ++ this._smartcardInProgress = false; ++ } ++ ++ _handleUpdateEnabledMechanisms() { ++ if (!this._fingerprintManager?.readerFound) { ++ this._enabledMechanisms.push(...Mechanisms.filter(m => ++ this._enabledRoles.includes(m.role) && ++ m.role !== Constants.FINGERPRINT_ROLE_NAME ++ )); ++ } else { ++ this._enabledMechanisms.push(...Mechanisms.filter(m => ++ this._enabledRoles.includes(m.role) ++ )); ++ } ++ } ++ ++ _handleSmartcardChanged() { ++ if (this._selectedMechanism?.role !== Constants.SMARTCARD_ROLE_NAME || ++ this._smartcardInProgress && this._smartcardManager.hasInsertedTokens()) ++ return; ++ ++ this.emit('reset', {softReset: true}); ++ } ++ ++ _handleFingerprintChanged() { ++ if (!this._enabledRoles.includes(Constants.FINGERPRINT_ROLE_NAME)) ++ return; ++ ++ this._updateEnabledMechanisms(); ++ this.emit('reset', {softReset: true, reuseEntryText: true}); ++ } ++ ++ _handleOnInfo(serviceName, info) { ++ if (serviceName === this._selectedMechanism?.serviceName) { ++ this.emit('queue-message', serviceName, info, Util.MessageType.INFO); ++ } else if (serviceName === Constants.FINGERPRINT_SERVICE_NAME && ++ this._enabledMechanisms.some(m => m.serviceName === serviceName)) { ++ // We don't show fingerprint messages directly since it's ++ // not the main auth service. Instead we use the messages ++ // as a cue to display our own message. ++ this.emit('queue-message', ++ serviceName, ++ this._fingerprintManager?.readerType === FingerprintReaderType.SWIPE ++ // Translators: this message is shown below the password entry field ++ // to indicate the user can swipe their finger on the fingerprint reader ++ ? _('(or swipe finger across reader)') ++ // Translators: this message is shown below the password entry field ++ // to indicate the user can place their finger on the fingerprint reader instead ++ : _('(or place finger on reader)'), ++ Util.MessageType.HINT); ++ } ++ } ++ ++ _handleOnProblem(serviceName, problem) { ++ if (serviceName === this._selectedMechanism?.serviceName || ++ (serviceName === Constants.FINGERPRINT_SERVICE_NAME && ++ this._enabledMechanisms.some(m => m.serviceName === serviceName))) { ++ this.emit('queue-priority-message', ++ serviceName, ++ problem, ++ Util.MessageType.ERROR); ++ } ++ ++ if (serviceName === Constants.FINGERPRINT_SERVICE_NAME && ++ this._enabledMechanisms.some(m => m.serviceName === serviceName)) { ++ // pam_fprintd allows the user to retry multiple (maybe even infinite! ++ // times before failing the authentication conversation. ++ // We don't want this behavior to bypass the max-tries setting the user has set, ++ // so we count the problem messages to know how many times the user has failed. ++ // Once we hit the max number of failures we allow, it's time to failure the ++ // conversation from our side. We can't do that right away, however, because ++ // we may drop pending messages coming from pam_fprintd. In order to make sure ++ // the user sees everything, we queue the failure up to get handled in the ++ // near future, after we've finished up the current round of messages. ++ this._failCounter++; ++ ++ if (this._canRetry()) ++ return; ++ ++ if (this._fingerprintFailedId) ++ GLib.source_remove(this._fingerprintFailedId); ++ ++ this._fingerprintFailedId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ++ FINGERPRINT_ERROR_TIMEOUT_WAIT, () => { ++ this._fingerprintFailedId = 0; ++ if (!this._cancellable.is_cancelled()) ++ this._verificationFailed(serviceName, false); ++ return GLib.SOURCE_REMOVE; ++ }); ++ } ++ } ++ ++ _handleOnInfoQuery(serviceName, question) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ this.emit('ask-question', serviceName, question, false); ++ } ++ ++ _handleOnSecretInfoQuery(serviceName, secretQuestion) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ let token = null; ++ if (this._credentialManagers[serviceName]) ++ token = this._credentialManagers[serviceName].token; ++ ++ if (token) { ++ this.answerQuery(serviceName, token); ++ return; ++ } ++ ++ this.emit('ask-question', serviceName, secretQuestion, true); ++ } ++ ++ _handleOnConversationStopped(serviceName) { ++ if (serviceName !== this._selectedMechanism?.serviceName && ++ serviceName !== Constants.FINGERPRINT_SERVICE_NAME) ++ return; ++ ++ // If the login failed with the preauthenticated oVirt credentials ++ // then discard the credentials and revert to default authentication ++ // mechanism. ++ if (this._credentialManagers[serviceName]) { ++ this._credentialManagers[serviceName].token = null; ++ this._selectedMechanism = null; ++ this._verificationFailed(serviceName, false); ++ return; ++ } ++ ++ if (serviceName === Constants.FINGERPRINT_SERVICE_NAME && ++ this._unavailableServices.has(serviceName)) { ++ this._enabledMechanisms = this._enabledMechanisms ++ .filter(m => m.serviceName !== serviceName); ++ this.emit('mechanisms-changed'); ++ } ++ ++ if (this._unavailableServices.has(serviceName)) ++ return; ++ ++ // if the password service fails, then cancel everything. ++ // But if, e.g., fingerprint fails, still give ++ // password authentication a chance to succeed ++ if (serviceName === this._selectedMechanism?.serviceName) ++ this._failCounter++; ++ ++ this._verificationFailed(serviceName, true); ++ } ++ ++ _handleOnServiceUnavailable(serviceName, errorMessage) { ++ if (serviceName !== Constants.FINGERPRINT_SERVICE_NAME || ++ !this._enabledMechanisms.some(m => m.serviceName === serviceName) || ++ !errorMessage) ++ return; ++ ++ this.emit('queue-message', ++ serviceName, ++ errorMessage, ++ Util.MessageType.ERROR); ++ } ++ ++ _handleVerificationFailed(serviceName) { ++ if (serviceName === Constants.FINGERPRINT_SERVICE_NAME && ++ this._enabledMechanisms.some(m => m.serviceName === serviceName) && ++ this._fingerprintFailedId) ++ GLib.source_remove(this._fingerprintFailedId); ++ } ++ ++ _handleOnVerificationComplete(serviceName) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ if (this._credentialManagers[serviceName]) { ++ this._credentialManagers[serviceName].token = null; ++ this._selectedMechanism = null; ++ } ++ } ++ ++ _handleOnChoiceListQuery(serviceName, promptMessage, list) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ const choiceList = {}; ++ for (const [key, value] of Object.entries(list.deepUnpack())) ++ choiceList[key] = {title: value}; ++ ++ this.emit('show-choice-list', serviceName, promptMessage, choiceList); ++ } ++ ++ _handleGetCredentialManagerServices() { ++ return Object.keys(this._credentialManagers); ++ } ++ ++ _handleCanStartService(serviceName) { ++ return serviceName === this._selectedMechanism?.serviceName || ++ (serviceName === Constants.FINGERPRINT_SERVICE_NAME && ++ this._enabledMechanisms.some(m => m.serviceName === serviceName) && ++ this._userName); ++ } ++ ++ addCredentialManager(serviceName, credentialManager) { ++ if (this._credentialManagers[serviceName]) ++ return; ++ ++ this._credentialManagers[serviceName] = credentialManager; ++ if (credentialManager.token) ++ this._onCredentialManagerAuthenticated(credentialManager); ++ ++ credentialManager.connectObject( ++ 'user-authenticated', () => this._onCredentialManagerAuthenticated(credentialManager), ++ this); ++ } ++ ++ removeCredentialManager(serviceName) { ++ const credentialManager = this._credentialManagers[serviceName]; ++ if (!credentialManager) ++ return; ++ ++ credentialManager.disconnectObject(this); ++ delete this._credentialManagers[serviceName]; ++ ++ if (this._selectedMechanism?.serviceName === serviceName) ++ this.emit('reset'); ++ } ++ ++ _onCredentialManagerAuthenticated(credentialManager) { ++ this._selectedMechanism = { ++ serviceName: credentialManager.service, ++ role: Constants.PASSWORD_ROLE_NAME, ++ }; ++ this.emit('reset', {softReset: true}); ++ } ++} +diff --git a/js/gdm/util.js b/js/gdm/util.js +index 1176f77500..2172e7b923 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -2,29 +2,14 @@ + /* exported BANNER_MESSAGE_KEY, BANNER_MESSAGE_TEXT_KEY, LOGO_KEY, + DISABLE_USER_LIST_KEY, fadeInActor, fadeOutActor, cloneAndFadeOutActor */ + +-const { Clutter, Gdm, Gio, GLib } = imports.gi; ++const { Clutter, Gio, GLib } = imports.gi; + const Signals = imports.signals; + + const Batch = imports.gdm.batch; + const Constants = imports.gdm.constants; +-const {FingerprintManager, FingerprintReaderType} = imports.misc.fingerprintManager; +-const OVirt = imports.gdm.oVirt; +-const Vmware = imports.gdm.vmware; + const Main = imports.ui.main; +-const { loadInterfaceXML } = imports.misc.fileUtils; + const Params = imports.misc.params; +-const SmartcardManager = imports.misc.smartcardManager; +- +-Gio._promisify(Gdm.Client.prototype, +- 'open_reauthentication_channel', 'open_reauthentication_channel_finish'); +-Gio._promisify(Gdm.Client.prototype, +- 'get_user_verifier', 'get_user_verifier_finish'); +-Gio._promisify(Gdm.UserVerifierProxy.prototype, +- 'call_begin_verification_for_user', 'call_begin_verification_for_user_finish'); +-Gio._promisify(Gdm.UserVerifierProxy.prototype, +- 'call_begin_verification', 'call_begin_verification_finish'); +-Gio._promisify(Gio.DBusProxy.prototype, +- 'call', 'call_finish'); ++const { AuthServicesLegacy } = imports.gdm.authServicesLegacy; + + var FADE_ANIMATION_TIME = 160; + var CLONE_FADE_ANIMATION_TIME = 250; +@@ -47,9 +32,6 @@ const MESSAGE_TIME_MULTIPLIER = (() => { + return Number.isFinite(value) && value > 0 ? value : 1; + })(); + +-const FINGERPRINT_SERVICE_PROXY_TIMEOUT = 5000; +-const FINGERPRINT_ERROR_TIMEOUT_WAIT = 15; +- + var MessageType = { + // Keep messages in order by priority + NONE: 0, +@@ -132,6 +114,20 @@ function cloneAndFadeOutActor(actor) { + return hold; + } + ++/** ++ * Error thrown during the authentication initialization phase. ++ * ++ * This error is emitted when requesting user verifier proxies or starting ++ * a service via beginVerification fails. It wraps the underlying error ++ * and provides context about which service failed. ++ */ ++export class InitError extends Error { ++ constructor(error, message, serviceName) { ++ super(message, {cause: error}); ++ this.serviceName = serviceName; ++ } ++} ++ + /** + * @param {object} mechanism + * @returns {boolean} +@@ -174,41 +170,12 @@ var ShellUserVerifier = class { + this._client = client; + this._cancellable = null; + +- this._defaultService = null; +- this._preemptingService = null; +- this._fingerprintReaderType = FingerprintReaderType.NONE; +- this._fingerprintReaderFound = false; +- + this._messageQueue = []; + this._messageQueueTimeoutId = 0; + +- this._failCounter = 0; +- this._activeServices = new Set(); +- this._unavailableServices = new Set(); +- +- this._credentialManagers = {}; +- +- this.reauthenticating = false; +- this.smartcardDetected = false; +- +- this._settings = new Gio.Settings({ schema_id: LOGIN_SCREEN_SCHEMA }); ++ this._settings = new Gio.Settings({schema_id: LOGIN_SCREEN_SCHEMA}); + this._settings.connect('changed', () => this._onSettingsChanged()); +- this._updateEnabledServices(); +- this._updateDefaultService(); +- +- this._credentialManagers[OVirt.SERVICE_NAME] = OVirt.getOVirtCredentialsManager(); +- this._credentialManagers[Vmware.SERVICE_NAME] = Vmware.getVmwareCredentialsManager(); +- +- for (let service in this._credentialManagers) { +- if (this._credentialManagers[service].token) { +- this._onCredentialManagerAuthenticated(this._credentialManagers[service], +- this._credentialManagers[service].token); +- } +- +- this._credentialManagers[service]._authenticatedSignalId = +- this._credentialManagers[service].connect('user-authenticated', +- this._onCredentialManagerAuthenticated.bind(this)); +- } ++ this._updateAuthServices(); + } + + get hasPendingMessages() { +@@ -223,95 +190,85 @@ var ShellUserVerifier = class { + return this._messageQueue ? this._messageQueue[0] : null; + } + +- begin(userName, hold) { ++ async begin(userName, hold) { ++ this._cancellable?.cancel(); + this._cancellable = new Gio.Cancellable(); +- this._hold = hold; +- this._userName = userName; +- this.reauthenticating = false; + +- this._fingerprintManager?.checkReaderType(this._cancellable); ++ try { ++ const proxies = await this._getUserVerifierProxies(userName, this._cancellable); ++ await this._authServicesLegacy?.beginVerification(userName, proxies); ++ this._userVerifier = proxies.userVerifier; ++ } catch (e) { ++ if (e instanceof InitError) ++ this._reportInitError(e); ++ } + +- // If possible, reauthenticate an already running session, +- // so any session specific credentials get updated appropriately +- if (userName) +- this._openReauthenticationChannel(userName); +- else +- this._getUserVerifier(); ++ hold?.release(); + } + + selectMechanism(mechanism) { +- // TODO: Implement mechanism selection +- return false; ++ return this._authServicesLegacy?.selectMechanism(mechanism); + } + +- cancel() { +- if (this._cancellable) +- this._cancellable.cancel(); ++ needsUsername() { ++ return this._authServicesLegacy?.needsUsername(); ++ } + +- if (this._userVerifier) { +- this._userVerifier.call_cancel_sync(null); +- this.clear(); +- } ++ reset() { ++ this._authServicesLegacy?.reset(); ++ ++ this._userVerifier?.call_cancel_sync(null); ++ ++ this.clear(); + } + +- _clearUserVerifier() { +- if (this._userVerifier) { +- this._disconnectSignals(); +- this._userVerifier.run_dispose(); +- this._userVerifier = null; +- if (this._userVerifierChoiceList) { +- this._userVerifierChoiceList.run_dispose(); +- this._userVerifierChoiceList = null; +- } +- } ++ cancel() { ++ this._authServicesLegacy?.cancel(); ++ ++ this._userVerifier?.call_cancel_sync(null); ++ ++ this.clear(); + } + + clear() { +- if (this._cancellable) { +- this._cancellable.cancel(); +- this._cancellable = null; +- } ++ this._authServicesLegacy?.clear(); + +- this._clearUserVerifier(); + this._clearMessageQueue(); +- this._activeServices.clear(); ++ ++ this._cancellable?.cancel(); ++ this._cancellable = null; ++ ++ this._userVerifier = null; + } + + destroy() { ++ this._authServicesLegacy?.destroy(); ++ this._authServicesLegacy = null; ++ + this.cancel(); + + this._settings.run_dispose(); + this._settings = null; ++ } + +- this._smartcardManager?.disconnect(this._smartcardInsertedId); +- this._smartcardManager?.disconnect(this._smartcardRemovedId); +- this._smartcardManager = null; ++ selectChoice(serviceName, key) { ++ this._authServicesLegacy?.selectChoice(serviceName, key); ++ } + +- this._fingerprintManager?.disconnectObject(this); +- this._fingerprintManager = null; ++ async answerQuery(serviceName, answer) { ++ // Wait for pending messages to be displayed before answering to ++ // ensure no messages get lost ++ await this._handlePendingMessages().catch(logErrorUnlessCancelled); + +- for (let service in this._credentialManagers) { +- let credentialManager = this._credentialManagers[service]; +- credentialManager.disconnect(credentialManager._authenticatedSignalId); +- credentialManager = null; +- } ++ this._authServicesLegacy?.answerQuery(serviceName, answer); + } + +- selectChoice(serviceName, key) { +- this._userVerifierChoiceList.call_select_choice(serviceName, key, this._cancellable, null); ++ addCredentialManager(serviceName, credentialManager) { ++ this._authServicesLegacy?.addCredentialManager(serviceName, credentialManager); + } + +- answerQuery(serviceName, answer) { +- if (!this.hasPendingMessages) { +- this._userVerifier.call_answer_query(serviceName, answer, this._cancellable, null); +- } else { +- const cancellable = this._cancellable; +- let signalId = this.connect('no-more-messages', () => { +- this.disconnect(signalId); +- if (!cancellable.is_cancelled()) +- this._userVerifier.call_answer_query(serviceName, answer, cancellable, null); +- }); +- } ++ removeCredentialManager(serviceName) { ++ this._authServicesLegacy?.removeCredentialManager(serviceName); + } + + _getIntervalForMessage(message) { +@@ -322,7 +279,7 @@ var ShellUserVerifier = class { + return message.length * USER_READ_TIME * MESSAGE_TIME_MULTIPLIER; + } + +- finishMessageQueue() { ++ _finishMessageQueue() { + if (!this.hasPendingMessages) + return; + +@@ -365,7 +322,7 @@ var ShellUserVerifier = class { + this._messageQueue.shift(); + this._queueMessageTimeout(); + } else { +- this.finishMessageQueue(); ++ this._finishMessageQueue(); + } + + return GLib.SOURCE_REMOVE; +@@ -395,7 +352,7 @@ var ShellUserVerifier = class { + } + + _clearMessageQueue() { +- this.finishMessageQueue(); ++ this._finishMessageQueue(); + + if (this._messageQueueTimeoutId != 0) { + GLib.source_remove(this._messageQueueTimeoutId); +@@ -404,459 +361,143 @@ var ShellUserVerifier = class { + this.emit('show-message', null, null, MessageType.NONE); + } + +- async _initFingerprintManager() { +- if (this._fingerprintManager) +- return; +- +- this._fingerprintManager = new FingerprintManager(this._cancellable); +- this._fingerprintManager.connectObject( +- 'reader-type-changed', () => this._onFingerprintReaderTypeChanged(), +- this); +- +- if (!this._getDetectedDefaultService()) { +- // Other authentication methods would have already been detected by +- // now as possibilities if they were available. +- // If we're here it means that FINGERPRINT_AUTHENTICATION_KEY is +- // true and so fingerprint authentication is our last potential +- // option, so go ahead a synchronously look for a fingerprint device +- // during startup or default service update. +- // Do not wait too much for fprintd to reply, as in case it hangs +- // we should fail early without having the shell to misbehave +- this._fingerprintManager.setDefaultTimeout(FINGERPRINT_SERVICE_PROXY_TIMEOUT); +- await this._fingerprintManager.checkReaderType(this._cancellable); +- } else { +- // Ensure fingerprint service starts, but do not wait for it +- this._fingerprintManager.checkReaderType(this._cancellable); +- } +- } ++ _reportInitError(initError) { ++ const {cause, message, serviceName} = initError; + +- _onFingerprintReaderTypeChanged() { +- this._fingerprintReaderType = this._fingerprintManager.readerType; +- this._fingerprintReaderFound = this._fingerprintManager.readerFound; +- this._updateDefaultService(); +- +- if (this._userVerifier && +- !this._activeServices.has(Constants.FINGERPRINT_SERVICE_NAME)) +- this._maybeStartFingerprintVerification(); +- } +- +- _onCredentialManagerAuthenticated(credentialManager, _token) { +- this._preemptingService = credentialManager.service; +- this.emit('credential-manager-authenticated'); +- } +- +- _initSmartcardManager() { +- if (this._smartcardManager) +- return; +- +- this._smartcardManager = SmartcardManager.getSmartcardManager(); +- +- // We check for smartcards right away, since an inserted smartcard +- // at startup should result in immediately initiating authentication. +- // This is different than fingerprint readers, where we only check them +- // after a user has been picked. +- this.smartcardDetected = false; +- this._checkForSmartcard(); +- +- this._smartcardInsertedId = this._smartcardManager.connect('smartcard-inserted', +- this._checkForSmartcard.bind(this)); +- this._smartcardRemovedId = this._smartcardManager.connect('smartcard-removed', +- this._checkForSmartcard.bind(this)); +- } +- +- _checkForSmartcard() { +- let smartcardDetected; +- +- if (!this._settings.get_boolean(SMARTCARD_AUTHENTICATION_KEY)) +- smartcardDetected = false; +- else if (this._reauthOnly) +- smartcardDetected = this._smartcardManager.hasInsertedLoginToken(); +- else +- smartcardDetected = this._smartcardManager.hasInsertedTokens(); +- +- if (smartcardDetected != this.smartcardDetected) { +- this.smartcardDetected = smartcardDetected; +- +- if (this.smartcardDetected) +- this._preemptingService = Constants.SMARTCARD_SERVICE_NAME; +- else if (this._preemptingService === Constants.SMARTCARD_SERVICE_NAME) +- this._preemptingService = null; +- +- this._updateDefaultService(); +- +- this.emit('smartcard-status-changed'); +- } +- } +- +- _reportInitError(where, error, serviceName) { +- logError(error, where); +- this._hold?.release(); ++ logError(cause, message); + + this._queueMessage(serviceName, _('Authentication error'), MessageType.ERROR); +- this._failCounter++; + this._verificationFailed(serviceName, false); + } + +- async _openReauthenticationChannel(userName) { +- try { +- this._clearUserVerifier(); +- this._userVerifier = await this._client.open_reauthentication_channel( +- userName, this._cancellable); +- } catch (e) { +- if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) +- return; +- if (e.matches(Gio.DBusError, Gio.DBusError.ACCESS_DENIED) && +- !this._reauthOnly) { +- // Gdm emits org.freedesktop.DBus.Error.AccessDenied when there +- // is no session to reauthenticate. Fall back to performing +- // verification from this login session +- this._getUserVerifier(); +- return; ++ async _getUserVerifierProxies(userName, cancellable) { ++ const proxies = {}; ++ ++ if (userName) { ++ try { ++ proxies.userVerifier = await this._client.open_reauthentication_channel( ++ userName, cancellable); ++ } catch (e) { ++ if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) ++ throw e; ++ if (e.matches(Gio.DBusError, Gio.DBusError.ACCESS_DENIED) && ++ !this._reauthOnly) { ++ // Gdm emits org.freedesktop.DBus.Error.AccessDenied when there ++ // is no session to reauthenticate. Fall back to performing ++ // verification from this login session ++ return this._getUserVerifierProxies(null, cancellable); ++ } ++ throw new InitError(e, 'Failed to open reauthentication channel'); ++ } ++ } else { ++ try { ++ proxies.userVerifier = await this._client.get_user_verifier( ++ cancellable); ++ } catch (e) { ++ if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) ++ throw e; ++ throw new InitError(e, 'Failed to obtain user verifier'); + } +- +- this._reportInitError('Failed to open reauthentication channel', e); +- return; + } + +- if (this._client.get_user_verifier_choice_list) +- this._userVerifierChoiceList = this._client.get_user_verifier_choice_list(); +- else +- this._userVerifierChoiceList = null; +- +- this.reauthenticating = true; +- this._connectSignals(); +- this._beginVerification(); +- this._hold?.release(); +- } +- +- async _getUserVerifier() { + try { +- this._clearUserVerifier(); +- this._userVerifier = +- await this._client.get_user_verifier(this._cancellable); ++ if (this._client.get_user_verifier_choice_list) ++ proxies.userVerifierChoiceList = await this._client.get_user_verifier_choice_list(); ++ if (this._client.get_user_verifier_custom_json) ++ proxies.userVerifierCustomJSON = await this._client.get_user_verifier_custom_json(); + } catch (e) { +- if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) +- return; +- this._reportInitError('Failed to obtain user verifier', e); +- return; ++ throw new InitError(e, 'Failed to obtain user verifier extensions'); + } + +- if (this._client.get_user_verifier_choice_list) +- this._userVerifierChoiceList = this._client.get_user_verifier_choice_list(); +- else +- this._userVerifierChoiceList = null; +- +- this._connectSignals(); +- this._beginVerification(); +- this._hold?.release(); +- } +- +- _connectSignals() { +- this._disconnectSignals(); +- this._signalIds = []; +- +- let id = this._userVerifier.connect('info', this._onInfo.bind(this)); +- this._signalIds.push(id); +- id = this._userVerifier.connect('problem', this._onProblem.bind(this)); +- this._signalIds.push(id); +- id = this._userVerifier.connect('info-query', this._onInfoQuery.bind(this)); +- this._signalIds.push(id); +- id = this._userVerifier.connect('secret-info-query', this._onSecretInfoQuery.bind(this)); +- this._signalIds.push(id); +- id = this._userVerifier.connect('conversation-started', this._onConversationStarted.bind(this)); +- this._signalIds.push(id); +- id = this._userVerifier.connect('conversation-stopped', this._onConversationStopped.bind(this)); +- this._signalIds.push(id); +- id = this._userVerifier.connect('service-unavailable', this._onServiceUnavailable.bind(this)); +- this._signalIds.push(id); +- id = this._userVerifier.connect('reset', this._onReset.bind(this)); +- this._signalIds.push(id); +- id = this._userVerifier.connect('verification-complete', this._onVerificationComplete.bind(this)); +- this._signalIds.push(id); +- +- if (this._userVerifierChoiceList) +- this._userVerifierChoiceList.connect('choice-query', this._onChoiceListQuery.bind(this)); +- } +- +- _disconnectSignals() { +- if (!this._signalIds || !this._userVerifier) +- return; +- +- this._signalIds.forEach(s => this._userVerifier.disconnect(s)); +- this._signalIds = []; +- } +- +- _getForegroundService() { +- if (this._preemptingService) +- return this._preemptingService; +- +- return this._defaultService; +- } +- +- serviceIsForeground(serviceName) { +- return serviceName == this._getForegroundService(); +- } +- +- serviceIsDefault(serviceName) { +- return serviceName == this._defaultService; ++ return proxies; + } + + serviceIsFingerprint(serviceName) { +- return this._fingerprintReaderFound && +- serviceName === Constants.FINGERPRINT_SERVICE_NAME; ++ return serviceName === Constants.FINGERPRINT_SERVICE_NAME; + } + + _onSettingsChanged() { +- this._updateEnabledServices(); +- this._updateDefaultService(); ++ this._updateAuthServices(); + } + +- _updateEnabledServices() { +- let needsReset = false; ++ _updateAuthServices() { ++ const enabledRoles = []; + +- if (this._settings.get_boolean(FINGERPRINT_AUTHENTICATION_KEY)) { +- this._initFingerprintManager(); +- } else if (this._fingerprintManager) { +- this._fingerprintManager.disconnectObject(this); +- this._fingerprintManager = null; +- this._fingerprintReaderFound = false; +- this._fingerprintReaderType = FingerprintReaderType.NONE; +- +- if (this._activeServices.has(Constants.FINGERPRINT_SERVICE_NAME)) +- needsReset = true; +- } ++ if (this._settings.get_boolean(PASSWORD_AUTHENTICATION_KEY)) ++ enabledRoles.push(Constants.PASSWORD_ROLE_NAME); ++ if (this._settings.get_boolean(SMARTCARD_AUTHENTICATION_KEY)) ++ enabledRoles.push(Constants.SMARTCARD_ROLE_NAME); ++ if (this._settings.get_boolean(FINGERPRINT_AUTHENTICATION_KEY)) ++ enabledRoles.push(Constants.FINGERPRINT_ROLE_NAME); + +- if (this._settings.get_boolean(SMARTCARD_AUTHENTICATION_KEY)) { +- this._initSmartcardManager(); +- } else if (this._smartcardManager) { +- this._smartcardManager.disconnect(this._smartcardInsertedId); +- this._smartcardManager.disconnect(this._smartcardRemovedId); +- this._smartcardManager = null; +- +- if (this._activeServices.has(Constants.SMARTCARD_SERVICE_NAME)) +- needsReset = true; +- } +- +- if (needsReset) +- this._cancelAndReset(); +- } +- +- _getDetectedDefaultService() { +- if (this._smartcardManager?.loggedInWithToken()) +- return Constants.SMARTCARD_SERVICE_NAME; +- else if (this._settings.get_boolean(PASSWORD_AUTHENTICATION_KEY)) +- return Constants.PASSWORD_SERVICE_NAME; +- else if (this._smartcardManager) +- return Constants.SMARTCARD_SERVICE_NAME; +- else if (this._fingerprintReaderFound) +- return Constants.FINGERPRINT_SERVICE_NAME; +- return null; +- } +- +- _updateDefaultService() { +- const oldDefaultService = this._defaultService; +- this._defaultService = this._getDetectedDefaultService(); +- +- if (!this._defaultService) { +- log("no authentication service is enabled, using password authentication"); +- this._defaultService = Constants.PASSWORD_SERVICE_NAME; +- } +- +- if (oldDefaultService && +- oldDefaultService !== this._defaultService && +- this._activeServices.has(oldDefaultService)) +- this._cancelAndReset(); +- } +- +- async _startService(serviceName) { +- this._hold?.acquire(); +- try { +- this._activeServices.add(serviceName); +- if (this._userName) { +- await this._userVerifier.call_begin_verification_for_user( +- serviceName, this._userName, this._cancellable); +- } else { +- await this._userVerifier.call_begin_verification( +- serviceName, this._cancellable); +- } +- } catch (e) { +- this._activeServices.delete(serviceName); +- if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) +- return; +- if (!this.serviceIsForeground(serviceName)) { +- logError(e, 'Failed to start %s for %s'.format(serviceName, this._userName)); +- this._hold?.release(); +- return; +- } +- this._reportInitError(this._userName +- ? 'Failed to start %s verification for user'.format(serviceName) +- : 'Failed to start %s verification'.format(serviceName), e, +- serviceName); ++ if (JSON.stringify(enabledRoles) === JSON.stringify(this._enabledRoles)) + return; +- } +- this._hold?.release(); +- } +- +- _beginVerification() { +- this._startService(this._getForegroundService()); +- this._maybeStartFingerprintVerification().catch(logError); +- } + +- async _maybeStartFingerprintVerification() { +- if (this._userName && +- this._fingerprintReaderFound && +- !this.serviceIsForeground(Constants.FINGERPRINT_SERVICE_NAME)) +- await this._startService(Constants.FINGERPRINT_SERVICE_NAME); +- } ++ this._enabledRoles = enabledRoles; + +- _onChoiceListQuery(client, serviceName, promptMessage, list) { +- if (!this.serviceIsForeground(serviceName)) +- return; +- +- const choiceList = {}; +- for (const [key, value] of Object.entries(list.deepUnpack())) +- choiceList[key] = {title: value}; +- +- this.emit('show-choice-list', serviceName, promptMessage, choiceList); +- } +- +- _onInfo(client, serviceName, info) { +- if (this.serviceIsForeground(serviceName)) { +- this._queueMessage(serviceName, info, MessageType.INFO); +- } else if (this.serviceIsFingerprint(serviceName)) { +- // We don't show fingerprint messages directly since it's +- // not the main auth service. Instead we use the messages +- // as a cue to display our own message. +- if (this._fingerprintReaderType === FingerprintReaderType.SWIPE) { +- // Translators: this message is shown below the password entry field +- // to indicate the user can swipe their finger on the fingerprint reader +- this._queueMessage(serviceName, _('(or swipe finger across reader)'), +- MessageType.HINT); +- } else { +- // Translators: this message is shown below the password entry field +- // to indicate the user can place their finger on the fingerprint reader instead +- this._queueMessage(serviceName, _('(or place finger on reader)'), +- MessageType.HINT); +- } +- } ++ this._createAuthServices(); + } + +- _onProblem(client, serviceName, problem) { +- const isFingerprint = this.serviceIsFingerprint(serviceName); +- +- if (!this.serviceIsForeground(serviceName) && !isFingerprint) +- return; +- +- this._queuePriorityMessage(serviceName, problem, MessageType.ERROR); +- +- if (isFingerprint) { +- // pam_fprintd allows the user to retry multiple (maybe even infinite! +- // times before failing the authentication conversation. +- // We don't want this behavior to bypass the max-tries setting the user has set, +- // so we count the problem messages to know how many times the user has failed. +- // Once we hit the max number of failures we allow, it's time to failure the +- // conversation from our side. We can't do that right away, however, because +- // we may drop pending messages coming from pam_fprintd. In order to make sure +- // the user sees everything, we queue the failure up to get handled in the +- // near future, after we've finished up the current round of messages. +- this._failCounter++; +- +- if (!this._canRetry()) { +- if (this._fingerprintFailedId) +- GLib.source_remove(this._fingerprintFailedId); +- +- const cancellable = this._cancellable; +- this._fingerprintFailedId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, +- FINGERPRINT_ERROR_TIMEOUT_WAIT, () => { +- this._fingerprintFailedId = 0; +- if (!cancellable.is_cancelled()) +- this._verificationFailed(serviceName, false); +- return GLib.SOURCE_REMOVE; +- }); +- } +- } +- } ++ _createAuthServices() { ++ this._clearAuthServices(); + +- _onInfoQuery(client, serviceName, question) { +- if (!this.serviceIsForeground(serviceName)) +- return; ++ const params = { ++ client: this._client, ++ enabledRoles: this._enabledRoles, ++ allowedFailures: this.allowedFailures, ++ reauthOnly: this._reauthOnly, ++ }; ++ if (AuthServicesLegacy.supportsAny(this._enabledRoles)) ++ this._authServicesLegacy = new AuthServicesLegacy(params); + +- this.emit('ask-question', serviceName, question, false); ++ this._connectAuthServices(); + } + +- _onSecretInfoQuery(client, serviceName, secretQuestion) { +- if (!this.serviceIsForeground(serviceName)) +- return; +- +- let token = null; +- if (this._credentialManagers[serviceName]) +- token = this._credentialManagers[serviceName].token; +- +- if (token) { +- this.answerQuery(serviceName, token); +- return; +- } +- +- this.emit('ask-question', serviceName, secretQuestion, true); ++ _clearAuthServices() { ++ this._authServicesLegacy?.destroy(); ++ this._authServicesLegacy = null; + } + +- _onReset() { +- // Clear previous attempts to authenticate +- this._failCounter = 0; +- this._activeServices.clear(); +- this._unavailableServices.clear(); +- this._updateDefaultService(); +- +- this.emit('reset'); ++ _connectAuthServices() { ++ [ ++ this._authServicesLegacy, ++ ].forEach(authServices => { ++ authServices?.connectObject( ++ 'ask-question', (_, ...args) => this.emit('ask-question', ...args), ++ 'queue-message', (_, ...args) => this._queueMessage(...args), ++ 'queue-priority-message', (_, ...args) => this._queuePriorityMessage(...args), ++ 'wait-pending-messages', (_, ...args) => this._waitPendingMessages(...args), ++ 'filter-messages', (_, ...args) => this._filterServiceMessages(...args), ++ 'verification-failed', (_, ...args) => this._verificationFailed(...args), ++ 'verification-complete', (_, ...args) => this.emit('verification-complete', ...args), ++ 'reset', (_, ...args) => this.emit('reset', ...args), ++ 'show-choice-list', (_, ...args) => this.emit('show-choice-list', ...args), ++ 'mechanisms-changed', (_, ...args) => this._onMechanismsChanged(...args), ++ this); ++ }); + } + +- _onVerificationComplete() { +- this.emit('verification-complete'); ++ _verificationFailed(serviceName, canRetry) { ++ this._filterServiceMessages(serviceName, MessageType.ERROR); ++ this.emit('verification-failed', serviceName, canRetry); + } + +- _cancelAndReset() { +- this.cancel(); +- this._onReset(); +- } ++ _onMechanismsChanged() { ++ const mechanisms = this._authServicesLegacy?.enabledMechanisms ?? []; + +- _retry(serviceName) { +- this._connectSignals(); +- this._startService(serviceName); +- } ++ const selectedMechanism = this._authServicesLegacy?.selectedMechanism ?? ++ mechanisms.find(m => isSelectable(m)) ?? ++ {}; + +- _canRetry() { +- return this._userName && +- (this._reauthOnly || this._failCounter < this.allowedFailures); ++ this.emit('mechanisms-changed', mechanisms, selectedMechanism); + } + +- _verificationFailed(serviceName, shouldRetry) { +- if (serviceName === Constants.FINGERPRINT_SERVICE_NAME) { +- if (this._fingerprintFailedId) +- GLib.source_remove(this._fingerprintFailedId); +- } +- +- // For Not Listed / enterprise logins, immediately reset +- // the dialog +- // Otherwise, when in login mode we allow ALLOWED_FAILURES attempts. +- // After that, we go back to the welcome screen. +- this._filterServiceMessages(serviceName, MessageType.ERROR); +- +- const doneTrying = !shouldRetry || !this._canRetry(); +- +- if (doneTrying) { +- this._disconnectSignals(); +- +- // eslint-disable-next-line no-lonely-if +- if (!this.hasPendingMessages) { +- this._cancelAndReset(); +- } else { +- const cancellable = this._cancellable; +- let signalId = this.connect('no-more-messages', () => { +- this.disconnect(signalId); +- if (!cancellable.is_cancelled()) +- this._cancelAndReset(); +- }); +- } ++ async _waitPendingMessages(task) { ++ try { ++ await this._handlePendingMessages(); ++ task.return_boolean(true); ++ } catch (e) { ++ task.return_error(e); + } + + this.emit('verification-failed', serviceName, !doneTrying); +@@ -873,47 +514,20 @@ var ShellUserVerifier = class { + } + } + +- _onServiceUnavailable(_client, serviceName, errorMessage) { +- this._unavailableServices.add(serviceName); +- +- if (!errorMessage) +- return; +- +- if (this.serviceIsForeground(serviceName) || this.serviceIsFingerprint(serviceName)) +- this._queueMessage(serviceName, errorMessage, MessageType.ERROR); +- } +- +- _onConversationStarted(client, serviceName) { +- this._activeServices.add(serviceName); +- } +- +- _onConversationStopped(client, serviceName) { +- this._activeServices.delete(serviceName); +- +- // If the login failed with the preauthenticated oVirt credentials +- // then discard the credentials and revert to default authentication +- // mechanism. +- let foregroundService = Object.keys(this._credentialManagers).find(service => +- this.serviceIsForeground(service)); +- if (foregroundService) { +- this._credentialManagers[foregroundService].token = null; +- this._preemptingService = null; +- this._verificationFailed(serviceName, false); +- return; +- } +- +- this._filterServiceMessages(serviceName, MessageType.ERROR); +- +- if (this._unavailableServices.has(serviceName)) +- return; +- +- // if the password service fails, then cancel everything. +- // But if, e.g., fingerprint fails, still give +- // password authentication a chance to succeed +- if (this.serviceIsForeground(serviceName)) +- this._failCounter++; ++ _handlePendingMessages() { ++ if (!this.hasPendingMessages) ++ return Promise.resolve(); + +- this._verificationFailed(serviceName, true); ++ const cancellable = this._cancellable; ++ return new Promise((resolve, reject) => { ++ const signalId = this.connect('no-more-messages', () => { ++ this.disconnect(signalId); ++ if (cancellable.is_cancelled()) ++ reject(new GLib.Error(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED, 'Operation was cancelled')); ++ else ++ resolve(); ++ }); ++ }); + } + }; + Signals.addSignalMethods(ShellUserVerifier.prototype); +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index df6dbf5070..f2fb20c7a4 100644 +--- a/js/js-resources.gresource.xml ++++ b/js/js-resources.gresource.xml +@@ -4,6 +4,8 @@ + gdm/authList.js + gdm/authMenuButton.js + gdm/authPrompt.js ++ gdm/authServices.js ++ gdm/authServicesLegacy.js + gdm/batch.js + gdm/constants.js + gdm/loginDialog.js +diff --git a/po/POTFILES.in b/po/POTFILES.in +index cb279c1ee5..a7a645a41c 100644 +--- a/po/POTFILES.in ++++ b/po/POTFILES.in +@@ -6,6 +6,7 @@ data/org.gnome.shell.gschema.xml.in + data/org.gnome.Shell.PortalHelper.desktop.in.in + js/dbusServices/extensions/ui/extension-prefs-dialog.ui + js/gdm/authPrompt.js ++js/gdm/authServicesLegacy.js + js/gdm/loginDialog.js + js/gdm/util.js + js/misc/systemActions.js +-- +2.51.0 + + +From 86d48c601147b21a79d3f8c792f2ff4cfa320514 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 16 Feb 2026 12:21:54 +0100 +Subject: [PATCH 41/48] gdm: Add fingerprint ready state to delay showing icon + +Fingerprint mechanism now has a ready state that controls when it +appears in the authentication UI. When fingerprint authentication +starts, the mechanism is marked as not ready and filtered from the +enabledMechanisms list. Only after the fingerprint service has been +running for a brief window (0.5s) is it marked as ready and emitted +via mechanisms-changed. + +This prevents the fingerprint icon from briefly appearing in the UI +for users who don't have enrolled fingerprints. In those cases, the +fingerprint service starts but quickly stops with service-unavailable, +so the timeout never completes and the icon never shows. +--- + js/gdm/authServices.js | 2 +- + js/gdm/authServicesLegacy.js | 54 ++++++++++++++++++++++++++++++++---- + 2 files changed, 50 insertions(+), 6 deletions(-) + +diff --git a/js/gdm/authServices.js b/js/gdm/authServices.js +index d522ee352e..b3bfb56c6c 100644 +--- a/js/gdm/authServices.js ++++ b/js/gdm/authServices.js +@@ -90,7 +90,7 @@ export class AuthServices extends GObject.Object { + } + + get enabledMechanisms() { +- return this._enabledMechanisms; ++ return this._enabledMechanisms?.filter(m => m.ready !== false); + } + + get _roleToService() { +diff --git a/js/gdm/authServicesLegacy.js b/js/gdm/authServicesLegacy.js +index e27107c85e..c0b9fdedd8 100644 +--- a/js/gdm/authServicesLegacy.js ++++ b/js/gdm/authServicesLegacy.js +@@ -10,6 +10,7 @@ import * as Vmware from './vmware.js'; + import {AuthServices} from './authServices.js'; + + const FINGERPRINT_ERROR_TIMEOUT_WAIT = 15; ++const FINGERPRINT_READY_TIMEOUT_MS = 500; + + const Mechanisms = [ + { +@@ -54,6 +55,8 @@ export class AuthServicesLegacy extends AuthServices { + this._credentialManagers = {}; + this.addCredentialManager(OVirt.SERVICE_NAME, OVirt.getOVirtCredentialsManager()); + this.addCredentialManager(Vmware.SERVICE_NAME, Vmware.getVmwareCredentialsManager()); ++ ++ this._fingerprintReadyTimeoutId = 0; + } + + _handleSelectChoice(serviceName, key) { +@@ -100,6 +103,41 @@ export class AuthServicesLegacy extends AuthServices { + + _handleClear() { + this._smartcardInProgress = false; ++ this._clearFingerprintReadyTimeout(); ++ } ++ ++ _clearFingerprintReadyTimeout() { ++ if (this._fingerprintReadyTimeoutId) { ++ GLib.source_remove(this._fingerprintReadyTimeoutId); ++ this._fingerprintReadyTimeoutId = 0; ++ } ++ } ++ ++ _handleOnConversationStarted(serviceName) { ++ if (serviceName !== Constants.FINGERPRINT_SERVICE_NAME || ++ this._fingerprintReadyTimeoutId !== 0) ++ return; ++ ++ this._fingerprintReadyTimeoutId = GLib.timeout_add_once( ++ GLib.PRIORITY_DEFAULT, ++ FINGERPRINT_READY_TIMEOUT_MS, ++ () => { ++ this._fingerprintReadyTimeoutId = 0; ++ this._setFingerprintReady(true); ++ }); ++ } ++ ++ _setFingerprintReady(ready) { ++ const mechanism = this._enabledMechanisms.find(m => ++ m.role === Constants.FINGERPRINT_ROLE_NAME); ++ ++ if (!mechanism || mechanism.ready === ready) ++ return; ++ ++ mechanism.ready = ready; ++ ++ if (ready) ++ this.emit('mechanisms-changed'); + } + + _handleUpdateEnabledMechanisms() { +@@ -112,6 +150,10 @@ export class AuthServicesLegacy extends AuthServices { + this._enabledMechanisms.push(...Mechanisms.filter(m => + this._enabledRoles.includes(m.role) + )); ++ ++ // Mark fingerprint as not ready until service confirms ++ // it's working for this user ++ this._setFingerprintReady(false); + } + } + +@@ -229,11 +271,13 @@ export class AuthServicesLegacy extends AuthServices { + return; + } + +- if (serviceName === Constants.FINGERPRINT_SERVICE_NAME && +- this._unavailableServices.has(serviceName)) { +- this._enabledMechanisms = this._enabledMechanisms +- .filter(m => m.serviceName !== serviceName); +- this.emit('mechanisms-changed'); ++ if (serviceName === Constants.FINGERPRINT_SERVICE_NAME) { ++ this._clearFingerprintReadyTimeout(); ++ if (this._unavailableServices.has(serviceName)) { ++ this._enabledMechanisms = this._enabledMechanisms ++ .filter(m => m.serviceName !== serviceName); ++ this.emit('mechanisms-changed'); ++ } + } + + if (this._unavailableServices.has(serviceName)) +-- +2.51.0 + + +From 5ea67b48e5dc62cac9b08bc817c89a40e4b22ecd Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 6 Feb 2024 14:09:34 -0500 +Subject: [PATCH 42/48] gdm: Add authServicesSSSDSwitchable + +This new authService child is used by SSSD to control multiple authentication +mechanisms from a single PAM conversation using 'gdm-switchable-auth' +service. It relies on the new JSON extension with a new 'auth-selection' +protocol provided by pam_sss. + +This initial commit just supports password mechanism. Future commits will add +support for other mechanisms. + +The basic workflow: +1. Begin verification starts the only service 'gdm-switchable-auth'. +2. _userVerifierCustomJson emits 'request' signal with a JSON message. + That message contains the list of available mechanisms and a priority list + that indicates which mechanism should be used first. +3. Those mechanisms are formated and stored in _enabledMechanisms. The + default mechanism is selected and the 'mechanisms-changed' signal is emitted. +4. When the mechanism is selected, its role is used with a switch to + handle the different authentication flows. Depending on the role, + different signals will be emitted, like 'ask-question'... +5. When handling user feedback, the selected mechanism role is used with + a switch to determine how to format the response and is sent using + _userVerifierCustomJson. +6. When verification succeeds, 'verification-complete' signal is emitted, + if it fails, 'verification-failed' is emitted. + +Now authServicesLegacy and authServicesSwitchable are mutually exclusive, but in +the next commit they won't be. + +When a mechanism is selected, both authServices try to store it. +Only the one that has it in _enabledMechanisms keeps it; the other gets null. +The stored selected mechanism indicates whether authServicesLegacy or +authServicesSwitchable will handle interactions. +--- + js/gdm/authServices.js | 1 + + js/gdm/authServicesSSSDSwitchable.js | 166 +++++++++++++++++++++++++++ + js/gdm/loginDialog.js | 5 +- + js/gdm/util.js | 42 ++++++- + js/js-resources.gresource.xml | 1 + + js/ui/unlockDialog.js | 1 + + po/POTFILES.in | 1 + + 7 files changed, 210 insertions(+), 7 deletions(-) + create mode 100644 js/gdm/authServicesSSSDSwitchable.js + +diff --git a/js/gdm/authServices.js b/js/gdm/authServices.js +index b3bfb56c6c..3416ce3055 100644 +--- a/js/gdm/authServices.js ++++ b/js/gdm/authServices.js +@@ -17,6 +17,7 @@ Gio._promisify(Gdm.UserVerifierProxy.prototype, 'call_begin_verification_for_use + Gio._promisify(Gdm.UserVerifierProxy.prototype, 'call_begin_verification'); + Gio._promisify(Gdm.UserVerifierProxy.prototype, 'call_answer_query'); + Gio._promisify(Gdm.UserVerifierChoiceListProxy.prototype, 'call_select_choice'); ++Gio._promisify(Gdm.UserVerifierCustomJSONProxy.prototype, 'call_reply'); + + export class AuthServices extends GObject.Object { + static [GObject.signals] = { +diff --git a/js/gdm/authServicesSSSDSwitchable.js b/js/gdm/authServicesSSSDSwitchable.js +new file mode 100644 +index 0000000000..c67933b6f3 +--- /dev/null ++++ b/js/gdm/authServicesSSSDSwitchable.js +@@ -0,0 +1,166 @@ ++import GObject from 'gi://GObject'; ++ ++import * as Constants from './constants.js'; ++import {logErrorUnlessCancelled} from '../misc/errorUtils.js'; ++import * as Util from './util.js'; ++import {AuthServices} from './authServices.js'; ++ ++export class AuthServicesSSSDSwitchable extends AuthServices { ++ static SupportedRoles = [ ++ Constants.PASSWORD_ROLE_NAME, ++ ]; ++ ++ static RoleToService = { ++ [Constants.PASSWORD_ROLE_NAME]: Constants.SWITCHABLE_AUTH_SERVICE_NAME, ++ }; ++ ++ static { ++ GObject.registerClass(this); ++ } ++ ++ constructor(params) { ++ super(params); ++ } ++ ++ _handleAnswerQuery(serviceName, answer) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ let response; ++ switch (this._selectedMechanism.role) { ++ case Constants.PASSWORD_ROLE_NAME: ++ response = this._formatResponse(answer); ++ this._sendResponse(response); ++ break; ++ } ++ } ++ ++ _handleSelectMechanism() { ++ switch (this._selectedMechanism?.role) { ++ case Constants.PASSWORD_ROLE_NAME: ++ this._startPasswordLogin(); ++ break; ++ } ++ } ++ ++ _handleReset() { ++ this._savedMechanism = null; ++ } ++ ++ _handleCancel() { ++ if (this._selectedMechanism) ++ this._savedMechanism = this._selectedMechanism; ++ } ++ ++ _handleClear() { ++ this._mechanisms = null; ++ this._priorityList = null; ++ this._enabledMechanisms = null; ++ this._selectedMechanism = null; ++ } ++ ++ _handleOnCustomJSONRequest(_serviceName, _protocol, _version, json) { ++ let requestObject; ++ ++ try { ++ requestObject = JSON.parse(json); ++ } catch (e) { ++ logError(e); ++ return; ++ } ++ ++ const {authSelection} = requestObject; ++ if (authSelection) { ++ this._mechanisms = authSelection.mechanisms; ++ this._priorityList = authSelection.priority; ++ ++ if (this._mechanisms) ++ this._updateEnabledMechanisms(); ++ } ++ } ++ ++ _handleUpdateEnabledMechanisms() { ++ this._enabledMechanisms.push(...Object.keys(this._mechanisms) ++ .map(id => ({ ++ serviceName: Constants.SWITCHABLE_AUTH_SERVICE_NAME, ++ id, ++ ...this._mechanisms[id], ++ })) ++ // filter out mechanisms with roles that are not enabled ++ .filter(m => this._enabledRoles.includes(m.role))); ++ ++ const selectedMechanism = ++ this._enabledMechanisms ++ .find(m => this._savedMechanism?.role === m.role) ?? ++ this._priorityList ++ .map(id => this._enabledMechanisms.find(m => m.id === id))[0] ?? ++ this._enabledMechanisms[0]; ++ this.selectMechanism(selectedMechanism); ++ ++ this._savedMechanism = null; ++ } ++ ++ _handleOnInfo(serviceName, info) { ++ if (serviceName === this._selectedMechanism?.serviceName) ++ this.emit('queue-message', serviceName, info, Util.MessageType.INFO); ++ } ++ ++ _handleOnProblem(serviceName, problem) { ++ if (serviceName === this._selectedMechanism?.serviceName) { ++ this.emit('queue-priority-message', ++ serviceName, ++ problem, ++ Util.MessageType.ERROR); ++ } ++ } ++ ++ _handleOnConversationStopped(serviceName) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ if (this._unavailableServices.has(serviceName)) ++ return; ++ ++ this._failCounter++; ++ this._verificationFailed(serviceName, true); ++ } ++ ++ _handleCanStartService(serviceName) { ++ return serviceName === Constants.SWITCHABLE_AUTH_SERVICE_NAME && ++ !this._enabledMechanisms; ++ } ++ ++ _formatResponse(answer) { ++ const {role, id} = this._selectedMechanism; ++ ++ let response; ++ switch (role) { ++ case Constants.PASSWORD_ROLE_NAME: { ++ response = {password: answer}; ++ break; ++ } ++ default: ++ throw new GObject.NotImplementedError(`formatResponse: ${role}`); ++ } ++ ++ return { ++ authSelection: { ++ status: 'Ok', ++ [id]: response, ++ }, ++ }; ++ } ++ ++ _sendResponse(response) { ++ const {serviceName} = this._selectedMechanism; ++ ++ this._userVerifierCustomJSON.call_reply( ++ serviceName, JSON.stringify(response), this._cancellable).catch(logErrorUnlessCancelled); ++ } ++ ++ _startPasswordLogin() { ++ const {serviceName, prompt} = this._selectedMechanism; ++ ++ this.emit('ask-question', serviceName, prompt, true); ++ } ++} +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index ab085fae49..8e71e4f0ed 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -326,7 +326,10 @@ var LoginDialog = GObject.registerClass({ + this._gdmClient = new Gdm.Client(); + + try { +- this._gdmClient.set_enabled_extensions([Gdm.UserVerifierChoiceList.interface_info().name]); ++ this._gdmClient.set_enabled_extensions([ ++ Gdm.UserVerifierChoiceList.interface_info().name, ++ Gdm.UserVerifierCustomJSON.interface_info().name, ++ ]); + } catch (e) { + } + +diff --git a/js/gdm/util.js b/js/gdm/util.js +index 2172e7b923..9d9722187d 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -10,6 +10,7 @@ const Constants = imports.gdm.constants; + const Main = imports.ui.main; + const Params = imports.misc.params; + const { AuthServicesLegacy } = imports.gdm.authServicesLegacy; ++const { AuthServicesSSSDSwitchable } = imports.gdm.authServicesSSSDSwitchable; + + var FADE_ANIMATION_TIME = 160; + var CLONE_FADE_ANIMATION_TIME = 250; +@@ -18,6 +19,7 @@ var LOGIN_SCREEN_SCHEMA = 'org.gnome.login-screen'; + var PASSWORD_AUTHENTICATION_KEY = 'enable-password-authentication'; + var FINGERPRINT_AUTHENTICATION_KEY = 'enable-fingerprint-authentication'; + var SMARTCARD_AUTHENTICATION_KEY = 'enable-smartcard-authentication'; ++var SWITCHABLE_AUTHENTICATION_KEY = 'enable-switchable-authentication'; + var BANNER_MESSAGE_KEY = 'banner-message-enable'; + var BANNER_MESSAGE_TEXT_KEY = 'banner-message-text'; + var ALLOWED_FAILURES_KEY = 'allowed-failures'; +@@ -196,6 +198,7 @@ var ShellUserVerifier = class { + + try { + const proxies = await this._getUserVerifierProxies(userName, this._cancellable); ++ await this._authServicesSSSDSwitchable?.beginVerification(userName, proxies); + await this._authServicesLegacy?.beginVerification(userName, proxies); + this._userVerifier = proxies.userVerifier; + } catch (e) { +@@ -207,14 +210,19 @@ var ShellUserVerifier = class { + } + + selectMechanism(mechanism) { +- return this._authServicesLegacy?.selectMechanism(mechanism); ++ let selected = false; ++ selected |= this._authServicesSSSDSwitchable?.selectMechanism(mechanism); ++ selected |= this._authServicesLegacy?.selectMechanism(mechanism); ++ return selected; + } + + needsUsername() { +- return this._authServicesLegacy?.needsUsername(); ++ return this._authServicesSSSDSwitchable?.needsUsername() || ++ this._authServicesLegacy?.needsUsername(); + } + + reset() { ++ this._authServicesSSSDSwitchable?.reset(); + this._authServicesLegacy?.reset(); + + this._userVerifier?.call_cancel_sync(null); +@@ -223,6 +231,7 @@ var ShellUserVerifier = class { + } + + cancel() { ++ this._authServicesSSSDSwitchable?.cancel(); + this._authServicesLegacy?.cancel(); + + this._userVerifier?.call_cancel_sync(null); +@@ -231,6 +240,7 @@ var ShellUserVerifier = class { + } + + clear() { ++ this._authServicesSSSDSwitchable?.clear(); + this._authServicesLegacy?.clear(); + + this._clearMessageQueue(); +@@ -242,6 +252,9 @@ var ShellUserVerifier = class { + } + + destroy() { ++ this._authServicesSSSDSwitchable?.destroy(); ++ this._authServicesSSSDSwitchable = null; ++ + this._authServicesLegacy?.destroy(); + this._authServicesLegacy = null; + +@@ -252,6 +265,7 @@ var ShellUserVerifier = class { + } + + selectChoice(serviceName, key) { ++ this._authServicesSSSDSwitchable?.selectChoice(serviceName, key); + this._authServicesLegacy?.selectChoice(serviceName, key); + } + +@@ -260,6 +274,7 @@ var ShellUserVerifier = class { + // ensure no messages get lost + await this._handlePendingMessages().catch(logErrorUnlessCancelled); + ++ this._authServicesSSSDSwitchable?.answerQuery(serviceName, answer); + this._authServicesLegacy?.answerQuery(serviceName, answer); + } + +@@ -430,10 +445,15 @@ var ShellUserVerifier = class { + if (this._settings.get_boolean(FINGERPRINT_AUTHENTICATION_KEY)) + enabledRoles.push(Constants.FINGERPRINT_ROLE_NAME); + +- if (JSON.stringify(enabledRoles) === JSON.stringify(this._enabledRoles)) ++ const switchableAuthentication = ++ this._settings.get_boolean(SWITCHABLE_AUTHENTICATION_KEY); ++ ++ if (JSON.stringify(enabledRoles) === JSON.stringify(this._enabledRoles) && ++ switchableAuthentication === this._switchableAuthenticationEnabled) + return; + + this._enabledRoles = enabledRoles; ++ this._switchableAuthenticationEnabled = switchableAuthentication; + + this._createAuthServices(); + } +@@ -447,19 +467,25 @@ var ShellUserVerifier = class { + allowedFailures: this.allowedFailures, + reauthOnly: this._reauthOnly, + }; +- if (AuthServicesLegacy.supportsAny(this._enabledRoles)) ++ if (this._switchableAuthenticationEnabled && ++ AuthServicesSSSDSwitchable.supportsAny(this._enabledRoles)) ++ this._authServicesSSSDSwitchable = new AuthServicesSSSDSwitchable(params); ++ else if (AuthServicesLegacy.supportsAny(this._enabledRoles)) + this._authServicesLegacy = new AuthServicesLegacy(params); + + this._connectAuthServices(); + } + + _clearAuthServices() { ++ this._authServicesSSSDSwitchable?.destroy(); ++ this._authServicesSSSDSwitchable = null; + this._authServicesLegacy?.destroy(); + this._authServicesLegacy = null; + } + + _connectAuthServices() { + [ ++ this._authServicesSSSDSwitchable, + this._authServicesLegacy, + ].forEach(authServices => { + authServices?.connectObject( +@@ -483,9 +509,13 @@ var ShellUserVerifier = class { + } + + _onMechanismsChanged() { +- const mechanisms = this._authServicesLegacy?.enabledMechanisms ?? []; ++ const mechanismsSwitchable = this._authServicesSSSDSwitchable?.enabledMechanisms ?? []; ++ const mechanismsLegacy = this._authServicesLegacy?.enabledMechanisms ?? []; ++ const mechanisms = [...mechanismsSwitchable, ...mechanismsLegacy]; + +- const selectedMechanism = this._authServicesLegacy?.selectedMechanism ?? ++ const selectedMechanism = ++ this._authServicesSSSDSwitchable?.selectedMechanism ?? ++ this._authServicesLegacy?.selectedMechanism ?? + mechanisms.find(m => isSelectable(m)) ?? + {}; + +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index f2fb20c7a4..bde0cf66c4 100644 +--- a/js/js-resources.gresource.xml ++++ b/js/js-resources.gresource.xml +@@ -6,6 +6,7 @@ + gdm/authPrompt.js + gdm/authServices.js + gdm/authServicesLegacy.js ++ gdm/authServicesSSSDSwitchable.js + gdm/batch.js + gdm/constants.js + gdm/loginDialog.js +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index db6f1d4ba1..e4c63c92cb 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -542,6 +542,7 @@ var UnlockDialog = GObject.registerClass({ + try { + this._gdmClient.set_enabled_extensions([ + Gdm.UserVerifierChoiceList.interface_info().name, ++ Gdm.UserVerifierCustomJSON.interface_info().name, + ]); + } catch (e) { + } +diff --git a/po/POTFILES.in b/po/POTFILES.in +index a7a645a41c..f2592f04c6 100644 +--- a/po/POTFILES.in ++++ b/po/POTFILES.in +@@ -7,6 +7,7 @@ data/org.gnome.Shell.PortalHelper.desktop.in.in + js/dbusServices/extensions/ui/extension-prefs-dialog.ui + js/gdm/authPrompt.js + js/gdm/authServicesLegacy.js ++js/gdm/authServicesSSSDSwitchable.js + js/gdm/loginDialog.js + js/gdm/util.js + js/misc/systemActions.js +-- +2.51.0 + + +From e3989e74e46b5ebddcb2b7dfbbbc3d50888cedab Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Wed, 12 Nov 2025 17:25:33 +0100 +Subject: [PATCH 43/48] authServicesSSSDSwitchable: Allow resetting expired + password + +JSON protocol can't inform when a password is expired, so it's needed to +check the info message. + +When the password is expired, a resetting process will be started. It will +have multiple requests to insert the current password and insert the new +password. This has to be done using the old flow because the PAM JSON protocol +doesn't support this process yet. +--- + js/gdm/authServicesSSSDSwitchable.js | 26 +++++++++++++++++++++++++- + 1 file changed, 25 insertions(+), 1 deletion(-) + +diff --git a/js/gdm/authServicesSSSDSwitchable.js b/js/gdm/authServicesSSSDSwitchable.js +index c67933b6f3..ecbd80dc81 100644 +--- a/js/gdm/authServicesSSSDSwitchable.js ++++ b/js/gdm/authServicesSSSDSwitchable.js +@@ -22,10 +22,18 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + super(params); + } + +- _handleAnswerQuery(serviceName, answer) { ++ async _handleAnswerQuery(serviceName, answer) { + if (serviceName !== this._selectedMechanism?.serviceName) + return; + ++ if (this._selectedMechanism.role === Constants.PASSWORD_ROLE_NAME && ++ this._resettingPassword) { ++ this._userVerifier.call_answer_query(serviceName, ++ answer, ++ this._cancellable).catch(logErrorUnlessCancelled); ++ return; ++ } ++ + let response; + switch (this._selectedMechanism.role) { + case Constants.PASSWORD_ROLE_NAME: +@@ -57,6 +65,8 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + this._priorityList = null; + this._enabledMechanisms = null; + this._selectedMechanism = null; ++ ++ this._resettingPassword = false; + } + + _handleOnCustomJSONRequest(_serviceName, _protocol, _version, json) { +@@ -101,6 +111,13 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + } + + _handleOnInfo(serviceName, info) { ++ // sssd can't inform about expired password from JSON so it's needed ++ // to check the info message and handle the reset using the old flow ++ if (serviceName === this._selectedMechanism?.serviceName && ++ this._selectedMechanism.role === Constants.PASSWORD_ROLE_NAME && ++ info.includes('Password expired. Change your password now')) ++ this._resettingPassword = true; ++ + if (serviceName === this._selectedMechanism?.serviceName) + this.emit('queue-message', serviceName, info, Util.MessageType.INFO); + } +@@ -114,6 +131,13 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + } + } + ++ _handleOnSecretInfoQuery(serviceName, secretQuestion) { ++ if (serviceName === this._selectedMechanism?.serviceName && ++ this._selectedMechanism.role === Constants.PASSWORD_ROLE_NAME && ++ this._resettingPassword) ++ this.emit('ask-question', serviceName, secretQuestion, true); ++ } ++ + _handleOnConversationStopped(serviceName) { + if (serviceName !== this._selectedMechanism?.serviceName) + return; +-- +2.51.0 + + +From 6f02e838291e52e0f3bf6a70e3c5d1d0ad208a6e Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 21 Aug 2025 22:25:02 +0200 +Subject: [PATCH 44/48] gdm: Allow starting authServicesLegacy as fallback +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +There can be cases where authServicesSSSDSwitchable doesn't support some +authentication methods. +In such cases, we should start authServicesLegacy as a fallback for +those unsupported methods. + +authServicesSSSDSwitchable leads when the fallback will happen emitting +'mechanisms-changed'. Then unsupportedRoles, from authServicesSSSDSwitchable will +be used to update the enabledRoles in authServicesLegacy. + +The supportedRoles and unsupportedRoles properties work together: +- supportedRoles: static per class, defines roles the class can handle +- unsupportedRoles: dynamic, roles from enabledRoles that should be delegated + to another auth service as fallback + +For authServicesSSSDSwitchable: +- WAITING/FOUND state: unsupportedRoles returns enabledRoles not in supportedRoles + (e.g., fingerprint). If a role like password isn't in the JSON mechanisms, + that means it's not enabled for this user — authServicesLegacy shouldn't + handle it. +- NOT_FOUND state: unsupportedRoles returns all enabledRoles, since pam_sss + isn't handling this user and authServicesLegacy must take over completely. + +This way authServicesSSSDSwitchable controls which roles authServicesLegacy +handles, enabling coordination between both services. +--- + js/gdm/authServices.js | 22 ++++++++++ + js/gdm/authServicesLegacy.js | 5 +++ + js/gdm/authServicesSSSDSwitchable.js | 65 +++++++++++++++++++++++++++- + js/gdm/util.js | 28 ++++++++++-- + 4 files changed, 114 insertions(+), 6 deletions(-) + +diff --git a/js/gdm/authServices.js b/js/gdm/authServices.js +index 3416ce3055..cfc16b9d7f 100644 +--- a/js/gdm/authServices.js ++++ b/js/gdm/authServices.js +@@ -102,6 +102,10 @@ export class AuthServices extends GObject.Object { + return this.constructor.SupportedRoles; + } + ++ get unsupportedRoles() { ++ return this._handleGetUnsupportedRoles(); ++ } ++ + selectChoice(serviceName, key) { + this._handleSelectChoice(serviceName, key); + } +@@ -178,6 +182,18 @@ export class AuthServices extends GObject.Object { + this._handleClear(); + } + ++ updateEnabledRoles(roles) { ++ if (this._enabledRoles.length === roles.length && ++ this._enabledRoles.every(r => roles.includes(r))) ++ return false; ++ ++ this._enabledRoles = roles; ++ ++ this._handleUpdateEnabledRoles(); ++ ++ return true; ++ } ++ + _clearUserVerifier() { + this._disconnectUserVerifierSignals(); + this._userVerifier = null; +@@ -409,6 +425,10 @@ export class AuthServices extends GObject.Object { + } + } + ++ _handleGetUnsupportedRoles() { ++ return []; ++ } ++ + _handleSelectChoice() {} + + _handleAnswerQuery() {} +@@ -427,6 +447,8 @@ export class AuthServices extends GObject.Object { + + _handleClear() {} + ++ _handleUpdateEnabledRoles() {} ++ + _handleUpdateEnabledMechanisms() { + throw new GObject.NotImplementedError( + `_handleUpdateEnabledMechanisms in ${this.constructor.name}`); +diff --git a/js/gdm/authServicesLegacy.js b/js/gdm/authServicesLegacy.js +index c0b9fdedd8..c1ff1242dc 100644 +--- a/js/gdm/authServicesLegacy.js ++++ b/js/gdm/authServicesLegacy.js +@@ -140,6 +140,11 @@ export class AuthServicesLegacy extends AuthServices { + this.emit('mechanisms-changed'); + } + ++ _handleUpdateEnabledRoles() { ++ this._selectedMechanism = null; ++ this._updateEnabledMechanisms(); ++ } ++ + _handleUpdateEnabledMechanisms() { + if (!this._fingerprintManager?.readerFound) { + this._enabledMechanisms.push(...Mechanisms.filter(m => +diff --git a/js/gdm/authServicesSSSDSwitchable.js b/js/gdm/authServicesSSSDSwitchable.js +index ecbd80dc81..a33ee0df91 100644 +--- a/js/gdm/authServicesSSSDSwitchable.js ++++ b/js/gdm/authServicesSSSDSwitchable.js +@@ -5,6 +5,12 @@ import {logErrorUnlessCancelled} from '../misc/errorUtils.js'; + import * as Util from './util.js'; + import {AuthServices} from './authServices.js'; + ++const MechanismsStatus = { ++ WAITING: 0, ++ NOT_FOUND: 1, ++ FOUND: 2, ++}; ++ + export class AuthServicesSSSDSwitchable extends AuthServices { + static SupportedRoles = [ + Constants.PASSWORD_ROLE_NAME, +@@ -20,6 +26,8 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + + constructor(params) { + super(params); ++ ++ this._mechanismsStatus = MechanismsStatus.WAITING; + } + + async _handleAnswerQuery(serviceName, answer) { +@@ -51,13 +59,30 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + } + } + ++ _handleGetUnsupportedRoles() { ++ // Until we know the mechanisms or wait for them, ++ // consider supportedRoles as supported ++ switch (this._mechanismsStatus) { ++ case MechanismsStatus.WAITING: ++ case MechanismsStatus.FOUND: ++ return this._enabledRoles.filter(r => !this.supportedRoles.includes(r)); ++ case MechanismsStatus.NOT_FOUND: ++ return this._enabledRoles; ++ default: ++ throw new GObject.NotImplementedError(`invalid MechanismStatus: ${this._mechanismsStatus}`); ++ } ++ } ++ + _handleReset() { + this._savedMechanism = null; ++ this._mechanismsStatus = MechanismsStatus.WAITING; + } + + _handleCancel() { +- if (this._selectedMechanism) ++ if (this._selectedMechanism) { + this._savedMechanism = this._selectedMechanism; ++ this._mechanismsStatus = MechanismsStatus.WAITING; ++ } + } + + _handleClear() { +@@ -72,6 +97,9 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + _handleOnCustomJSONRequest(_serviceName, _protocol, _version, json) { + let requestObject; + ++ if (this._mechanismsStatus !== MechanismsStatus.WAITING) ++ log(`Received unexpected JSON message, something might be wrong`); ++ + try { + requestObject = JSON.parse(json); + } catch (e) { +@@ -87,6 +115,10 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + if (this._mechanisms) + this._updateEnabledMechanisms(); + } ++ ++ this._mechanismsStatus = authSelection ++ ? MechanismsStatus.FOUND ++ : MechanismsStatus.NOT_FOUND; + } + + _handleUpdateEnabledMechanisms() { +@@ -111,6 +143,9 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + } + + _handleOnInfo(serviceName, info) { ++ if (!this._eventExpected()) ++ return; ++ + // sssd can't inform about expired password from JSON so it's needed + // to check the info message and handle the reset using the old flow + if (serviceName === this._selectedMechanism?.serviceName && +@@ -123,6 +158,9 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + } + + _handleOnProblem(serviceName, problem) { ++ if (!this._eventExpected()) ++ return; ++ + if (serviceName === this._selectedMechanism?.serviceName) { + this.emit('queue-priority-message', + serviceName, +@@ -131,7 +169,16 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + } + } + ++ _handleOnInfoQuery() { ++ if (!this._eventExpected()) ++ // eslint-disable-next-line no-useless-return ++ return; ++ } ++ + _handleOnSecretInfoQuery(serviceName, secretQuestion) { ++ if (!this._eventExpected()) ++ return; ++ + if (serviceName === this._selectedMechanism?.serviceName && + this._selectedMechanism.role === Constants.PASSWORD_ROLE_NAME && + this._resettingPassword) +@@ -151,7 +198,7 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + + _handleCanStartService(serviceName) { + return serviceName === Constants.SWITCHABLE_AUTH_SERVICE_NAME && +- !this._enabledMechanisms; ++ this._mechanismsStatus === MechanismsStatus.WAITING; + } + + _formatResponse(answer) { +@@ -182,6 +229,20 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + serviceName, JSON.stringify(response), this._cancellable).catch(logErrorUnlessCancelled); + } + ++ _eventExpected() { ++ // If legacy PAM messages are received before receiving JSON PAM ++ // messages informing about mechanisms, then pam_unix is being ++ // used and the user is not supported by pam_sss using JSON. ++ // Fallback to legacy authentication services. ++ if (this._mechanismsStatus === MechanismsStatus.WAITING) { ++ this._mechanismsStatus = MechanismsStatus.NOT_FOUND; ++ this.emit('mechanisms-changed'); ++ return false; ++ } ++ ++ return true; ++ } ++ + _startPasswordLogin() { + const {serviceName, prompt} = this._selectedMechanism; + +diff --git a/js/gdm/util.js b/js/gdm/util.js +index 9d9722187d..e69761747b 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -243,6 +243,11 @@ var ShellUserVerifier = class { + this._authServicesSSSDSwitchable?.clear(); + this._authServicesLegacy?.clear(); + ++ if (this._authServicesSSSDSwitchable) { ++ this._authServicesLegacy?.updateEnabledRoles( ++ this._authServicesSSSDSwitchable.unsupportedRoles); ++ } ++ + this._clearMessageQueue(); + + this._cancellable?.cancel(); +@@ -468,10 +473,14 @@ var ShellUserVerifier = class { + reauthOnly: this._reauthOnly, + }; + if (this._switchableAuthenticationEnabled && +- AuthServicesSSSDSwitchable.supportsAny(this._enabledRoles)) ++ AuthServicesSSSDSwitchable.supportsAny(this._enabledRoles)) { + this._authServicesSSSDSwitchable = new AuthServicesSSSDSwitchable(params); +- else if (AuthServicesLegacy.supportsAny(this._enabledRoles)) ++ ++ params.enabledRoles = this._authServicesSSSDSwitchable.unsupportedRoles; + this._authServicesLegacy = new AuthServicesLegacy(params); ++ } else if (AuthServicesLegacy.supportsAny(this._enabledRoles)) { ++ this._authServicesLegacy = new AuthServicesLegacy(params); ++ } + + this._connectAuthServices(); + } +@@ -509,9 +518,12 @@ var ShellUserVerifier = class { + } + + _onMechanismsChanged() { +- const mechanismsSwitchable = this._authServicesSSSDSwitchable?.enabledMechanisms ?? []; ++ if (this._enableFallbackMechanisms()) ++ return; ++ ++ const mechanismsSSSDSwitchable = this._authServicesSSSDSwitchable?.enabledMechanisms ?? []; + const mechanismsLegacy = this._authServicesLegacy?.enabledMechanisms ?? []; +- const mechanisms = [...mechanismsSwitchable, ...mechanismsLegacy]; ++ const mechanisms = [...mechanismsSSSDSwitchable, ...mechanismsLegacy]; + + const selectedMechanism = + this._authServicesSSSDSwitchable?.selectedMechanism ?? +@@ -522,6 +534,14 @@ var ShellUserVerifier = class { + this.emit('mechanisms-changed', mechanisms, selectedMechanism); + } + ++ _enableFallbackMechanisms() { ++ if (!this._authServicesSSSDSwitchable || !this._authServicesLegacy) ++ return false; ++ ++ return this._authServicesLegacy.updateEnabledRoles( ++ this._authServicesSSSDSwitchable.unsupportedRoles); ++ } ++ + async _waitPendingMessages(task) { + try { + await this._handlePendingMessages(); +-- +2.51.0 + + +From 124b735692aa5f8f892dc739c15c047a2ae64c1d Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Marco=20Trevisan=20=28Trevi=C3=B1o=29?= +Date: Wed, 11 Feb 2026 03:36:54 +0100 +Subject: [PATCH 45/48] ui/qrCode: Add a QR Code widget + +The widget can only have a squared size that is picked using the maximum +between the provided width ad height properties, using the minimum of +the space available. + +The qr code is always generated using the minimum sized image with a +transparent background (so that the widget background color is used) +while the foreground is colored following the theme. + +The texture is then draw using the nearest filter so that it will adjust +to the actor size without the need to regenerate it on size changes +theme +--- + .../gnome-shell-sass/widgets/_qr-code.scss | 14 +++ + data/theme/meson.build | 1 + + js/js-resources.gresource.xml | 1 + + js/ui/qrCode.js | 118 ++++++++++++++++++ + 4 files changed, 134 insertions(+) + create mode 100644 data/theme/gnome-shell-sass/widgets/_qr-code.scss + create mode 100644 js/ui/qrCode.js + +diff --git a/data/theme/gnome-shell-sass/widgets/_qr-code.scss b/data/theme/gnome-shell-sass/widgets/_qr-code.scss +new file mode 100644 +index 0000000000..da04425bd6 +--- /dev/null ++++ b/data/theme/gnome-shell-sass/widgets/_qr-code.scss +@@ -0,0 +1,14 @@ ++// QR Code ++.qr-code { ++ border-radius: $base_border_radius * .5; ++ border-width: 1em; ++ @if ($variant == 'light') { ++ $qrcode_bg_color: mix($fg_color, $bg_color, 8%); ++ background-color: $qrcode_bg_color; ++ border-color: $qrcode_bg_color; ++ } @else { ++ background-color: $system_fg_color; ++ border-color: $system_fg_color; ++ color: $system_bg_color; ++ } ++} +diff --git a/data/theme/meson.build b/data/theme/meson.build +index 87112c358d..169262d518 100644 +--- a/data/theme/meson.build ++++ b/data/theme/meson.build +@@ -30,6 +30,7 @@ theme_sources = files([ + 'gnome-shell-sass/widgets/_panel.scss', + 'gnome-shell-sass/widgets/_popovers.scss', + 'gnome-shell-sass/widgets/_screen-shield.scss', ++ 'gnome-shell-sass/widgets/_qr-code.scss', + 'gnome-shell-sass/widgets/_scrollbars.scss', + 'gnome-shell-sass/widgets/_search-entry.scss', + 'gnome-shell-sass/widgets/_search-results.scss', +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index bde0cf66c4..e8575a7b6e 100644 +--- a/js/js-resources.gresource.xml ++++ b/js/js-resources.gresource.xml +@@ -95,6 +95,7 @@ + ui/pointerA11yTimeout.js + ui/pointerWatcher.js + ui/popupMenu.js ++ ui/qrCode.js + ui/remoteSearch.js + ui/ripples.js + ui/runDialog.js +diff --git a/js/ui/qrCode.js b/js/ui/qrCode.js +new file mode 100644 +index 0000000000..caf0999f34 +--- /dev/null ++++ b/js/ui/qrCode.js +@@ -0,0 +1,118 @@ ++import Clutter from 'gi://Clutter'; ++import Cogl from 'gi://Cogl'; ++import GObject from 'gi://GObject'; ++import Gio from 'gi://Gio'; ++import GnomeQR from 'gi://GnomeQR'; ++import St from 'gi://St'; ++import {logErrorUnlessCancelled} from '../misc/errorUtils.js'; ++ ++Gio._promisify(GnomeQR, 'generate_qr_code_async'); ++ ++const QR_CODE_DEFAULT_SIZE = 150; ++const QR_CODE_TRANSPARENT_COLOR = new GnomeQR.Color({alpha: 0}); ++ ++export class QrCode extends St.Bin { ++ static [GObject.properties] = { ++ 'url': GObject.ParamSpec.string( ++ 'url', null, null, ++ GObject.ParamFlags.READWRITE, ++ null), ++ }; ++ ++ static { ++ GObject.registerClass(this); ++ } ++ ++ constructor(params) { ++ const qrSize = Math.max(params.width ?? 0, params.height ?? 0) || ++ QR_CODE_DEFAULT_SIZE; ++ ++ super({ ++ styleClass: 'qr-code', ++ width: qrSize, ++ height: qrSize, ++ ...params, ++ }); ++ ++ this.set_child(new Clutter.Actor({ ++ xExpand: true, ++ yExpand: true, ++ xAlign: Clutter.ActorAlign.FILL, ++ yAlign: Clutter.ActorAlign.FILL, ++ minificationFilter: Clutter.ScalingFilter.NEAREST, ++ magnificationFilter: Clutter.ScalingFilter.NEAREST, ++ })); ++ ++ this._fgColor = null; ++ ++ this.connect('notify::url', () => ++ this._update().catch(logErrorUnlessCancelled)); ++ }; ++ ++ vfunc_allocate(box) { ++ const width = box.get_width(); ++ const height = box.get_height(); ++ ++ box.set_size(Math.min(width, height), Math.min(width, height)); ++ super.vfunc_allocate(box); ++ } ++ ++ vfunc_style_changed() { ++ super.vfunc_style_changed(); ++ ++ if (!this.get_parent()) ++ return; ++ ++ const node = this.get_theme_node(); ++ const fgColor = node.get_foreground_color(); ++ ++ if (!this._fgColor?.equal(fgColor)) { ++ this._fgColor = fgColor; ++ this._update().catch(logErrorUnlessCancelled); ++ } ++ } ++ ++ async _update() { ++ const fgColor = QrCode.#getGnomeQRColor(this._fgColor); ++ ++ this._cancellable?.cancel(); ++ const cancellable = new Gio.Cancellable(); ++ this._cancellable = cancellable; ++ ++ const [pixelData, qrSize] = await GnomeQR.generate_qr_code_async( ++ this.url, ++ 0 /* size, are fine with the minimum value */, ++ QR_CODE_TRANSPARENT_COLOR, ++ fgColor, ++ GnomeQR.PixelFormat.RGBA_8888, ++ GnomeQR.EccLevel.LOW, ++ cancellable); ++ ++ const coglContext = global.stage.context.get_backend().get_cogl_context(); ++ const bpp = Cogl.pixel_format_get_bytes_per_pixel(Cogl.PixelFormat.RGBA_8888, 0); ++ const rowStride = qrSize * bpp; ++ ++ const content = St.ImageContent.new_with_preferred_size(qrSize, qrSize); ++ content.set_bytes( ++ coglContext, ++ pixelData, ++ Cogl.PixelFormat.RGBA_8888, ++ qrSize, ++ qrSize, ++ rowStride); ++ ++ this.child.set_content(content); ++ } ++ ++ static #getGnomeQRColor(color) { ++ if (!color) ++ return null; ++ ++ return new GnomeQR.Color({ ++ red: color.red, ++ green: color.green, ++ blue: color.blue, ++ alpha: color.alpha, ++ }); ++ } ++}; +-- +2.51.0 + + +From 634b290c79fbc7f552b536dbb121e25ea435df41 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 14:18:24 -0500 +Subject: [PATCH 46/48] gdm: Add support for Web Login in + authServicesSSSDSwitchable +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +SSSD supports Web Login via a url on an external device. It +advertises this support over the 'auth-selection' JSON protocol. + +This commit adds Web Login support by showing the new WebLoginDialog that +shows an url and a QR code. + +Use the bindings of the new GnomeQR library to generate the QR code. + +Co-authored-by: Marco Trevisan (Treviño) +--- + js/gdm/authPrompt.js | 105 ++++++++- + js/gdm/authServices.js | 6 + + js/gdm/authServicesSSSDSwitchable.js | 80 +++++++ + js/gdm/constants.js | 1 + + js/gdm/loginDialog.js | 5 +- + js/gdm/util.js | 5 + + js/gdm/webLogin.js | 308 +++++++++++++++++++++++++++ + js/js-resources.gresource.xml | 1 + + js/ui/unlockDialog.js | 3 +- + js/ui/userWidget.js | 8 + + po/POTFILES.in | 1 + + 11 files changed, 511 insertions(+), 12 deletions(-) + create mode 100644 js/gdm/webLogin.js + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 5a92c725b5..8c5c8a1fab 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -12,6 +12,7 @@ const Vmware = imports.gdm.vmware; + const Params = imports.misc.params; + const ShellEntry = imports.ui.shellEntry; + const UserWidget = imports.ui.userWidget; ++const WebLogin = imports.gdm.webLogin; + const Util = imports.misc.util; + + var DEFAULT_BUTTON_WELL_ICON_SIZE = 16; +@@ -85,6 +86,7 @@ var AuthPrompt = GObject.registerClass({ + 'show-message', this._onShowMessage.bind(this), + 'show-choice-list', this._onShowChoiceList.bind(this), + 'mechanisms-changed', (_, ...args) => this.emit('mechanisms-changed', ...args), ++ 'web-login', this._onWebLogin.bind(this), + 'verification-failed', this._onVerificationFailed.bind(this), + 'verification-complete', this._onVerificationComplete.bind(this), + 'reset', this._onReset.bind(this), +@@ -297,6 +299,28 @@ var AuthPrompt = GObject.registerClass({ + this._defaultButtonWell.add_child(this._spinner); + + this.setActorInDefaultButtonWell(this._nextButton); ++ ++ this._webLoginIntro = new WebLogin.WebLoginIntro(); ++ this._webLoginIntro.set({ ++ y_expand: true, ++ }); ++ this._webLoginIntro.connect('clicked', () => { ++ this._webLoginIntro.hide(); ++ this._openWebLoginDialog(); ++ }); ++ this._mainBox.add_child(this._webLoginIntro); ++ ++ this._webLoginDialog = new WebLogin.WebLoginDialog(); ++ this._webLoginDialog.connect('cancel', () => { ++ if (this._webLoginDialog.isLoading) { ++ this.reset({softReset: true}); ++ } else { ++ this._closeWebLoginDialog(); ++ this._fadeInElement(this._webLoginIntro); ++ } ++ }); ++ this._webLoginDialog.connect('loading', () => this.emit('loading', this._webLoginDialog.isLoading)); ++ this._inputWell.add_child(this._webLoginDialog); + } + + _updateShowPasswordIcon() { +@@ -440,6 +464,52 @@ var AuthPrompt = GObject.registerClass({ + this.emit('prompted'); + } + ++ _onWebLogin(_userVerifier, serviceName, introMessage, message, url, code, buttons) { ++ if (this._queryingService) ++ this.clear(); ++ ++ this._queryingService = serviceName; ++ this._promptStep++; ++ this._updateCancelButton(); ++ ++ this._webLoginParams = {message, url, code, buttons}; ++ ++ this._entryArea.hide(); ++ ++ if (this._preemptiveAnswer) ++ this._preemptiveAnswer = null; ++ ++ if (!this._webLoginDialog.visible && introMessage) { ++ this._webLoginIntro.setMessage(introMessage); ++ this._fadeInElement(this._webLoginIntro); ++ } else { ++ this._openWebLoginDialog(); ++ } ++ ++ this.emit('prompted'); ++ } ++ ++ _closeWebLoginDialog() { ++ this._webLoginDialog.hide(); ++ ++ this._userWell.get_child()?.showAvatar(); ++ this._mainBox.show(); ++ ++ this.webLoginActive = false; ++ this.remove_style_class_name('web-login-active'); ++ } ++ ++ _openWebLoginDialog() { ++ this._userWell.get_child()?.hideAvatar(); ++ this._mainBox.hide(); ++ ++ this._webLoginDialog.update(this._webLoginParams); ++ this._fadeInElement(this._webLoginDialog); ++ ++ this.webLoginActive = true; ++ this.add_style_class_name('web-login-active'); ++ } ++ + _onShowMessage(_userVerifier, serviceName, message, type) { + this.setMessage(serviceName, message, type); + +@@ -448,7 +518,9 @@ var AuthPrompt = GObject.registerClass({ + if (message && + type < GdmUtil.MessageType.ERROR && + !this._entryArea.visible && +- !this._authList.visible) ++ !this._authList.visible && ++ !this._webLoginIntro.visible && ++ !this._webLoginDialog.visible) + this._fadeInElement(this._entryArea); + + this.emit('prompted'); +@@ -474,12 +546,14 @@ var AuthPrompt = GObject.registerClass({ + this.stopSpinning({animate: true}); + this.verificationStatus = AuthPromptStatus.VERIFICATION_SUCCEEDED; + +- this._mainBox.reactive = false; +- this._mainBox.can_focus = false; +- this._mainBox.ease({ +- opacity: 0, +- duration: MESSAGE_FADE_OUT_ANIMATION_TIME, +- mode: Clutter.AnimationMode.EASE_OUT_QUAD, ++ [this._mainBox, this._webLoginDialog].forEach(widget => { ++ widget.reactive = false; ++ widget.can_focus = false; ++ widget.ease({ ++ opacity: 0, ++ duration: MESSAGE_FADE_OUT_ANIMATION_TIME, ++ mode: Clutter.AnimationMode.EASE_OUT_QUAD, ++ }); + }); + + this.emit('verification-complete'); +@@ -551,6 +625,9 @@ var AuthPrompt = GObject.registerClass({ + stopSpinning({animate = false} = {}) { + this.emit('loading', false); + this.setActorInDefaultButtonWell(this._nextButton, animate); ++ ++ if (this._webLoginDialog.isLoading) ++ this._webLoginDialog.stopLoading(); + } + + clear(params) { +@@ -568,10 +645,14 @@ var AuthPrompt = GObject.registerClass({ + this._authListTitle.child.text = ''; + this._authList.clear(); + this._authList.hide(); ++ this._webLoginIntro.hide(); ++ this._closeWebLoginDialog(); + +- this._mainBox.opacity = 255; +- this._mainBox.reactive = true; +- this._mainBox.can_focus = true; ++ [this._mainBox, this._webLoginDialog].forEach(widget => { ++ widget.opacity = 255; ++ widget.reactive = true; ++ widget.can_focus = true; ++ }); + } + + setQuestion(question) { +@@ -583,6 +664,8 @@ var AuthPrompt = GObject.registerClass({ + this._entry.hint_text = question; + + this._authList.hide(); ++ this._webLoginIntro.hide(); ++ this._closeWebLoginDialog(); + + this._fadeInElement(this._entryArea); + } +@@ -692,6 +775,8 @@ var AuthPrompt = GObject.registerClass({ + updateSensitivity({sensitive}) { + const authWidget = [ + this._authList, ++ this._webLoginIntro, ++ this._webLoginDialog, + ].find(widget => widget.visible) ?? this._entry; + + if (authWidget.reactive === sensitive) +diff --git a/js/gdm/authServices.js b/js/gdm/authServices.js +index cfc16b9d7f..a62a3de901 100644 +--- a/js/gdm/authServices.js ++++ b/js/gdm/authServices.js +@@ -48,6 +48,12 @@ export class AuthServices extends GObject.Object { + param_types: [GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_JSOBJECT], + }, + 'mechanisms-changed': {}, ++ 'web-login': { ++ param_types: [ ++ GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_STRING, ++ GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_JSOBJECT, ++ ], ++ }, + }; + + static { +diff --git a/js/gdm/authServicesSSSDSwitchable.js b/js/gdm/authServicesSSSDSwitchable.js +index a33ee0df91..5369334b0a 100644 +--- a/js/gdm/authServicesSSSDSwitchable.js ++++ b/js/gdm/authServicesSSSDSwitchable.js +@@ -1,3 +1,4 @@ ++import GLib from 'gi://GLib'; + import GObject from 'gi://GObject'; + + import * as Constants from './constants.js'; +@@ -14,10 +15,12 @@ const MechanismsStatus = { + export class AuthServicesSSSDSwitchable extends AuthServices { + static SupportedRoles = [ + Constants.PASSWORD_ROLE_NAME, ++ Constants.WEB_LOGIN_ROLE_NAME, + ]; + + static RoleToService = { + [Constants.PASSWORD_ROLE_NAME]: Constants.SWITCHABLE_AUTH_SERVICE_NAME, ++ [Constants.WEB_LOGIN_ROLE_NAME]: Constants.SWITCHABLE_AUTH_SERVICE_NAME, + }; + + static { +@@ -56,6 +59,9 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + case Constants.PASSWORD_ROLE_NAME: + this._startPasswordLogin(); + break; ++ case Constants.WEB_LOGIN_ROLE_NAME: ++ this._startWebLogin(); ++ break; + } + } + +@@ -92,6 +98,8 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + this._selectedMechanism = null; + + this._resettingPassword = false; ++ ++ this._clearWebLoginTimeout(); + } + + _handleOnCustomJSONRequest(_serviceName, _protocol, _version, json) { +@@ -131,6 +139,8 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + // filter out mechanisms with roles that are not enabled + .filter(m => this._enabledRoles.includes(m.role))); + ++ this._trackWebLoginTimeout(); ++ + const selectedMechanism = + this._enabledMechanisms + .find(m => this._savedMechanism?.role === m.role) ?? +@@ -142,6 +152,29 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + this._savedMechanism = null; + } + ++ _trackWebLoginTimeout() { ++ this._clearWebLoginTimeout(); ++ ++ const webLoginMechanism = this._enabledMechanisms ++ .find(m => m.role === Constants.WEB_LOGIN_ROLE_NAME); ++ if (!webLoginMechanism) ++ return; ++ ++ const {timeout} = webLoginMechanism; ++ if (!timeout) ++ return; ++ ++ this._webLoginTimeoutId = GLib.timeout_add_seconds_once(GLib.PRIORITY_DEFAULT, ++ timeout, () => { ++ if (this._selectedMechanism?.role !== Constants.WEB_LOGIN_ROLE_NAME) ++ webLoginMechanism.needsRefresh = true; ++ else ++ this.emit('reset', {softReset: true}); ++ ++ this._webLoginTimeoutId = 0; ++ }); ++ } ++ + _handleOnInfo(serviceName, info) { + if (!this._eventExpected()) + return; +@@ -210,6 +243,10 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + response = {password: answer}; + break; + } ++ case Constants.WEB_LOGIN_ROLE_NAME: { ++ response = {}; ++ break; ++ } + default: + throw new GObject.NotImplementedError(`formatResponse: ${role}`); + } +@@ -248,4 +285,47 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + + this.emit('ask-question', serviceName, prompt, true); + } ++ ++ _startWebLogin() { ++ const { ++ serviceName, ++ initPrompt, linkPrompt, ++ uri, code, needsRefresh, ++ } = this._selectedMechanism; ++ ++ if (!linkPrompt || !uri) ++ return; ++ ++ if (needsRefresh) { ++ this.emit('reset', {softReset: true}); ++ return; ++ } ++ ++ const buttons = [{ ++ default: true, ++ needsLoading: true, ++ label: _('Done'), ++ action: () => this._webLoginDone(), ++ }]; ++ ++ this.emit('web-login', serviceName, initPrompt, linkPrompt, uri, code, buttons); ++ } ++ ++ _webLoginDone() { ++ if (this._selectedMechanism?.role !== Constants.WEB_LOGIN_ROLE_NAME) ++ return; ++ ++ const response = this._formatResponse(); ++ this._sendResponse(response); ++ ++ this._clearWebLoginTimeout(); ++ } ++ ++ _clearWebLoginTimeout() { ++ if (!this._webLoginTimeoutId) ++ return; ++ ++ GLib.source_remove(this._webLoginTimeoutId); ++ this._webLoginTimeoutId = 0; ++ } + } +diff --git a/js/gdm/constants.js b/js/gdm/constants.js +index 2f37446c8a..e8ca48625a 100644 +--- a/js/gdm/constants.js ++++ b/js/gdm/constants.js +@@ -3,6 +3,7 @@ + export const PASSWORD_ROLE_NAME = 'password'; + export const SMARTCARD_ROLE_NAME = 'smartcard'; + export const FINGERPRINT_ROLE_NAME = 'fingerprint'; ++export const WEB_LOGIN_ROLE_NAME = 'eidp'; + + export const PASSWORD_SERVICE_NAME = 'gdm-password'; + export const SMARTCARD_SERVICE_NAME = 'gdm-smartcard'; +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index 8e71e4f0ed..f59663401b 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -611,7 +611,10 @@ var LoginDialog = GObject.registerClass({ + let authPromptAllocation = null; + let authPromptWidth = 0; + if (this._authPrompt.visible) { +- authPromptAllocation = this._getFixedTopActorAllocation(dialogBox, this._authPrompt); ++ if (this._authPrompt.webLoginActive) ++ authPromptAllocation = this._getCenterActorAllocation(dialogBox, this._authPrompt); ++ else ++ authPromptAllocation = this._getFixedTopActorAllocation(dialogBox, this._authPrompt); + authPromptWidth = authPromptAllocation.x2 - authPromptAllocation.x1; + } + +diff --git a/js/gdm/util.js b/js/gdm/util.js +index e69761747b..6c160d323b 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -20,6 +20,7 @@ var PASSWORD_AUTHENTICATION_KEY = 'enable-password-authentication'; + var FINGERPRINT_AUTHENTICATION_KEY = 'enable-fingerprint-authentication'; + var SMARTCARD_AUTHENTICATION_KEY = 'enable-smartcard-authentication'; + var SWITCHABLE_AUTHENTICATION_KEY = 'enable-switchable-authentication'; ++var WEB_AUTHENTICATION_KEY = 'enable-web-authentication'; + var BANNER_MESSAGE_KEY = 'banner-message-enable'; + var BANNER_MESSAGE_TEXT_KEY = 'banner-message-text'; + var ALLOWED_FAILURES_KEY = 'allowed-failures'; +@@ -138,6 +139,7 @@ export function isSelectable(mechanism) { + switch (mechanism.role) { + case Constants.PASSWORD_ROLE_NAME: + case Constants.SMARTCARD_ROLE_NAME: ++ case Constants.WEB_LOGIN_ROLE_NAME: + return true; + case Constants.FINGERPRINT_ROLE_NAME: + return false; +@@ -449,6 +451,8 @@ var ShellUserVerifier = class { + enabledRoles.push(Constants.SMARTCARD_ROLE_NAME); + if (this._settings.get_boolean(FINGERPRINT_AUTHENTICATION_KEY)) + enabledRoles.push(Constants.FINGERPRINT_ROLE_NAME); ++ if (this._settings.get_boolean(WEB_AUTHENTICATION_KEY)) ++ enabledRoles.push(Constants.WEB_LOGIN_ROLE_NAME); + + const switchableAuthentication = + this._settings.get_boolean(SWITCHABLE_AUTHENTICATION_KEY); +@@ -508,6 +512,7 @@ var ShellUserVerifier = class { + 'reset', (_, ...args) => this.emit('reset', ...args), + 'show-choice-list', (_, ...args) => this.emit('show-choice-list', ...args), + 'mechanisms-changed', (_, ...args) => this._onMechanismsChanged(...args), ++ 'web-login', (_, ...args) => this.emit('web-login', ...args), + this); + }); + } +diff --git a/js/gdm/webLogin.js b/js/gdm/webLogin.js +new file mode 100644 +index 0000000000..8f2a0d8767 +--- /dev/null ++++ b/js/gdm/webLogin.js +@@ -0,0 +1,308 @@ ++// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- ++// ++// A widget showing a URL for web login ++/* exported WebLoginPrompt */ ++ ++import Clutter from 'gi://Clutter'; ++import GObject from 'gi://GObject'; ++import Pango from 'gi://Pango'; ++import St from 'gi://St'; ++import {Spinner} from '../ui/animation.js'; ++import * as Params from '../misc/params.js'; ++import {QrCode} from '../ui/qrCode.js'; ++ ++const QR_CODE_SIZE = 150; ++const WEB_LOGIN_SPINNER_SIZE = 35; ++const URL_LABEL_LONG_THRESHOLD = 45; ++ ++export const WebLoginPrompt = GObject.registerClass( ++class WebLoginPrompt extends St.BoxLayout { ++ constructor(params) { ++ const {qrSize: qrCodeSize, message, url, code} = Params.parse(params, { ++ qrSize: QR_CODE_SIZE, ++ message: null, ++ url: null, ++ code: null, ++ }); ++ ++ super({ ++ styleClass: 'web-login-prompt', ++ vertical: true, ++ y_align: Clutter.ActorAlign.CENTER, ++ }); ++ ++ this._urlTitleLabel = new St.Label({ ++ style_class: 'web-login-title-label', ++ }); ++ this._urlTitleLabel.clutterText.set({ ++ lineWrap: true, ++ ellipsize: Pango.EllipsizeMode.NONE, ++ }); ++ this.add_child(this._urlTitleLabel); ++ ++ this._qrCode = new QrCode({ ++ width: qrCodeSize, ++ height: qrCodeSize, ++ xAlign: Clutter.ActorAlign.CENTER, ++ }); ++ this.add_child(this._qrCode); ++ ++ this._urlLabel = new St.Label({ ++ style_class: 'web-login-url-label', ++ x_expand: true, ++ }); ++ this.add_child(this._urlLabel); ++ ++ this._codeBox = new St.BoxLayout({ ++ x_align: Clutter.ActorAlign.CENTER, ++ x_expand: true, ++ visible: false, ++ }); ++ ++ this._codeTitleLabel = new St.Label({ ++ text: _('Login code: '), ++ style_class: 'web-login-code-title-label', ++ }); ++ this._codeBox.add_child(this._codeTitleLabel); ++ ++ this._codeLabel = new St.Label({ ++ style_class: 'web-login-code-label', ++ }); ++ this._codeBox.add_child(this._codeLabel); ++ this.add_child(this._codeBox); ++ ++ this.update({message, url, code}); ++ } ++ ++ update(params) { ++ const {message, url, code} = Params.parse(params, { ++ message: null, ++ url: null, ++ code: null, ++ }); ++ ++ if (message !== null) ++ this._urlTitleLabel.text = message; ++ ++ if (url !== null) { ++ this._qrCode.set({url}); ++ ++ const formattedUrl = this._formatURLForDisplay(url); ++ this._urlLabel.text = formattedUrl; ++ ++ if (formattedUrl.length > URL_LABEL_LONG_THRESHOLD) ++ this._urlLabel.add_style_class_name('web-login-url-label-long'); ++ else ++ this._urlLabel.remove_style_class_name('web-login-url-label-long'); ++ } ++ ++ if (code !== null) { ++ this._codeLabel.text = code; ++ this._codeBox.show(); ++ } else { ++ this._codeBox.hide(); ++ } ++ } ++ ++ _formatURLForDisplay(url) { ++ const http = 'http://'; ++ const https = 'https://'; ++ ++ if (!url) ++ return http; ++ ++ if (url.endsWith('/')) ++ url = url.substring(0, url.length - 1); ++ ++ if (url.startsWith(http)) ++ return url.substring(http.length); ++ ++ if (url.startsWith(https)) ++ return url.substring(https.length); ++ ++ return url; ++ } ++}); ++ ++export const WebLoginDialog = GObject.registerClass({ ++ Signals: { ++ 'cancel': {}, ++ 'loading': {}, ++ }, ++}, class WebLoginDialog extends St.Widget { ++ constructor(params) { ++ const {message, url, code, buttons} = Params.parse(params, { ++ message: null, ++ url: null, ++ code: null, ++ buttons: [], ++ }); ++ ++ super({ ++ layout_manager: new Clutter.BinLayout(), ++ x_expand: true, ++ y_expand: true, ++ visible: false, ++ }); ++ this.bind_property_full('reactive', ++ this, 'opacity', ++ GObject.BindingFlags.SYNC_CREATE, ++ (_bind, source) => [true, source ? 255 : 128], ++ null); ++ ++ this._contentBox = new St.BoxLayout({ ++ orientation: Clutter.Orientation.VERTICAL, ++ x_expand: true, ++ y_expand: true, ++ }); ++ this.add_child(this._contentBox); ++ ++ this._webLoginPrompt = new WebLoginPrompt({code, message, url}); ++ this._contentBox.add_child(this._webLoginPrompt); ++ ++ this._buttonBox = new St.BoxLayout({ ++ orientation: Clutter.Orientation.HORIZONTAL, ++ x_align: Clutter.ActorAlign.CENTER, ++ x_expand: true, ++ }); ++ this._contentBox.add_child(this._buttonBox); ++ ++ this._updateButtons(buttons); ++ ++ this._spinnerFrame = new St.Widget({ ++ layout_manager: new Clutter.BinLayout(), ++ style_class: 'web-login-spinner', ++ }); ++ this.add_child(this._spinnerFrame); ++ ++ this._spinner = new Spinner(WEB_LOGIN_SPINNER_SIZE, { ++ hideOnStop: true, ++ }); ++ this._spinnerFrame.add_child(this._spinner); ++ } ++ ++ _updateButtons(buttons) { ++ this._buttonBox.destroy_all_children(); ++ this._cancelButton = this._addButton({ ++ label: _('Cancel'), ++ action: () => this.emit('cancel'), ++ }); ++ ++ buttons?.forEach(b => this._addButton(b)); ++ } ++ ++ _addButton(b) { ++ const button = new St.Button({ ++ style_class: 'web-login-prompt-button', ++ can_focus: true, ++ accessible_name: b.label, ++ child: new St.Label({ ++ text: b.label, ++ style_class: 'web-login-button-label', ++ }), ++ }); ++ ++ button.connectObject('clicked', () => { ++ if (b.needsLoading) ++ this._startLoading(); ++ b.action(); ++ }, this); ++ ++ button.default = b.default; ++ ++ this._buttonBox.add_child(button); ++ ++ this.bind_property('reactive', ++ button, 'reactive', ++ GObject.BindingFlags.SYNC_CREATE); ++ this.bind_property('reactive', ++ button, 'can_focus', ++ GObject.BindingFlags.SYNC_CREATE); ++ ++ return button; ++ } ++ ++ vfunc_key_focus_in() { ++ this._buttonBox.get_children().find(b => b.default)?.grab_key_focus(); ++ } ++ ++ update(params) { ++ const {message, url, code, buttons} = Params.parse(params, { ++ message: null, ++ url: null, ++ code: null, ++ buttons: [], ++ }); ++ ++ this.stopLoading(); ++ ++ this._webLoginPrompt.update({message, url, code}); ++ ++ this._updateButtons(buttons); ++ } ++ ++ _startLoading() { ++ this._webLoginPrompt.opacity = 128; ++ ++ this._buttonBox.get_children() ++ .filter(b => b !== this._cancelButton) ++ .forEach(b => { ++ b.opacity = 128; ++ b.can_focus = false; ++ b.reactive = false; ++ }); ++ this._cancelButton.grab_key_focus(); ++ ++ this._spinnerFrame.show(); ++ this._spinner.play(); ++ ++ this.isLoading = true; ++ this.emit('loading'); ++ } ++ ++ stopLoading() { ++ this._webLoginPrompt.opacity = 255; ++ ++ this._buttonBox.get_children() ++ .filter(b => b !== this._cancelButton) ++ .forEach(b => { ++ b.opacity = 255; ++ b.can_focus = this.reactive; ++ b.reactive = this.reactive; ++ }); ++ ++ this._spinnerFrame.hide(); ++ this._spinner.stop(); ++ ++ this.isLoading = false; ++ this.emit('loading'); ++ } ++}); ++ ++export var WebLoginIntro = GObject.registerClass( ++class WebLoginIntro extends St.Button { ++ constructor(params) { ++ const {message} = Params.parse(params, { ++ message: null, ++ }); ++ ++ const label = new St.Label({ ++ text: message, ++ style_class: 'web-login-button-label', ++ }); ++ ++ super({ ++ style_class: 'web-login-intro-button', ++ accessible_name: message, ++ button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, ++ reactive: true, ++ can_focus: true, ++ child: label, ++ }); ++ } ++ ++ setMessage(message) { ++ this.child.text = message; ++ this.accessible_name = message; ++ } ++}); +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index e8575a7b6e..40702ccabc 100644 +--- a/js/js-resources.gresource.xml ++++ b/js/js-resources.gresource.xml +@@ -15,6 +15,7 @@ + gdm/vmware.js + gdm/realmd.js + gdm/util.js ++ gdm/webLogin.js + + misc/config.js + misc/extensionUtils.js +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index e4c63c92cb..bebc7ad40a 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -466,7 +466,8 @@ class UnlockDialogLayout extends Clutter.LayoutManager { + // Authentication Box + const dialog = container.get_parent(); + let stackY; +- if (dialog._activePage === dialog._clock) { ++ if (dialog._activePage === dialog._clock || ++ dialog._authPrompt?.webLoginActive) { + stackY = Math.min( + Math.floor(centerY - stackHeight / 2.0), + height - stackHeight - maxNotificationsHeight); +diff --git a/js/ui/userWidget.js b/js/ui/userWidget.js +index c376028af3..80bf795239 100644 +--- a/js/ui/userWidget.js ++++ b/js/ui/userWidget.js +@@ -247,4 +247,12 @@ class UserWidget extends St.BoxLayout { + _updateUser() { + this._avatar.update(); + } ++ ++ hideAvatar() { ++ this._avatar.hide(); ++ } ++ ++ showAvatar() { ++ this._avatar.show(); ++ } + }); +diff --git a/po/POTFILES.in b/po/POTFILES.in +index f2592f04c6..dfcd19cf0e 100644 +--- a/po/POTFILES.in ++++ b/po/POTFILES.in +@@ -10,6 +10,7 @@ js/gdm/authServicesLegacy.js + js/gdm/authServicesSSSDSwitchable.js + js/gdm/loginDialog.js + js/gdm/util.js ++js/gdm/webLogin.js + js/misc/systemActions.js + js/misc/util.js + js/portalHelper/main.js +-- +2.51.0 + + +From d77e9e49c2e8c0e5f03cddd2c30ad68041b74bd7 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 14 Jan 2025 07:31:59 -0500 +Subject: [PATCH 47/48] gdm: Add support for Smartcard in + authServicesSSSDSwitchable + +This allows selecting smartcard as a login method. + +While SSSD can inform for each user what mechanisms are available, it +can't know if a user has smartcard mechanism available until a valid smartcard +is inserted. + +When a smartcard is inserted, authServicesSSSDSwitchable will be restarted to +check if a cert is available for the current user. This won't change the +authentication state (if it was doing password auth, that won't change). +--- + js/gdm/authServicesSSSDSwitchable.js | 70 ++++++++++++++++++++++++++++ + 1 file changed, 70 insertions(+) + +diff --git a/js/gdm/authServicesSSSDSwitchable.js b/js/gdm/authServicesSSSDSwitchable.js +index 5369334b0a..e04814caf3 100644 +--- a/js/gdm/authServicesSSSDSwitchable.js ++++ b/js/gdm/authServicesSSSDSwitchable.js +@@ -15,11 +15,13 @@ const MechanismsStatus = { + export class AuthServicesSSSDSwitchable extends AuthServices { + static SupportedRoles = [ + Constants.PASSWORD_ROLE_NAME, ++ Constants.SMARTCARD_ROLE_NAME, + Constants.WEB_LOGIN_ROLE_NAME, + ]; + + static RoleToService = { + [Constants.PASSWORD_ROLE_NAME]: Constants.SWITCHABLE_AUTH_SERVICE_NAME, ++ [Constants.SMARTCARD_ROLE_NAME]: Constants.SWITCHABLE_AUTH_SERVICE_NAME, + [Constants.WEB_LOGIN_ROLE_NAME]: Constants.SWITCHABLE_AUTH_SERVICE_NAME, + }; + +@@ -33,6 +35,18 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + this._mechanismsStatus = MechanismsStatus.WAITING; + } + ++ _handleSelectChoice(serviceName, key) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ if (this._selectedMechanism.role === Constants.SMARTCARD_ROLE_NAME) { ++ const certificates = this._selectedMechanism.certificates; ++ const cert = certificates.find(c => c.keyId === key); ++ this._selectedSmartcard = cert; ++ this.emit('ask-question', serviceName, cert.pinPrompt, true); ++ } ++ } ++ + async _handleAnswerQuery(serviceName, answer) { + if (serviceName !== this._selectedMechanism?.serviceName) + return; +@@ -48,6 +62,7 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + let response; + switch (this._selectedMechanism.role) { + case Constants.PASSWORD_ROLE_NAME: ++ case Constants.SMARTCARD_ROLE_NAME: + response = this._formatResponse(answer); + this._sendResponse(response); + break; +@@ -59,6 +74,9 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + case Constants.PASSWORD_ROLE_NAME: + this._startPasswordLogin(); + break; ++ case Constants.SMARTCARD_ROLE_NAME: ++ this._startSmartcardLogin(); ++ break; + case Constants.WEB_LOGIN_ROLE_NAME: + this._startWebLogin(); + break; +@@ -96,6 +114,7 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + this._priorityList = null; + this._enabledMechanisms = null; + this._selectedMechanism = null; ++ this._selectedSmartcard = null; + + this._resettingPassword = false; + +@@ -175,6 +194,14 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + }); + } + ++ _handleSmartcardChanged() { ++ if (!this._selectedMechanism || ++ !this._enabledMechanisms.some(({role}) => role === Constants.SMARTCARD_ROLE_NAME)) ++ return; ++ ++ this.emit('reset', {softReset: true, reuseEntryText: true}); ++ } ++ + _handleOnInfo(serviceName, info) { + if (!this._eventExpected()) + return; +@@ -243,6 +270,11 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + response = {password: answer}; + break; + } ++ case Constants.SMARTCARD_ROLE_NAME: { ++ const {tokenName, moduleName, keyId, label} = this._selectedSmartcard; ++ response = {pin: answer, tokenName, moduleName, keyId, label}; ++ break; ++ } + case Constants.WEB_LOGIN_ROLE_NAME: { + response = {}; + break; +@@ -286,6 +318,44 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + this.emit('ask-question', serviceName, prompt, true); + } + ++ _startSmartcardLogin() { ++ const {serviceName, certificates} = this._selectedMechanism; ++ ++ if (certificates.length === 1) { ++ this._selectedSmartcard = certificates[0]; ++ this.emit('ask-question', serviceName, certificates[0].pinPrompt, true); ++ return; ++ } ++ ++ const choiceList = {}; ++ for (const cert of certificates) ++ choiceList[cert.keyId] = this._parseCertInstruction(cert.certInstruction); ++ ++ const prompt = certificates.length === 0 ++ ? _('Insert Smartcard') ++ : _('Select Identity'); ++ ++ this.emit('show-choice-list', serviceName, prompt, choiceList); ++ } ++ ++ _parseCertInstruction(certInstruction) { ++ // Currently sssd can't split cert data in a more granular way ++ // so it's parsed manually here ++ const [description, subject] = certInstruction.split('\n'); ++ ++ const fields = subject?.split(',').map(f => f.trim()) ?? []; ++ const commonName = fields.find(f => f.startsWith('CN='))?.substring(3); ++ const organization = fields.find(f => f.startsWith('O='))?.substring(2); ++ ++ return { ++ title: commonName, ++ subtitle: description, ++ iconName: organization ? 'vcard-symbolic' : null, ++ iconTitle: organization ? _('Organization') : null, ++ iconSubtitle: organization, ++ }; ++ } ++ + _startWebLogin() { + const { + serviceName, +-- +2.51.0 + + +From ea0ce9e4db98aa4a29dc1d6511499e858c73d388 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 15 Sep 2025 16:36:42 +0200 +Subject: [PATCH 48/48] gdm: Add support for Passkey in + authServicesSSSDSwitchable + +This allows selecting passkey authentication mechanism. + +Use passkeyManager to detect when there's a passkey inserted or removed +to restart the service so the JSON protocol gives valid information +about the passkey. + +It's used 'show-choice-list' with an emtpy list, to display the touch +instruction. +--- + js/gdm/authServices.js | 12 ++++++++ + js/gdm/authServicesSSSDSwitchable.js | 46 +++++++++++++++++++++++++++- + js/gdm/constants.js | 1 + + js/gdm/util.js | 4 +++ + js/ui/unlockDialog.js | 2 ++ + 5 files changed, 64 insertions(+), 1 deletion(-) + +diff --git a/js/gdm/authServices.js b/js/gdm/authServices.js +index a62a3de901..0a94f493a2 100644 +--- a/js/gdm/authServices.js ++++ b/js/gdm/authServices.js +@@ -3,6 +3,7 @@ + import * as FingerprintManager from '../misc/fingerprintManager.js'; + import * as Params from '../misc/params.js'; + import {registerDestroyableType} from '../misc/signalTracker.js'; ++import * as PasskeyDeviceManager from '../misc/passkeyDeviceManager.js'; + import * as SmartcardManager from '../misc/smartcardManager.js'; + import {logErrorUnlessCancelled} from '../misc/errorUtils.js'; + import * as Util from './util.js'; +@@ -89,6 +90,7 @@ export class AuthServices extends GObject.Object { + this._cancellable = null; + + this._connectSmartcardManager(); ++ this._connectPasskeyDeviceManager(); + this._connectFingerprintManager(); + } + +@@ -230,6 +232,14 @@ export class AuthServices extends GObject.Object { + this); + } + ++ _connectPasskeyDeviceManager() { ++ this._passkeyDeviceManager = PasskeyDeviceManager.getPasskeyDeviceManager(); ++ this._passkeyDeviceManager.connectObject( ++ 'passkey-inserted', () => this._handlePasskeyChanged(), ++ 'passkey-removed', () => this._handlePasskeyChanged(), ++ this); ++ } ++ + _connectFingerprintManager() { + // Fingerprint can only work on lockscreen + if (!this._reauthOnly) +@@ -462,6 +472,8 @@ export class AuthServices extends GObject.Object { + + _handleSmartcardChanged() {} + ++ _handlePasskeyChanged() {} ++ + _handleFingerprintChanged() {} + + _handleOnInfo() {} +diff --git a/js/gdm/authServicesSSSDSwitchable.js b/js/gdm/authServicesSSSDSwitchable.js +index e04814caf3..de03dec1d7 100644 +--- a/js/gdm/authServicesSSSDSwitchable.js ++++ b/js/gdm/authServicesSSSDSwitchable.js +@@ -16,12 +16,14 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + static SupportedRoles = [ + Constants.PASSWORD_ROLE_NAME, + Constants.SMARTCARD_ROLE_NAME, ++ Constants.PASSKEY_ROLE_NAME, + Constants.WEB_LOGIN_ROLE_NAME, + ]; + + static RoleToService = { + [Constants.PASSWORD_ROLE_NAME]: Constants.SWITCHABLE_AUTH_SERVICE_NAME, + [Constants.SMARTCARD_ROLE_NAME]: Constants.SWITCHABLE_AUTH_SERVICE_NAME, ++ [Constants.PASSKEY_ROLE_NAME]: Constants.SWITCHABLE_AUTH_SERVICE_NAME, + [Constants.WEB_LOGIN_ROLE_NAME]: Constants.SWITCHABLE_AUTH_SERVICE_NAME, + }; + +@@ -66,6 +68,13 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + response = this._formatResponse(answer); + this._sendResponse(response); + break; ++ case Constants.PASSKEY_ROLE_NAME: ++ response = this._formatResponse(answer); ++ this._sendResponse(response); ++ ++ this.emit('show-choice-list', serviceName, ++ this._selectedMechanism.touchInstruction, {}); ++ break; + } + } + +@@ -77,6 +86,9 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + case Constants.SMARTCARD_ROLE_NAME: + this._startSmartcardLogin(); + break; ++ case Constants.PASSKEY_ROLE_NAME: ++ this._startPasskeyLogin(); ++ break; + case Constants.WEB_LOGIN_ROLE_NAME: + this._startWebLogin(); + break; +@@ -202,6 +214,14 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + this.emit('reset', {softReset: true, reuseEntryText: true}); + } + ++ _handlePasskeyChanged() { ++ if (!this._selectedMechanism || ++ !this._enabledMechanisms.some(({role}) => role === Constants.PASSKEY_ROLE_NAME)) ++ return; ++ ++ this.emit('reset', {softReset: true, reuseEntryText: true}); ++ } ++ + _handleOnInfo(serviceName, info) { + if (!this._eventExpected()) + return; +@@ -262,7 +282,7 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + } + + _formatResponse(answer) { +- const {role, id} = this._selectedMechanism; ++ const {role, id, kerberos, cryptoChallenge} = this._selectedMechanism; + + let response; + switch (role) { +@@ -275,6 +295,10 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + response = {pin: answer, tokenName, moduleName, keyId, label}; + break; + } ++ case Constants.PASSKEY_ROLE_NAME: { ++ response = {pin: answer, kerberos, cryptoChallenge}; ++ break; ++ } + case Constants.WEB_LOGIN_ROLE_NAME: { + response = {}; + break; +@@ -356,6 +380,26 @@ export class AuthServicesSSSDSwitchable extends AuthServices { + }; + } + ++ _startPasskeyLogin() { ++ const { ++ serviceName, ++ keyConnected, initInstruction, ++ pinPrompt, pinAttempts, ++ } = this._selectedMechanism; ++ ++ if (!keyConnected) { ++ this.emit('show-choice-list', serviceName, initInstruction, {}); ++ return; ++ } ++ ++ this.emit('ask-question', serviceName, pinPrompt, true); ++ ++ if (pinAttempts <= 3 && pinAttempts > 0) { ++ const message = _('You have %d attempts left. If the passkey gets locked, you may not able to access your account.').format(pinAttempts); ++ this.emit('queue-message', serviceName, message, Util.MessageType.INFO); ++ } ++ } ++ + _startWebLogin() { + const { + serviceName, +diff --git a/js/gdm/constants.js b/js/gdm/constants.js +index e8ca48625a..7c48599e64 100644 +--- a/js/gdm/constants.js ++++ b/js/gdm/constants.js +@@ -3,6 +3,7 @@ + export const PASSWORD_ROLE_NAME = 'password'; + export const SMARTCARD_ROLE_NAME = 'smartcard'; + export const FINGERPRINT_ROLE_NAME = 'fingerprint'; ++export const PASSKEY_ROLE_NAME = 'passkey'; + export const WEB_LOGIN_ROLE_NAME = 'eidp'; + + export const PASSWORD_SERVICE_NAME = 'gdm-password'; +diff --git a/js/gdm/util.js b/js/gdm/util.js +index 6c160d323b..743fce11b7 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -19,6 +19,7 @@ var LOGIN_SCREEN_SCHEMA = 'org.gnome.login-screen'; + var PASSWORD_AUTHENTICATION_KEY = 'enable-password-authentication'; + var FINGERPRINT_AUTHENTICATION_KEY = 'enable-fingerprint-authentication'; + var SMARTCARD_AUTHENTICATION_KEY = 'enable-smartcard-authentication'; ++var PASSKEY_AUTHENTICATION_KEY = 'enable-passkey-authentication'; + var SWITCHABLE_AUTHENTICATION_KEY = 'enable-switchable-authentication'; + var WEB_AUTHENTICATION_KEY = 'enable-web-authentication'; + var BANNER_MESSAGE_KEY = 'banner-message-enable'; +@@ -139,6 +140,7 @@ export function isSelectable(mechanism) { + switch (mechanism.role) { + case Constants.PASSWORD_ROLE_NAME: + case Constants.SMARTCARD_ROLE_NAME: ++ case Constants.PASSKEY_ROLE_NAME: + case Constants.WEB_LOGIN_ROLE_NAME: + return true; + case Constants.FINGERPRINT_ROLE_NAME: +@@ -449,6 +451,8 @@ var ShellUserVerifier = class { + enabledRoles.push(Constants.PASSWORD_ROLE_NAME); + if (this._settings.get_boolean(SMARTCARD_AUTHENTICATION_KEY)) + enabledRoles.push(Constants.SMARTCARD_ROLE_NAME); ++ if (this._settings.get_boolean(PASSKEY_AUTHENTICATION_KEY)) ++ enabledRoles.push(Constants.PASSKEY_ROLE_NAME); + if (this._settings.get_boolean(FINGERPRINT_AUTHENTICATION_KEY)) + enabledRoles.push(Constants.FINGERPRINT_ROLE_NAME); + if (this._settings.get_boolean(WEB_AUTHENTICATION_KEY)) +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index bebc7ad40a..654959190e 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -398,6 +398,8 @@ class UnlockDialogClock extends St.BoxLayout { + + if (authMechanism?.role === GdmConstants.SMARTCARD_ROLE_NAME) + text = _('Insert smartcard'); ++ else if (authMechanism?.role === GdmConstants.PASSKEY_ROLE_NAME) ++ text = _('Insert security key'); + else if (this._seat.touch_mode) + text = _('Swipe up'); + else +-- +2.51.0 + diff --git a/gnome-shell.spec b/gnome-shell.spec index 8920e46..7e3d29f 100644 --- a/gnome-shell.spec +++ b/gnome-shell.spec @@ -8,7 +8,7 @@ Name: gnome-shell Version: 40.10 -Release: 34%{?dist} +Release: 35%{?dist} Summary: Window management and application launching for GNOME License: GPLv2+ @@ -41,6 +41,7 @@ Patch22: 0001-gdm-util-Early-initialize-all-internal-properties.patch Patch23: 0001-main-Register-session-with-GDM-on-startup.patch # Passwordless GDM patch series Patch24: pre-changes-for-passwordless-gdm-backport.patch +Patch25: 0001-Support-for-web-login-and-unified-auth-mechanism.patch # Misc. Patch30: 0001-panel-add-an-icon-to-the-ActivitiesButton.patch @@ -310,6 +311,10 @@ desktop-file-validate %{buildroot}%{_datadir}/applications/evolution-calendar.de %endif %changelog +* Thu Apr 2 2026 Joan Torres Lopez - 40.10-35 +- Backport passwordless GDM feature to RHEL 9 + Resolves: RHEL-169914 + * Mon Mar 9 2026 Joan Torres Lopez - 40.10-34 - Fix to automatically start fingerprint when enabled Resolves: RHEL-4166