From 7374913894e8bdb39c1220996ee4601ebf7461e5 Mon Sep 17 00:00:00 2001 From: Joan Torres Lopez Date: Wed, 24 Dec 2025 13:44:20 +0100 Subject: [PATCH] Add passwordless gdm patch series Resolves: https://issues.redhat.com/browse/RHEL-141473 --- ...web-login-and-unified-auth-mechanism.patch | 8198 +++++++++++++++++ gnome-shell.spec | 3 + 2 files changed, 8201 insertions(+) create mode 100644 0001-Support-for-web-login-and-unified-auth-mechanism.patch 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..cf6af2e --- /dev/null +++ b/0001-Support-for-web-login-and-unified-auth-mechanism.patch @@ -0,0 +1,8198 @@ +From bd65f20755963dbf291ef94f585474604ab6c285 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 2 Oct 2025 10:59:57 +0200 +Subject: [PATCH 01/29] 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. +--- + data/theme/gnome-shell-sass/_common.scss | 11 +++++++++++ + data/theme/gnome-shell-sass/_drawing.scss | 1 + + .../gnome-shell-sass/widgets/_login-lock.scss | 16 ++-------------- + 3 files changed, 14 insertions(+), 14 deletions(-) + +diff --git a/data/theme/gnome-shell-sass/_common.scss b/data/theme/gnome-shell-sass/_common.scss +index 846427e8e0..1f14286f9f 100644 +--- a/data/theme/gnome-shell-sass/_common.scss ++++ b/data/theme/gnome-shell-sass/_common.scss +@@ -378,3 +378,14 @@ stage { + StLabel.hint-text { color: transparentize($system_fg_color, 0.3);} + } + ++/* Login Dialog Elements */ ++ ++@mixin login_dialog_item_button($style: normal) { ++ @extend %button_common; ++ @include button(normal, $tc:$system_fg_color, $c:$system_base_color, $style: $style, $always_dark: true); ++ &:selected, ++ &:focus { @include button(focus, $tc:$system_fg_color, $c:$system_base_color, $style: $style, $always_dark: true);} ++ &:hover { @include button(hover, $tc:$system_fg_color, $c:$system_base_color, $style: $style, $always_dark: true);} ++ &:active { @include button(active, $tc:$system_fg_color, $c:$system_base_color, $style: $style, $always_dark: true);} ++ &:insensitive { @include button(insensitive, $tc:$system_fg_color, $c:$system_base_color, $style: $style, $always_dark: true);} ++} +diff --git a/data/theme/gnome-shell-sass/_drawing.scss b/data/theme/gnome-shell-sass/_drawing.scss +index d98cb49397..f1e82176a4 100644 +--- a/data/theme/gnome-shell-sass/_drawing.scss ++++ b/data/theme/gnome-shell-sass/_drawing.scss +@@ -219,6 +219,7 @@ + $hover_button_bg_color: transparentize($tc, .87); + $active_button_bg_color: transparentize($tc, .84); + $active_hover_button_bg_color: transparentize($tc, .81); ++ $insensitive_button_bg_color: transparentize($tc, .9); + } + + // background color overrides for notification style +diff --git a/data/theme/gnome-shell-sass/widgets/_login-lock.scss b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +index b661e93c8d..6cca1e28e9 100644 +--- a/data/theme/gnome-shell-sass/widgets/_login-lock.scss ++++ b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +@@ -137,12 +137,7 @@ $_gdm_dialog_width: 25em; + + .login-dialog { + .login-dialog-auth-list-item { +- @extend %button_common; +- @include button(normal, $tc:$_gdm_fg, $c:$system_base_color, $always_dark: true); +- &:selected, +- &:focus { @include button(focus, $tc:$_gdm_fg, $c:$system_base_color, $always_dark: true);} +- &:hover { @include button(hover, $tc:$_gdm_fg, $c:$system_base_color, $always_dark: true);} +- &:active { @include button(active, $tc:$_gdm_fg, $c:$system_base_color, $always_dark: true);} ++ @include login_dialog_item_button(); + + border-radius: $modal_radius * 0.6; + padding: $base_padding * 1.2; +@@ -175,14 +170,7 @@ $_gdm_dialog_width: 25em; + spacing: $base_padding * 2; + + .login-dialog-user-list-item { +- // use button styling +- @extend %button_common; +- @include button(normal, $tc:$_gdm_fg, $c:$system_base_color, $always_dark: true); +- &:selected, +- &:focus { @include button(focus, $tc:$_gdm_fg, $c:$system_base_color, $always_dark: true);} +- &:hover { @include button(hover, $tc:$_gdm_fg, $c:$system_base_color, $always_dark: true);} +- &:active { @include button(active, $tc:$_gdm_fg, $c:$system_base_color, $always_dark: true);} +- ++ @include login_dialog_item_button(); + border-radius: $modal_radius; + padding: $base_padding * 1.5; + +-- +2.51.1 + + +From 92cfcabba70abdccf4cf5ce90e4c80d900994841 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Sun, 24 Nov 2024 12:53:50 -0500 +Subject: [PATCH 02/29] unlockDialog: Add some small fixes + +1. Vertically center dialog if clock is active, otherwise reduce vertical + margins with a fixed top. +2. The unlock dialog currently fails to reuse the username, when requested + to do so. This fixes that. +3. Wait until authPrompt is destroyed (clock transition animation is complete) + to switch VT. +--- + js/ui/unlockDialog.js | 24 ++++++++++++++++-------- + 1 file changed, 16 insertions(+), 8 deletions(-) + +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index 63ba591eec..03d91b6114 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -449,8 +449,9 @@ 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 quarterOfHeight = height / 4.0; ++ const centerY = height / 2.0; + + let [, , stackWidth, stackHeight] = + this._stack.get_preferred_size(); +@@ -476,9 +477,16 @@ class UnlockDialogLayout extends Clutter.LayoutManager { + this._notifications.allocate(actorBox); + + // Authentication Box +- let stackY = Math.min( +- thirdOfHeight, +- height - stackHeight - maxNotificationsHeight); ++ let stackY; ++ if (this._activePage === this._clock) { ++ stackY = Math.min( ++ Math.floor(centerY - stackHeight / 2.0), ++ height - stackHeight - maxNotificationsHeight); ++ } else { ++ stackY = Math.min( ++ quarterOfHeight, ++ height - stackHeight - maxNotificationsHeight); ++ } + + actorBox.x1 = columnX1; + actorBox.y1 = stackY; +@@ -838,7 +846,7 @@ export const 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 { +@@ -888,8 +896,8 @@ export const UnlockDialog = GObject.registerClass({ + } + + _otherUserClicked() { +- Gdm.goto_login_session_sync(null); +- ++ this._authPrompt.connectObject('destroy', () => ++ Gdm.goto_login_session_sync(null)); + this._authPrompt.cancel(); + } + +-- +2.51.1 + + +From ad29cbd94aab54a03268d1483c4227dfffbdab15 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Fri, 9 Feb 2024 09:02:25 -0500 +Subject: [PATCH 03/29] 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. + +Also, make _fadeInElement more abstract instead of specific for +authList. It'll be used in next commits. +--- + js/gdm/authPrompt.js | 25 +++++++++++++++++++------ + js/gdm/loginDialog.js | 11 +++++++++++ + 2 files changed, 30 insertions(+), 6 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 2bf722b140..cdacd32172 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -51,6 +51,7 @@ export const AuthPrompt = GObject.registerClass({ + 'next': {}, + 'prompted': {}, + 'reset': {param_types: [GObject.TYPE_UINT]}, ++ 'verification-complete': {}, + }, + }, class AuthPrompt extends St.BoxLayout { + _init(gdmClient, mode) { +@@ -432,8 +433,16 @@ export const 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() { +@@ -520,6 +529,10 @@ export const 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) { +@@ -535,13 +548,13 @@ export const AuthPrompt = GObject.registerClass({ + this._entry.grab_key_focus(); + } + +- _fadeInChoiceList() { +- this._authList.set({ ++ _fadeInElement(element) { ++ element.set({ + opacity: 0, + visible: true, + }); + this.updateSensitivity(false); +- this._authList.ease({ ++ element.ease({ + opacity: 255, + duration: MESSAGE_FADE_OUT_ANIMATION_TIME, + transition: Clutter.AnimationMode.EASE_OUT_QUAD, +@@ -560,7 +573,7 @@ export const AuthPrompt = GObject.registerClass({ + this._entry.hide(); + if (this._message.text === '') + this._message.hide(); +- this._fadeInChoiceList(); ++ this._fadeInElement(this._authList); + } + + getAnswer() { +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index 26966b633d..886e432063 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -587,6 +587,7 @@ export const 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); + +@@ -1081,6 +1082,16 @@ export const 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.1 + + +From 96f9ce3bd82b6cf32af399df5ecf6fa08c228f00 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Sun, 24 Nov 2024 16:29:43 -0500 +Subject: [PATCH 04/29] authPrompt: Add some changes to the elements in + _mainBox + + * Update the prompt entry style to be a bit bigger and more rounded. + + * Position cancelButton anchored to left side of password entry. + To do this, make _mainBox a St.Widget with a BinLayout. Then add + a constraint to cancelButton pivot_point. + + * Have a next button (cevron icon), embedded in the password entry. + Use a container (_entryArea) which contains _entry and + _defaultButtonWell (has _nextButton and _spinner), the latter is + overlaying _entry aligned on right. + + * _entryArea fades in when showing. + Use _fadeInElement (this sets sensitivity to true). + Now by default it's not visible. Don't clear it on verificationFailed to + keep it visible, it'll be cleared later on reset. + + * Fix animation when updating next button or spinning. + Now _deafaultButtonWell can have either _nextButton or _spinner, + so just use startSpinning and stopSpinning to set one or the oter. + Emit a 'loading' signal when starting/stopping spinning (used in + next commits). + Also, refactor setActorInDefaultButtonWell to make it simpler. + + * Increase left margin of hint-text + The cursor was overlapping the hint-text. + This allows proper readability of hint-text. +--- + .../gnome-shell-sass/widgets/_entries.scss | 2 +- + .../gnome-shell-sass/widgets/_login-lock.scss | 24 ++- + js/gdm/authPrompt.js | 174 ++++++++++-------- + 3 files changed, 118 insertions(+), 82 deletions(-) + +diff --git a/data/theme/gnome-shell-sass/widgets/_entries.scss b/data/theme/gnome-shell-sass/widgets/_entries.scss +index 41e10f7663..5a45e86d6d 100644 +--- a/data/theme/gnome-shell-sass/widgets/_entries.scss ++++ b/data/theme/gnome-shell-sass/widgets/_entries.scss +@@ -15,6 +15,6 @@ StEntry { + } + + StLabel.hint-text { +- margin-left: $base_margin * 0.5; ++ margin-left: $base_margin * 2; + } + } +diff --git a/data/theme/gnome-shell-sass/widgets/_login-lock.scss b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +index 6cca1e28e9..93dbe617b7 100644 +--- a/data/theme/gnome-shell-sass/widgets/_login-lock.scss ++++ b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +@@ -16,6 +16,19 @@ $_gdm_dialog_width: 25em; + width: $_gdm_dialog_width; + spacing: $base_padding * 1.5; + } ++ ++ .login-dialog-prompt-entry-area { ++ margin: 0.5em $base_margin * 5; ++ } ++ ++ .login-dialog-prompt-entry { ++ border-radius: $base_border_radius * 1.5; ++ padding-right: 3em; // Make room for button-well inside entry ++ } ++ ++ .login-dialog-default-button-well { ++ margin-right: 1em; ++ } + } + + // GDM Login Dialog +@@ -33,7 +46,7 @@ $_gdm_dialog_width: 25em; + + // buttons on login screen + .login-dialog-button { +- ++ &.next-button, + &.a11y-button, + &.cancel-button, + &.switch-user-button, +@@ -44,13 +57,18 @@ $_gdm_dialog_width: 25em; + padding: to_em(16px); + } + ++ &.next-button { ++ background-color: transparent !important; ++ padding: 0; ++ } ++ + &.cancel-button { +- padding: $base_padding * 1.5; ++ padding: $base_padding * 2; + } + } + + .login-dialog-button-box { +- spacing: $base_padding * 2; ++ height: 4em; + } + + .conflicting-session-dialog-content { +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index cdacd32172..afb9e6b1c9 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -2,6 +2,7 @@ import Clutter from 'gi://Clutter'; + import GLib from 'gi://GLib'; + import Atk from 'gi://Atk'; + import GObject from 'gi://GObject'; ++import Graphene from 'gi://Graphene'; + import Pango from 'gi://Pango'; + import Shell from 'gi://Shell'; + import St from 'gi://St'; +@@ -16,7 +17,6 @@ import * as UserWidget from '../ui/userWidget.js'; + import {wiggle} from '../misc/animationUtils.js'; + + const DEFAULT_BUTTON_WELL_ICON_SIZE = 16; +-const DEFAULT_BUTTON_WELL_ANIMATION_DELAY = 1000; + const DEFAULT_BUTTON_WELL_ANIMATION_TIME = 300; + + const MESSAGE_FADE_OUT_ANIMATION_TIME = 500; +@@ -52,6 +52,7 @@ export const 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) { +@@ -155,9 +156,11 @@ export const 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', +- orientation: Clutter.Orientation.HORIZONTAL, ++ x_expand: true, ++ y_expand: false, + }); + this.add_child(this._mainBox); + +@@ -167,10 +170,17 @@ export const 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, + 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 +@@ -198,10 +208,20 @@ export const 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; +@@ -213,8 +233,7 @@ export const 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._inactiveEntry = this._textEntry; + + this._timedLoginIndicator = new St.Bin({ +@@ -230,26 +249,33 @@ export const 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({ + 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); + } + + showTimedLoginIndicator(time) { +@@ -286,12 +312,15 @@ export const AuthPrompt = GObject.registerClass({ + this._timedLoginIndicator.scale_x = 0.; + } + +- _activateNext(shouldSpin) { ++ _activateNext() { ++ if (!this._entry.reactive) ++ return; ++ + this.verificationStatus = AuthPromptStatus.VERIFICATION_IN_PROGRESS; + this.updateSensitivity(false); + + if (this._queryingService) { +- if (shouldSpin) ++ if (this._entry === this._passwordEntry) + this.startSpinning(); + + this._userVerifier.answerQuery(this._queryingService, this._entry.text); +@@ -319,7 +348,7 @@ export const 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; + +@@ -351,7 +380,6 @@ export const AuthPrompt = GObject.registerClass({ + else + this.setQuestion(question.replace(/[::] *$/, '').trim()); + +- this.updateSensitivity(true); + this.emit('prompted'); + } + +@@ -415,23 +443,21 @@ export const AuthPrompt = GObject.registerClass({ + _onVerificationFailed(userVerifier, serviceName, canRetry) { + const wasQueryingService = this._queryingService === serviceName; + +- if (wasQueryingService) { ++ if (wasQueryingService) + this._queryingService = null; +- this.clear(); +- } + + this.updateSensitivity(canRetry); +- this.setActorInDefaultButtonWell(null); ++ this.stopSpinning(); + + if (!canRetry) + this.verificationStatus = AuthPromptStatus.VERIFICATION_FAILED; + + if (wasQueryingService) +- wiggle(this._entry); ++ wiggle(this._entryArea); + } + + _onVerificationComplete() { +- this.setActorInDefaultButtonWell(null); ++ this.stopSpinning(true); + this.verificationStatus = AuthPromptStatus.VERIFICATION_SUCCEEDED; + + this._mainBox.reactive = false; +@@ -451,79 +477,68 @@ export const 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; +- +- let isSpinner; + if (actor === this._spinner) +- isSpinner = true; +- else +- isSpinner = false; ++ this._spinner.play(); + +- 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.emit('loading', true); + this.setActorInDefaultButtonWell(this._spinner, true); + } + +- stopSpinning() { +- this.setActorInDefaultButtonWell(null, false); ++ stopSpinning(animate) { ++ this.emit('loading', false); ++ this.setActorInDefaultButtonWell(this._nextButton, animate); + } + + clear() { ++ this._entryArea.hide(); + this._entry.text = ''; + this._inactiveEntry.text = ''; + this.stopSpinning(); +@@ -544,8 +559,8 @@ export const AuthPrompt = GObject.registerClass({ + this._entry.hint_text = question; + + this._authList.hide(); +- this._entry.show(); +- this._entry.grab_key_focus(); ++ ++ this._fadeInElement(this._entryArea); + } + + _fadeInElement(element) { +@@ -570,7 +585,7 @@ export const 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); +@@ -635,6 +650,9 @@ export const AuthPrompt = GObject.registerClass({ + if (authWidget.reactive === sensitive) + return; + ++ if (authWidget === this._entry) ++ this._nextButton.reactive = sensitive; ++ + authWidget.reactive = sensitive; + + if (sensitive) { +@@ -648,7 +666,7 @@ export const AuthPrompt = GObject.registerClass({ + } + + vfunc_hide() { +- this.setActorInDefaultButtonWell(null, true); ++ this.stopSpinning(); + super.vfunc_hide(); + this._message.opacity = 0; + +-- +2.51.1 + + +From 757543ed694b88d37fe64b57298d70da48e6d6a2 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 13:06:32 -0500 +Subject: [PATCH 05/29] 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 | 63 ++++++++++++++++++++++++++++---------------- + 1 file changed, 40 insertions(+), 23 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index afb9e6b1c9..39392465ff 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -471,9 +471,11 @@ export const AuthPrompt = GObject.registerClass({ + this.emit('verification-complete'); + } + +- _onReset() { +- this.verificationStatus = AuthPromptStatus.NOT_VERIFYING; +- this.reset(); ++ _onReset(_, resetParams) { ++ if (this.verificationStatus === AuthPromptStatus.VERIFICATION_SUCCEEDED) ++ return; ++ ++ this.reset(resetParams); + } + + setActorInDefaultButtonWell(actor, animate) { +@@ -701,13 +703,22 @@ export const AuthPrompt = GObject.registerClass({ + this.updateSensitivity(false); + } + +- reset() { +- let oldStatus = this.verificationStatus; ++ reset(params) { ++ let {beginRequestType, reuseEntryText, softReset} = Params.parse(params, { ++ beginRequestType: null, ++ reuseEntryText: false, ++ softReset: false, ++ }); ++ ++ const oldStatus = this.verificationStatus; + this.verificationStatus = AuthPromptStatus.NOT_VERIFYING; + this.cancelButton.reactive = this._hasCancelButton; + this.cancelButton.can_focus = this._hasCancelButton; + this._preemptiveAnswer = null; + ++ const oldEntryText = this._textEntry.text; ++ const oldPasswordText = this._passwordEntry.text; ++ + if (this._preemptiveAnswerWatchId) + this._idleMonitor.remove_watch(this._preemptiveAnswerWatchId); + this._preemptiveAnswerWatchId = this._idleMonitor.add_idle_watch(500, +@@ -723,29 +734,35 @@ export const AuthPrompt = GObject.registerClass({ + this._updateEntry(true); + this.stopSpinning(); + ++ if (reuseEntryText) { ++ this._textEntry.text = oldEntryText; ++ this._passwordEntry.text = oldPasswordText; ++ } ++ + if (oldStatus === AuthPromptStatus.VERIFICATION_FAILED) + this.emit('failed'); + else if (oldStatus === AuthPromptStatus.VERIFICATION_CANCELLED) + this.emit('cancelled'); + +- let beginRequestType; +- +- 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; +- } else if (this._userVerifier.foregroundServiceDeterminesUsername()) { +- // 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) { +- // We're going back to retry with current user +- beginRequestType = BeginRequestType.REUSE_USERNAME; +- } else { +- // In all other cases, we should get the username up front. +- beginRequestType = BeginRequestType.PROVIDE_USERNAME; ++ if (beginRequestType === null) { ++ 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; ++ } 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 ++ beginRequestType = BeginRequestType.DONT_PROVIDE_USERNAME; ++ } else if (oldStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS || ++ softReset) { ++ // We're going back to retry with current user ++ beginRequestType = BeginRequestType.REUSE_USERNAME; ++ } else { ++ // In all other cases, we should get the username up front. ++ beginRequestType = BeginRequestType.PROVIDE_USERNAME; ++ } + } + + this.emit('reset', beginRequestType); +-- +2.51.1 + + +From 089304978ce5375dcbc4b1c57970f8b9eec49d4d Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 11 Sep 2025 19:12:05 +0200 +Subject: [PATCH 06/29] 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 39392465ff..561b8d1fa6 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -446,7 +446,7 @@ export const AuthPrompt = GObject.registerClass({ + if (wasQueryingService) + this._queryingService = null; + +- this.updateSensitivity(canRetry); ++ this.updateSensitivity(false); + this.stopSpinning(); + + if (!canRetry) +-- +2.51.1 + + +From 7680a243f0ed2695970a61ed32c23ce98ac448b9 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 30 Sep 2025 17:43:22 +0200 +Subject: [PATCH 07/29] authPrompt: Update authList style + +Make the buttons a bit bigger and more rounded. + +Buttons can have two labels and one icon. +The icon is used to inform about Organization field of the certificate when +hovering over it. This will mainly be used by the next commit, for the +moment keep relying only in one single label (update choiceList list +format). + +Now the title is an insensitive button that is in _mainBox to ensure +back button is visible and properly aligned. + +Add accessible_name. +--- + .../gnome-shell-sass/widgets/_login-lock.scss | 91 ++++++++++-- + js/gdm/authList.js | 134 ++++++++++++++++-- + js/gdm/authPrompt.js | 28 +++- + js/gdm/util.js | 6 +- + po/POTFILES.in | 1 + + 5 files changed, 227 insertions(+), 33 deletions(-) + +diff --git a/data/theme/gnome-shell-sass/widgets/_login-lock.scss b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +index 93dbe617b7..5dbc00a718 100644 +--- a/data/theme/gnome-shell-sass/widgets/_login-lock.scss ++++ b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +@@ -141,42 +141,103 @@ $_gdm_dialog_width: 25em; + // Authentication methods list + .login-dialog-auth-list-view { + -st-vfade-offset: 3em; ++ max-height: 13em; + } + + .login-dialog-auth-list { +- spacing: $base_padding; +- margin-left: 2em; +-} +- +-.login-dialog-auth-list-title { +- margin-left: 2em; +- padding-bottom: $base_padding; ++ spacing: $base_padding * 1.5; + } + + .login-dialog { ++ .login-dialog-auth-list-title, + .login-dialog-auth-list-item { +- @include login_dialog_item_button(); ++ @include login_dialog_item_button($style:card); ++ border-radius: $base_border_radius * 1.5; ++ } + +- border-radius: $modal_radius * 0.6; +- padding: $base_padding * 1.2; ++ .login-dialog-auth-list-title { ++ background-color: transparentize($_gdm_fg, .98) !important; ++ color: $_gdm_fg !important; ++ margin: 0.5em $base_margin * 5; ++ } ++ ++ .login-dialog-auth-list-item { ++ min-height: 3em; ++ padding: $base_padding * 1.5; ++ margin: 0 $base_margin * 5; ++ margin-bottom: $base_margin; + } + } + + .unlock-dialog { + .login-dialog-auth-list-item { + @extend %lockscreen_button; ++ border-radius: $base_border_radius * 1.5; ++ min-height: 3em; ++ padding: $base_padding * 1.5; ++ margin: 0 $base_margin * 5; ++ margin-bottom: $base_margin; ++ } + +- border-radius: $modal_radius * 0.6; +- padding: $base_padding * 1.2; ++ .login-dialog-auth-list-title { ++ @extend %lockscreen_button; ++ background-color: transparent !important; ++ color: $system_fg_color !important; ++ padding: 0; ++ margin: 0; + } + } + +-.login-dialog-auth-list-label { ++.login-dialog-auth-list-title-label { + @extend %title_4; +- &:ltr { padding-left: $base_padding * 2.5; text-align: left; } +- &:rtl { padding-right: $base_padding * 2.5; text-align: right; } ++ padding: $base_padding; ++ text-align: center; + } + ++.login-dialog-auth-list-item-first-line, ++.login-dialog-auth-list-item-second-line { ++ @extend %heading; ++ text-align: center; ++ padding: $base_padding * 0.3 0; ++} ++ ++.login-dialog-auth-list-item-first-line { ++ color: $_gdm_fg; ++} ++ ++.login-dialog-auth-list-item-second-line { ++ color: darken($_gdm_fg, 20%); ++ font-weight: 500; ++} ++ ++.login-dialog-auth-list-item-icon { ++ width: 1.3em; ++ height: 1.3em; ++ color: $_gdm_fg; ++ padding: $base_padding * 0.7 $base_padding; ++ border-radius: $base_border_radius; ++ ++ &:hover { ++ background-color: $system_bg_color; ++ } ++} ++ ++.login-dialog-auth-list-item-popup-box { ++ .login-dialog-auth-list-item-popup-labels { ++ spacing: $base_padding * 0.5; ++ @extend %heading; ++ text-align: center; ++ ++ & > :first-child { ++ color: darken($fg_color, 30%); ++ font-weight: 500; ++ } ++ ++ & > :last-child { ++ color: $fg_color; ++ } ++ } ++} + + // User list + .login-dialog-user-list-view { +diff --git a/js/gdm/authList.js b/js/gdm/authList.js +index 6575ca4c01..1bb635cb87 100644 +--- a/js/gdm/authList.js ++++ b/js/gdm/authList.js +@@ -17,29 +17,65 @@ + + import Clutter from 'gi://Clutter'; + import GObject from 'gi://GObject'; ++import Graphene from 'gi://Graphene'; + import Meta from 'gi://Meta'; ++import Shell from 'gi://Shell'; + import St from 'gi://St'; + ++import * as Main from '../ui/main.js'; ++import * as PopupMenu from '../ui/popupMenu.js'; ++ + const SCROLL_ANIMATION_TIME = 500; + ++const PopupLabel = class extends PopupMenu.PopupMenu { ++ constructor(sourceActor, label) { ++ super(sourceActor, 0.5, St.Side.TOP); ++ ++ const menuItem = new PopupMenu.PopupBaseMenuItem({ ++ reactive: false, ++ can_focus: false, ++ }); ++ menuItem.add_child(label); ++ this.addMenuItem(menuItem); ++ ++ Main.uiGroup.add_child(this.actor); ++ ++ // Overwrite min-width to allow smaller width than the default ++ this.actor.set_style('min-width: 0;'); ++ } ++}; ++ + const AuthListItem = GObject.registerClass({ + Signals: {'activate': {}}, + }, class AuthListItem extends St.Button { +- _init(key, text) { ++ _init(key, content) { + this.key = key; +- const label = new St.Label({ +- text, +- style_class: 'login-dialog-auth-list-label', ++ ++ 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); ++ ++ const {commonName, description, organization} = content; ++ this._appendLine(commonName); ++ this._appendLine(description); ++ this._appendIcon(organization); + + super._init({ + style_class: 'login-dialog-auth-list-item', + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + can_focus: true, +- child: label, ++ child: this._container, + reactive: true, ++ accessible_name: [commonName, organization, description] ++ .filter(p => p) ++ .join(' '), + }); + + this.connect('key-focus-in', +@@ -52,6 +88,83 @@ const AuthListItem = GObject.registerClass({ + this.connect('clicked', this._onClicked.bind(this)); + } + ++ _appendLine(text) { ++ if (!text) ++ return; ++ ++ if (!this._firstLine) { ++ const label = new St.Label({ ++ text, ++ style_class: 'login-dialog-auth-list-item-first-line', ++ y_align: Clutter.ActorAlign.CENTER, ++ x_expand: true, ++ }); ++ this._labelBox.add_child(label); ++ this._firstLine = label; ++ } else if (!this._secondLine) { ++ const label = new St.Label({ ++ text, ++ style_class: 'login-dialog-auth-list-item-second-line', ++ y_align: Clutter.ActorAlign.CENTER, ++ x_expand: true, ++ }); ++ this._labelBox.add_child(label); ++ this._secondLine = label; ++ } ++ } ++ ++ _appendIcon(text) { ++ if (!text || this._icon) ++ return; ++ ++ const icon = new St.Button({ ++ style_class: 'login-dialog-auth-list-item-icon', ++ child: new St.Icon({icon_name: 'vcard-symbolic'}), ++ }); ++ 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}), ++ })); ++ ++ const textLines = [_('Organization'), text]; ++ this.popupLabel = this._createPopupLabel(icon, textLines); ++ ++ this._container.add_child(icon); ++ this._icon = icon; ++ } ++ ++ _createPopupLabel(sourceActor, textLines) { ++ const labelsContainer = new St.BoxLayout({ ++ orientation: Clutter.Orientation.VERTICAL, ++ style_class: 'login-dialog-auth-list-item-popup-labels', ++ }); ++ textLines.forEach(text => { ++ labelsContainer.add_child(new St.Label({text})); ++ }); ++ ++ const popup = new PopupLabel(sourceActor, labelsContainer); ++ popup.box.add_style_class_name('login-dialog-auth-list-item-popup-box'); ++ popup.actor.hide(); ++ ++ if (!this._menuManager) { ++ this._menuManager = new PopupMenu.PopupMenuManager(sourceActor, { ++ actionMode: Shell.ActionMode.NONE, ++ }); ++ } ++ this._menuManager.addMenu(popup); ++ ++ sourceActor.connect('clicked', () => popup.toggle()); ++ ++ return popup; ++ } ++ + _onClicked() { + this.emit('activate'); + } +@@ -76,13 +189,11 @@ export const AuthList = GObject.registerClass({ + super._init({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'login-dialog-auth-list-layout', +- x_align: Clutter.ActorAlign.START, ++ x_align: Clutter.ActorAlign.FILL, + 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._box = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'login-dialog-auth-list', +@@ -135,10 +246,10 @@ export const AuthList = GObject.registerClass({ + }); + } + +- addItem(key, text) { ++ addItem(key, content) { + this.removeItem(key); + +- let item = new AuthListItem(key, text); ++ const item = new AuthListItem(key, content); + this._box.add_child(item); + + this._items.set(key, item); +@@ -169,7 +280,6 @@ export const 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 561b8d1fa6..f344b9f7e8 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -200,13 +200,30 @@ export const 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.add_child(this._authList); ++ ++ 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', +@@ -544,6 +561,7 @@ export const AuthPrompt = GObject.registerClass({ + this._entry.text = ''; + this._inactiveEntry.text = ''; + this.stopSpinning(); ++ this._authListTitle.child.text = ''; + this._authList.clear(); + this._authList.hide(); + +@@ -581,10 +599,10 @@ export const 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 c87690f859..af512685a5 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -724,7 +724,11 @@ export class ShellUserVerifier extends Signals.EventEmitter { + if (!this.serviceIsForeground(serviceName)) + return; + +- this.emit('show-choice-list', serviceName, promptMessage, list.deepUnpack()); ++ const choiceList = Object.fromEntries( ++ Object.entries(list.deepUnpack()) ++ .map(([key, value]) => [key, {description: value}])); ++ ++ this.emit('show-choice-list', serviceName, promptMessage, choiceList); + } + + _onInfo(client, serviceName, info) { +diff --git a/po/POTFILES.in b/po/POTFILES.in +index ee0829c96e..8be425615d 100644 +--- a/po/POTFILES.in ++++ b/po/POTFILES.in +@@ -10,6 +10,7 @@ data/X-GNOME-Shell-System.directory.desktop.in + data/X-GNOME-Shell-Utilities.directory.desktop.in + js/dbusServices/extensions/extensionPrefsDialog.js + js/dbusServices/extensions/ui/extension-error-page.ui ++js/gdm/authList.js + js/gdm/authPrompt.js + js/gdm/loginDialog.js + js/gdm/util.js +-- +2.51.1 + + +From ab9b9cf90f5025a5c0976e4735942f2d3cc2a5e0 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Wed, 8 Oct 2025 18:48:07 +0200 +Subject: [PATCH 08/29] 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 f344b9f7e8..462a1d19e7 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -70,6 +70,7 @@ export const AuthPrompt = GObject.registerClass({ + this._mode = mode; + this._defaultButtonWellActor = null; + this._cancelledRetries = 0; ++ this._promptStep = 0; + + this._idleMonitor = global.backend.get_core_idle_monitor(); + +@@ -99,8 +100,6 @@ export const AuthPrompt = GObject.registerClass({ + }); + this.add_child(this._userWell); + +- this._hasCancelButton = this._mode === AuthPromptMode.UNLOCK_OR_LOG_IN; +- + this._initInputRow(); + + let capsLockPlaceholder = new St.Label(); +@@ -168,8 +167,8 @@ export const AuthPrompt = GObject.registerClass({ + style_class: 'login-dialog-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, +@@ -181,10 +180,8 @@ export const 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(); +@@ -295,6 +292,16 @@ export const AuthPrompt = GObject.registerClass({ + this.setActorInDefaultButtonWell(this._nextButton); + } + ++ _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(); + +@@ -381,6 +388,9 @@ export const 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; +@@ -405,6 +415,8 @@ export const AuthPrompt = GObject.registerClass({ + this.clear(); + + this._queryingService = serviceName; ++ this._promptStep++; ++ this._updateCancelButton(); + + if (this._preemptiveAnswer) + this._preemptiveAnswer = null; +@@ -730,9 +742,9 @@ export const AuthPrompt = GObject.registerClass({ + + const oldStatus = this.verificationStatus; + this.verificationStatus = AuthPromptStatus.NOT_VERIFYING; +- this.cancelButton.reactive = this._hasCancelButton; +- this.cancelButton.can_focus = this._hasCancelButton; + this._preemptiveAnswer = null; ++ this._promptStep = 0; ++ this._updateCancelButton(); + + const oldEntryText = this._textEntry.text; + const oldPasswordText = this._passwordEntry.text; +@@ -828,6 +840,12 @@ export const 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.1 + + +From 69eecd2b4ecb30170eb4a93ca59a16a0c0c3582b Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 20 Oct 2025 17:24:40 +0200 +Subject: [PATCH 09/29] loginDialog: Vertically align promptAuth with fixed top + +--- + js/gdm/loginDialog.js | 21 ++++++++++++++++++++- + 1 file changed, 20 insertions(+), 1 deletion(-) + +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index 886e432063..103bdfd83b 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -729,6 +729,25 @@ export const LoginDialog = GObject.registerClass({ + return actorBox; + } + ++ _getFixedTopActorAllocation(dialogBox, actor) { ++ const actorBox = new Clutter.ActorBox(); ++ ++ let [, , natWidth, natHeight] = actor.get_preferred_size(); ++ const centerX = dialogBox.x1 + (dialogBox.x2 - dialogBox.x1) / 2; ++ const marginTop = 0.22; ++ const top = dialogBox.y1 + (dialogBox.y2 - dialogBox.y1) * marginTop; ++ ++ natWidth = Math.min(natWidth, dialogBox.x2 - dialogBox.x1); ++ natHeight = Math.min(natHeight, dialogBox.y2 - dialogBox.y1); ++ ++ 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(); + +@@ -767,7 +786,7 @@ export const 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.1 + + +From ec0cfa9647795677d1ee2b594190e09465d3cd8d Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 14 Jan 2025 08:17:48 -0500 +Subject: [PATCH 10/29] loginDialog: When updating disable user list ask for + username + +Right now if the disable-user-list key is changed we do +a reset that asks for a username via PAM instead of via +gnome-shell. This is different than Not Listed? behavior +and can get in the way off smartcard handling, so change +it to explicitly ask for the username up front. +--- + js/gdm/loginDialog.js | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index 103bdfd83b..bb65f047a5 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -937,7 +937,7 @@ export const LoginDialog = GObject.registerClass({ + this._disableUserList = disableUserList; + + if (this._authPrompt.verificationStatus === AuthPrompt.AuthPromptStatus.NOT_VERIFYING) +- this._authPrompt.reset(); ++ this._authPrompt.reset({beginRequestType: AuthPrompt.BeginRequestType.PROVIDE_USERNAME}); + + if (this._disableUserList && this._timedLoginUserListHold) + this._timedLoginUserListHold.release(); +-- +2.51.1 + + +From fc795152f20f28aafde110761234167385ecd20c Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 12 Nov 2024 14:26:30 -0500 +Subject: [PATCH 11/29] 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. +--- + data/gnome-shell-icons.gresource.xml | 2 ++ + .../status/fingerprint-auth-symbolic.svg | 28 +++++++++++++++++++ + data/icons/scalable/status/vcard-symbolic.svg | 9 ++++++ + 3 files changed, 39 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/gnome-shell-icons.gresource.xml b/data/gnome-shell-icons.gresource.xml +index 4c9a3446b5..11a5afa3fd 100644 +--- a/data/gnome-shell-icons.gresource.xml ++++ b/data/gnome-shell-icons.gresource.xml +@@ -51,6 +51,7 @@ + scalable/actions/shell-focus-windows-symbolic.svg + scalable/status/background-app-ghost-symbolic.svg + scalable/status/check-symbolic.svg ++ scalable/status/fingerprint-auth-symbolic.svg + scalable/status/keyboard-brightness-high-symbolic.svg + scalable/status/keyboard-brightness-medium-symbolic.svg + scalable/status/keyboard-brightness-off-symbolic.svg +@@ -68,6 +69,7 @@ + scalable/status/screen-privacy-symbolic.svg + scalable/status/switch-off-symbolic.svg + scalable/status/switch-on-symbolic.svg ++ scalable/status/vcard-symbolic.svg + scalable/status/wellbeing-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.1 + + +From 05bae54ec750e5f460ee5f0fb7cf95e6bc4037d0 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 18 Aug 2025 12:30:50 +0200 +Subject: [PATCH 12/29] gdm: Extract authentication service and role constants + to const.js + +Create a dedicated const.js module to centralize GDM authentication-related +constants that are shared across multiple files. + +Also, in utils, add isSelectable() and getIconName() to get mechanisms metadata. + +This prepares the codebase for upcoming commits that will use these +constants. +--- + js/gdm/authPrompt.js | 3 +- + js/gdm/const.js | 10 ++++++ + js/gdm/util.js | 64 +++++++++++++++++++++++++---------- + js/js-resources.gresource.xml | 1 + + 4 files changed, 59 insertions(+), 19 deletions(-) + create mode 100644 js/gdm/const.js + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 462a1d19e7..2a66947493 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -10,6 +10,7 @@ import St from 'gi://St'; + import * as Animation from '../ui/animation.js'; + import * as AuthList from './authList.js'; + import * as Batch from './batch.js'; ++import * as Const from './const.js'; + import * as GdmUtil from './util.js'; + import * as Params from '../misc/params.js'; + import * as ShellEntry from '../ui/shellEntry.js'; +@@ -441,7 +442,7 @@ export const 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(Const.SMARTCARD_SERVICE_NAME) && + (this.verificationStatus === AuthPromptStatus.VERIFYING || + this.verificationStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS) && + this.smartcardDetected) +diff --git a/js/gdm/const.js b/js/gdm/const.js +new file mode 100644 +index 0000000000..2f37446c8a +--- /dev/null ++++ b/js/gdm/const.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 af512685a5..3ad0116686 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -23,9 +23,6 @@ Gio._promisify(Gdm.UserVerifierProxy.prototype, + 'call_begin_verification_for_user'); + Gio._promisify(Gdm.UserVerifierProxy.prototype, 'call_begin_verification'); + +-export const PASSWORD_SERVICE_NAME = 'gdm-password'; +-export const FINGERPRINT_SERVICE_NAME = 'gdm-fingerprint'; +-export const SMARTCARD_SERVICE_NAME = 'gdm-smartcard'; + const CLONE_FADE_ANIMATION_TIME = 250; + + export const LOGIN_SCREEN_SCHEMA = 'org.gnome.login-screen'; +@@ -97,6 +94,37 @@ export function cloneAndFadeOutActor(actor) { + return hold; + } + ++/** ++ * @param {object} mechanism ++ * @returns {boolean} ++ */ ++export function isSelectable(mechanism) { ++ switch (mechanism.role) { ++ case Const.PASSWORD_ROLE_NAME: ++ case Const.SMARTCARD_ROLE_NAME: ++ return true; ++ case Const.FINGERPRINT_ROLE_NAME: ++ return false; ++ default: ++ throw new Error(`Failed checking mechanism is selectable: ${mechanism.role}`); ++ } ++} ++ ++/** ++ * @param {object} mechanism ++ * @returns {string} ++ */ ++export function getIconName(mechanism) { ++ // This is only used for non selectable mechanisms. ++ // Currently only fingerprint is non selectable ++ switch (mechanism.role) { ++ case Const.FINGERPRINT_ROLE_NAME: ++ return 'fingerprint-auth-symbolic'; ++ default: ++ throw new Error(`Failed getting mechanism icon: ${mechanism.role}`); ++ } ++} ++ + export class ShellUserVerifier extends Signals.EventEmitter { + constructor(client, params) { + super(); +@@ -432,7 +460,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + this._updateDefaultService(); + + if (this._userVerifier && +- !this._activeServices.has(FINGERPRINT_SERVICE_NAME)) { ++ !this._activeServices.has(Const.FINGERPRINT_SERVICE_NAME)) { + if (!this._hold?.isAcquired()) + this._hold = new Batch.Hold(); + await this._maybeStartFingerprintVerification(); +@@ -486,8 +514,8 @@ export class ShellUserVerifier extends Signals.EventEmitter { + this.smartcardDetected = smartcardDetected; + + if (this.smartcardDetected) +- this._preemptingService = SMARTCARD_SERVICE_NAME; +- else if (this._preemptingService === SMARTCARD_SERVICE_NAME) ++ this._preemptingService = Const.SMARTCARD_SERVICE_NAME; ++ else if (this._preemptingService === Const.SMARTCARD_SERVICE_NAME) + this._preemptingService = null; + + this._updateDefaultService(); +@@ -602,7 +630,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + return true; + } + +- return this.serviceIsForeground(SMARTCARD_SERVICE_NAME); ++ return this.serviceIsForeground(Const.SMARTCARD_SERVICE_NAME); + } + + serviceIsDefault(serviceName) { +@@ -611,7 +639,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + + serviceIsFingerprint(serviceName) { + return this._fingerprintReaderType !== FingerprintReaderType.NONE && +- serviceName === FINGERPRINT_SERVICE_NAME; ++ serviceName === Const.FINGERPRINT_SERVICE_NAME; + } + + _onSettingsChanged() { +@@ -628,7 +656,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + this._fingerprintManager = null; + this._fingerprintReaderType = FingerprintReaderType.NONE; + +- if (this._activeServices.has(FINGERPRINT_SERVICE_NAME)) ++ if (this._activeServices.has(Const.FINGERPRINT_SERVICE_NAME)) + needsReset = true; + } + +@@ -638,7 +666,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + this._smartcardManager.disconnectObject(this); + this._smartcardManager = null; + +- if (this._activeServices.has(SMARTCARD_SERVICE_NAME)) ++ if (this._activeServices.has(Const.SMARTCARD_SERVICE_NAME)) + needsReset = true; + } + +@@ -648,13 +676,13 @@ export class ShellUserVerifier extends Signals.EventEmitter { + + _getDetectedDefaultService() { + if (this._smartcardManager?.loggedInWithToken()) +- return SMARTCARD_SERVICE_NAME; ++ return Const.SMARTCARD_SERVICE_NAME; + else if (this._settings.get_boolean(PASSWORD_AUTHENTICATION_KEY)) +- return PASSWORD_SERVICE_NAME; ++ return Const.PASSWORD_SERVICE_NAME; + else if (this._smartcardManager) +- return SMARTCARD_SERVICE_NAME; ++ return Const.SMARTCARD_SERVICE_NAME; + else if (this._fingerprintReaderType !== FingerprintReaderType.NONE) +- return FINGERPRINT_SERVICE_NAME; ++ return Const.FINGERPRINT_SERVICE_NAME; + return null; + } + +@@ -664,7 +692,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + + if (!this._defaultService) { + log('no authentication service is enabled, using password authentication'); +- this._defaultService = PASSWORD_SERVICE_NAME; ++ this._defaultService = Const.PASSWORD_SERVICE_NAME; + } + + if (oldDefaultService && +@@ -716,8 +744,8 @@ export class ShellUserVerifier extends Signals.EventEmitter { + async _maybeStartFingerprintVerification() { + if (this._userName && + this._fingerprintReaderType !== FingerprintReaderType.NONE && +- !this.serviceIsForeground(FINGERPRINT_SERVICE_NAME)) +- await this._startService(FINGERPRINT_SERVICE_NAME); ++ !this.serviceIsForeground(Const.FINGERPRINT_SERVICE_NAME)) ++ await this._startService(Const.FINGERPRINT_SERVICE_NAME); + } + + _onChoiceListQuery(client, serviceName, promptMessage, list) { +@@ -850,7 +878,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + async _verificationFailed(serviceName, shouldRetry) { +- if (serviceName === FINGERPRINT_SERVICE_NAME) { ++ if (serviceName === Const.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 e5e6167f15..ee6b77d322 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/const.js + gdm/credentialManager.js + gdm/loginDialog.js + gdm/oVirt.js +-- +2.51.1 + + +From f23fd9d9cea2972eac107263cf3b9fdc9c5f52fd Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 11:04:20 -0500 +Subject: [PATCH 13/29] 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. +--- + .../gnome-shell-sass/widgets/_login-lock.scss | 42 ++ + js/gdm/authMenuButton.js | 562 ++++++++++++++++++ + js/js-resources.gresource.xml | 1 + + js/ui/popupMenu.js | 22 + + 4 files changed, 627 insertions(+) + create mode 100644 js/gdm/authMenuButton.js + +diff --git a/data/theme/gnome-shell-sass/widgets/_login-lock.scss b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +index 5dbc00a718..4b4f1a5d4e 100644 +--- a/data/theme/gnome-shell-sass/widgets/_login-lock.scss ++++ b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +@@ -67,6 +67,48 @@ $_gdm_dialog_width: 25em; + } + } + ++.login-dialog-auth-menu-button-popup { ++ padding: $base_padding * 3; ++ margin-right: $base_padding * 2; ++ ++ .login-dialog-auth-menu-header { ++ @include fontsize($base_font_size - 1); ++ text-align: center; ++ font-weight: bold; ++ padding-top: $base_padding * 3; ++ padding-bottom: $base_padding; ++ ++ &:first-child { ++ padding-top: $base_padding; ++ } ++ } ++ ++ .login-dialog-auth-menu-item-indicator { ++ spacing: $base_padding * .5; ++ ++ .login-dialog-auth-menu-item-indicator-name { ++ @include fontsize($base_font_size + 1.75); ++ font-weight: bold; ++ } ++ ++ .login-dialog-auth-menu-item-indicator-description { ++ @include fontsize($base_font_size - .25); ++ } ++ } ++} ++ ++.login-dialog-auth-menu-button-indicator { ++ background-color: transparent !important; ++ ++ .login-dialog-auth-menu-button-indicator-icons { ++ spacing: $base_padding * 3; ++ ++ .login-dialog-auth-menu-button-indicator-icon { ++ icon-size: 2em; ++ } ++ } ++} ++ + .login-dialog-button-box { + height: 4em; + } +diff --git a/js/gdm/authMenuButton.js b/js/gdm/authMenuButton.js +new file mode 100644 +index 0000000000..cc45a8f44e +--- /dev/null ++++ b/js/gdm/authMenuButton.js +@@ -0,0 +1,562 @@ ++// -*- 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 Params from '../misc/params.js'; ++import * as PopupMenu from '../ui/popupMenu.js'; ++ ++const AuthMenuItem = GObject.registerClass( ++class AuthMenuItem extends PopupMenu.PopupImageMenuItem { ++ _init(item, params) { ++ super._init(item.name, item.iconName || '', params); ++ ++ // Move ornament to the left ++ this.set_child_at_index(this._ornamentIcon, 0); ++ } ++}); ++ ++const AuthMenuItemIndicator = GObject.registerClass( ++class AuthMenuItemIndicator extends AuthMenuItem { ++ _init(item, params) { ++ super._init(item, params); ++ ++ if (item.description) { ++ const box = new St.BoxLayout({ ++ vertical: true, ++ style_class: 'login-dialog-auth-menu-item-indicator', ++ }); ++ ++ const nameLabel = new St.Label({ ++ text: item.name, ++ style_class: 'login-dialog-auth-menu-item-indicator-name', ++ }); ++ ++ const descriptionLabel = new St.Label({ ++ text: item.description, ++ style_class: 'login-dialog-auth-menu-item-indicator-description', ++ }); ++ ++ const parent = this.label_actor.get_parent(); ++ ++ box.add_child(nameLabel); ++ box.add_child(descriptionLabel); ++ ++ parent.insert_child_below(box, this.label_actor); ++ ++ this.label_actor.destroy(); ++ this.label_actor = box; ++ } ++ } ++}); ++ ++export const AuthMenuButton = GObject.registerClass({ ++ Signals: {'active-item-changed': {param_types: [GObject.TYPE_STRING]}}, ++}, class AuthMenuButton extends St.Bin { ++ _init(params) { ++ params = Params.parse(params, { ++ title: '', ++ iconName: '', ++ readOnly: false, ++ sectionOrder: [], ++ }); ++ ++ this._readOnly = params.readOnly; ++ ++ const button = new St.Button({ ++ style_class: 'login-dialog-button login-dialog-auth-menu-button', ++ child: new St.Icon({icon_name: params.iconName}), ++ 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, ++ }); ++ ++ super._init({ ++ child: button, ++ }); ++ ++ this._button = button; ++ ++ 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._menu.connect('open-state-changed', (_menu, isOpen) => { ++ if (this._readOnly) { ++ if (isOpen) ++ this._addMenuShield(); ++ else ++ this._removeMenuShield(); ++ } ++ }); ++ ++ this._manager = new PopupMenu.PopupMenuManager(this._button, ++ {actionMode: Shell.ActionMode.NONE}); ++ this._manager.addMenu(this._menu); ++ ++ this._button.connect('clicked', () => { ++ if (this._readOnly && this._getVisibleItemsCount() === 1) ++ return; ++ this._menu.toggle(); ++ }); ++ ++ this._items = new Map(); ++ this._activeItems = new Set(); ++ this._headers = new Map(); ++ this._sectionOrder = params.sectionOrder; ++ this.updateSensitivity(true); ++ } ++ ++ _addMenuShield() { ++ if (this._menuShield) ++ return; ++ ++ this._menuShield = new St.Widget({ ++ reactive: true, ++ opacity: 0, ++ }); ++ ++ Main.uiGroup.add_child(this._menuShield); ++ ++ this._menuShield.add_constraint(new Clutter.BindConstraint({ ++ source: this._menu.actor, ++ coordinate: Clutter.BindCoordinate.ALL, ++ })); ++ } ++ ++ _removeMenuShield() { ++ if (this._menuShield) { ++ this._menuShield.destroy(); ++ this._menuShield = null; ++ } ++ } ++ ++ _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._button.reactive = reactive; ++ this._button.can_focus = reactive; ++ } ++ ++ updateSensitivity(sensitive) { ++ this._sensitive = sensitive; ++ ++ const visibleItems = this._getVisibleItemsCount(); ++ if (visibleItems === 0 || (visibleItems <= 1 && !this._readOnly)) ++ sensitive = false; ++ ++ this._button.reactive = sensitive; ++ this._button.can_focus = sensitive; ++ this.opacity = sensitive ? 255 : 0; ++ this.visible = sensitive; ++ this._menu.close(BoxPointer.PopupAnimation.NONE); ++ } ++ ++ _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); ++ } ++ } ++ ++ getSections() { ++ const sectionsSet = new Set(); ++ for (const itemKey of this._items.keys()) { ++ const item = JSON.parse(itemKey); ++ if (item.sectionName) ++ sectionsSet.add(item.sectionName); ++ } ++ ++ const sections = Array.from(sectionsSet); ++ sections.sort((a, b) => { ++ const indexA = this._sectionOrder.indexOf(a); ++ const indexB = this._sectionOrder.indexOf(b); ++ ++ if (indexA !== -1 && indexB !== -1) ++ return indexA - indexB; ++ if (indexA !== -1) ++ return -1; ++ if (indexB !== -1) ++ return 1; ++ return 0; ++ }); ++ ++ return sections; ++ } ++ ++ _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 = {}) { ++ const sections = this.getSections(); ++ ++ this._findItems(searchCriteria).forEach(itemKey => { ++ const menuItem = this._items.get(itemKey); ++ this._activeItems.delete(itemKey); ++ menuItem.destroy(); ++ this._items.delete(itemKey); ++ }); ++ ++ sections.forEach(sectionName => { ++ const itemsInSection = this._findItems({sectionName}); ++ if (itemsInSection.length === 0) { ++ const header = this._headers.get(sectionName); ++ if (header) { ++ header.destroy(); ++ this._headers.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 sectionName = item.sectionName ?? null; ++ if (sectionName && !this._headers.has(sectionName)) { ++ const header = new St.Label({ ++ text: sectionName, ++ style_class: 'login-dialog-auth-menu-header', ++ y_align: Clutter.ActorAlign.START, ++ y_expand: true, ++ }); ++ ++ let insertIndex = 0; ++ const orderIndex = this._sectionOrder.indexOf(sectionName); ++ ++ if (orderIndex === -1) { ++ insertIndex = -1; ++ } else { ++ for (const existingSectionName of this._headers.keys()) { ++ const existingIndex = this._sectionOrder.indexOf(existingSectionName); ++ if (existingIndex === -1 || existingIndex > orderIndex) ++ break; ++ insertIndex++; ++ } ++ } ++ ++ this._menu.box.insert_child_at_index(header, insertIndex); ++ this._headers.set(sectionName, header); ++ } ++ ++ const menuItem = this._createMenuItem(item); ++ menuItem.setOrnament(PopupMenu.Ornament.HIDDEN); ++ ++ menuItem.connect('activate', () => { ++ this.setActiveItem(item); ++ }); ++ ++ if (sectionName) { ++ const children = this._menu.box.get_children(); ++ const header = this._headers.get(sectionName); ++ const headerIndex = children.indexOf(header); ++ ++ let insertIndex = headerIndex + 1; ++ while (insertIndex < children.length && children[insertIndex] instanceof AuthMenuItem) ++ insertIndex++; ++ ++ this._menu.box.insert_child_at_index(menuItem, insertIndex); ++ } else { ++ this._menu.box.add_child(menuItem); ++ } ++ ++ this._items.set(itemKey, menuItem); ++ this._updateVisibility(); ++ this.updateSensitivity(this._sensitive); ++ } ++ ++ _createMenuItem(item) { ++ return new AuthMenuItem(item); ++ } ++ ++ _updateVisibility() { ++ const visibleSections = this._getVisibleSections(); ++ ++ for (const [sectionName, header] of this._headers) { ++ const showThisSection = visibleSections.includes(sectionName); ++ ++ header.visible = showThisSection; ++ ++ const sectionItems = this._findItems({sectionName}); ++ for (const itemKey of sectionItems) { ++ const menuItem = this._items.get(itemKey); ++ menuItem.visible = showThisSection; ++ } ++ } ++ ++ this._button.visible = visibleSections.length > 0; ++ } ++ ++ _getVisibleSections() { ++ return Array.from(this._headers.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]); ++ } ++ ++ close() { ++ this._menu.close(); ++ } ++}); ++ ++export const AuthMenuButtonIndicator = GObject.registerClass( ++class AuthMenuButtonIndicator extends AuthMenuButton { ++ _init(params) { ++ params = Params.parse(params, { ++ title: '', ++ sectionOrder: [], ++ }); ++ ++ params.readOnly = true; ++ ++ super._init(params); ++ ++ this._button.add_style_class_name('login-dialog-auth-menu-button-indicator'); ++ ++ 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, ++ }); ++ this._button.child = this._iconsBox; ++ ++ this.remove_child(this._button); ++ container.add_child(this._button); ++ ++ this._descriptionLabel = new St.Label({ ++ y_align: Clutter.ActorAlign.CENTER, ++ visible: false, ++ }); ++ container.add_child(this._descriptionLabel); ++ ++ this.child = container; ++ ++ this._menu.setSourceActor(this._button); ++ } ++ ++ 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); ++ } ++ ++ _createMenuItem(item) { ++ return new AuthMenuItemIndicator(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() { ++ if (this._items.size === 1) { ++ const item = this.getItems()[0]; ++ if (item?.description) { ++ this._descriptionLabel.text = item.description; ++ this._descriptionLabel.visible = true; ++ } else { ++ this._descriptionLabel.visible = false; ++ } ++ } else { ++ this._descriptionLabel.visible = false; ++ } ++ } ++ ++ // 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 ee6b77d322..254d2af958 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/const.js +diff --git a/js/ui/popupMenu.js b/js/ui/popupMenu.js +index 0426ce5452..b719b1bc0c 100644 +--- a/js/ui/popupMenu.js ++++ b/js/ui/popupMenu.js +@@ -1104,6 +1104,28 @@ export class PopupMenu extends PopupMenuBase { + this._boxPointer.setSourceAlignment(alignment); + } + ++ setSourceActor(sourceActor) { ++ if (this.sourceActor === sourceActor) ++ return; ++ ++ this.sourceActor?.disconnectObject(this); ++ ++ this.sourceActor = sourceActor; ++ this.focusActor = sourceActor; ++ ++ if (this.sourceActor) { ++ this.sourceActor.connectObject( ++ 'key-press-event', this._onKeyPress.bind(this), ++ 'notify::mapped', () => { ++ if (!this.sourceActor.mapped) ++ this.close(); ++ }, this); ++ } ++ ++ if (this.isOpen) ++ this._boxPointer.setPosition(this.sourceActor, this._arrowAlignment); ++ } ++ + open(animate) { + if (this.isOpen) + return; +-- +2.51.1 + + +From 26a5a5ae6130d6a3711a15c36b02b6273c1505bc Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 11:11:32 -0500 +Subject: [PATCH 14/29] 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. +--- + .../gnome-shell-sass/widgets/_login-lock.scss | 1 + + js/gdm/loginDialog.js | 167 ++++++------------ + 2 files changed, 56 insertions(+), 112 deletions(-) + +diff --git a/data/theme/gnome-shell-sass/widgets/_login-lock.scss b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +index 4b4f1a5d4e..58c2ed495c 100644 +--- a/data/theme/gnome-shell-sass/widgets/_login-lock.scss ++++ b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +@@ -50,6 +50,7 @@ $_gdm_dialog_width: 25em; + &.a11y-button, + &.cancel-button, + &.switch-user-button, ++ &.login-dialog-auth-menu-button, + &.login-dialog-session-list-button { + @extend .icon-button; + @extend %system_button; +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index bb65f047a5..507107614c 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -27,9 +27,9 @@ import Pango from 'gi://Pango'; + import Shell from 'gi://Shell'; + import St from 'gi://St'; + ++import * as AuthMenuButton from './authMenuButton.js'; + import * as AuthPrompt from './authPrompt.js'; + import * as Batch from './batch.js'; +-import * as BoxPointer from '../ui/boxpointer.js'; + import * as CtrlAltTab from '../ui/ctrlAltTab.js'; + import * as GdmUtil from './util.js'; + import * as Layout from '../ui/layout.js'; +@@ -47,6 +47,7 @@ const _FADE_ANIMATION_TIME = 250; + const _SCROLL_ANIMATION_TIME = 500; + const _TIMED_LOGIN_IDLE_THRESHOLD = 5.0; + const _CONFLICTING_SESSION_DIALOG_TIMEOUT = 60; ++const _SESSION_TYPE_SECTION_NAME = _('Session Type'); + + const N_A11Y_MENU_COLUMNS = 2; + +@@ -314,100 +315,6 @@ const UserList = GObject.registerClass({ + } + }); + +-const SessionMenuButton = GObject.registerClass({ +- Signals: {'session-activated': {param_types: [GObject.TYPE_STRING]}}, +-}, class SessionMenuButton extends St.Bin { +- _init() { +- let button = new St.Button({ +- style_class: 'login-dialog-button login-dialog-session-list-button', +- icon_name: 'cog-wheel-symbolic', +- 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, +- }); +- +- super._init({child: button}); +- this._button = button; +- +- this._menu = new PopupMenu.PopupMenu(this._button, 0, St.Side.BOTTOM); +- Main.uiGroup.add_child(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 = new Map(); +- 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() { +- for (const itemId of this._items.keys()) { +- if (itemId === this._activeSessionId) +- this._items.get(itemId).setOrnament(PopupMenu.Ornament.DOT); +- else +- this._items.get(itemId).setOrnament(PopupMenu.Ornament.NO_DOT); +- } +- } +- +- 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.set(id, item); +- +- item.connect('activate', () => { +- this.setActiveSession(id); +- this.emit('session-activated', this._activeSessionId); +- }); +- } +- } +-}); +- + const A11yMenuButton = GObject.registerClass( + class A11yMenuButton extends St.Button { + constructor() { +@@ -588,6 +495,7 @@ export const 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); + +@@ -641,14 +549,7 @@ export const 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._a11yMenuButton = new A11yMenuButton(); + this._bottomButtonGroup.add_child(this._a11yMenuButton); +@@ -684,6 +585,45 @@ export const LoginDialog = GObject.registerClass({ + this._updateDisableUserList.bind(this), this); + } + ++ _createAuthMenuButton() { ++ this._authMenuButton = new AuthMenuButton.AuthMenuButton({ ++ title: _('Login Options'), ++ iconName: 'cog-wheel-symbolic', ++ sectionOrder: [_SESSION_TYPE_SECTION_NAME], ++ }); ++ this._authMenuButton.updateSensitivity(false); ++ ++ const ids = Gdm.get_session_ids(); ++ ids.sort(); ++ ++ if (ids.length <= 1) { ++ this._button.hide(); ++ return; ++ } ++ ++ for (const id of ids) { ++ const [sessionName, _] = Gdm.get_session_name_and_description(id); ++ ++ this._authMenuButton.addItem({ ++ sectionName: _SESSION_TYPE_SECTION_NAME, ++ name: sessionName, ++ id, ++ }); ++ } ++ ++ 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.close(); ++ }); ++ this._bottomButtonGroup.add_child(this._authMenuButton); ++ } ++ + _getBannerAllocation(dialogBox) { + let actorBox = new Clutter.ActorBox(); + +@@ -1054,10 +994,8 @@ export const LoginDialog = GObject.registerClass({ + } + + _onPrompted() { +- const showSessionMenu = this._shouldShowSessionMenuButton(); ++ this._authMenuButton.updateSensitivity(this._shouldShowAuthMenu()); + +- this._sessionMenuButton.updateSensitivity(showSessionMenu); +- this._sessionMenuButton.visible = showSessionMenu; + this._showPrompt(); + } + +@@ -1077,7 +1015,6 @@ export const LoginDialog = GObject.registerClass({ + + _onReset(authPrompt, beginRequest) { + this._ensureGreeterProxy(); +- this._sessionMenuButton.updateSensitivity(true); + + const previousUser = this._user; + this._user = null; +@@ -1111,11 +1048,18 @@ export const 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, +@@ -1177,7 +1121,7 @@ export const LoginDialog = GObject.registerClass({ + }); + this._updateCancelButton(); + +- this._sessionMenuButton.updateSensitivity(false); ++ this._authMenuButton.updateSensitivity(false); + this._authPrompt.updateSensitivity(true); + this._showPrompt(); + } +@@ -1491,8 +1435,7 @@ export const 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.1 + + +From 028ce476031b926c0884eb75ec91540b8e868eec Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 13:40:26 -0500 +Subject: [PATCH 15/29] 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 it will be implemented how UserVerifier emits +"mechanisms-changed". + +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 new _authIndicatorButton to inform when they are enabled. + +Co-authored-by: Marco Trevisan (Treviño) +--- + js/gdm/authPrompt.js | 13 ++++++++++++ + js/gdm/loginDialog.js | 48 ++++++++++++++++++++++++++++++++++++------- + 2 files changed, 54 insertions(+), 7 deletions(-) + +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 2a66947493..bae0f008f7 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -51,6 +51,7 @@ export const AuthPrompt = GObject.registerClass({ + 'failed': {}, + 'next': {}, + 'prompted': {}, ++ 'mechanisms-changed': {param_types: [GObject.TYPE_JSOBJECT, GObject.TYPE_JSOBJECT]}, + 'reset': {param_types: [GObject.TYPE_UINT]}, + 'verification-complete': {}, + 'loading': {param_types: [GObject.TYPE_BOOLEAN]}, +@@ -86,6 +87,7 @@ export const AuthPrompt = GObject.registerClass({ + 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('mechanisms-changed', (_, ...args) => this.emit('mechanisms-changed', ...args)); + 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)); +@@ -734,6 +736,17 @@ export const AuthPrompt = GObject.registerClass({ + this.updateSensitivity(false); + } + ++ selectMechanism(mechanism) { ++ const invalidStatus = [ ++ AuthPromptStatus.VERIFICATION_SUCCEEDED, ++ AuthPromptStatus.VERIFICATION_IN_PROGRESS, ++ ]; ++ if (invalidStatus.includes(this.verificationStatus)) ++ return false; ++ ++ return true; ++ } ++ + reset(params) { + let {beginRequestType, reuseEntryText, softReset} = Params.parse(params, { + beginRequestType: null, +diff --git a/js/gdm/loginDialog.js b/js/gdm/loginDialog.js +index 507107614c..f578f6c88a 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -47,6 +47,7 @@ const _FADE_ANIMATION_TIME = 250; + const _SCROLL_ANIMATION_TIME = 500; + const _TIMED_LOGIN_IDLE_THRESHOLD = 5.0; + const _CONFLICTING_SESSION_DIALOG_TIMEOUT = 60; ++const _PRIMARY_LOGIN_METHOD_SECTION_NAME = _('Login Options'); + const _SESSION_TYPE_SECTION_NAME = _('Session Type'); + + const N_A11Y_MENU_COLUMNS = 2; +@@ -496,6 +497,7 @@ export const 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); + +@@ -589,18 +591,13 @@ export const 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); + + const ids = Gdm.get_session_ids(); + ids.sort(); + +- if (ids.length <= 1) { +- this._button.hide(); +- return; +- } +- + for (const id of ids) { + const [sessionName, _] = Gdm.get_session_name_and_description(id); + +@@ -616,7 +613,9 @@ export const 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.close(); +@@ -624,6 +623,20 @@ export const LoginDialog = GObject.registerClass({ + this._bottomButtonGroup.add_child(this._authMenuButton); + } + ++ _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(); + +@@ -1052,6 +1065,27 @@ export const 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, +-- +2.51.1 + + +From f7a8e6c80d8d0239ca718d62ad2406771496e028 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 13:41:39 -0500 +Subject: [PATCH 16/29] 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 03d91b6114..0a8c208d7d 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -15,11 +15,16 @@ import * as Main from './main.js'; + import * as MessageTray from './messageTray.js'; + import * as SwipeTracker from './swipeTracker.js'; + import {formatDateWithCFormatString} from '../misc/dateUtils.js'; ++import * as AuthMenuButton from '../gdm/authMenuButton.js'; + import * as AuthPrompt from '../gdm/authPrompt.js'; ++import * as GdmConst from '../gdm/const.js'; ++import * as GdmUtil from '../gdm/util.js'; + import {AuthPromptStatus} from '../gdm/authPrompt.js'; + import {MprisSource} from './mpris.js'; + import {MediaMessage} from './messageList.js'; + ++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; + +@@ -430,12 +435,13 @@ class UnlockDialogClock extends St.BoxLayout { + + const 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) { +@@ -495,22 +501,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); + } + } + }); +@@ -620,19 +644,47 @@ export const 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: 'login-dialog-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, +- 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.close(); ++ }); ++ this._authMenuButton.updateSensitivity(true); ++ this._bottomButtonGroup.add_child(this._authMenuButton); ++ ++ // Auth Indicators ++ this._authIndicatorButton = new AuthMenuButton.AuthMenuButtonIndicator({ ++ title: _('Background Authentication Methods'), ++ }); ++ this._authIndicatorButton.add_style_class_name('login-dialog-bottom-button-group'); ++ this._authIndicatorButton.set_pivot_point(0.5, 0.5); ++ this._authIndicatorButton.updateSensitivity(true); + + this._screenSaverSettings = new Gio.Settings({schema_id: 'org.gnome.desktop.screensaver'}); + +@@ -655,11 +707,13 @@ export const 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 = global.backend.get_core_idle_monitor(); +@@ -697,6 +751,20 @@ export const 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({ +@@ -753,6 +821,8 @@ export const 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); + } + +@@ -815,6 +885,12 @@ export const 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); + +@@ -832,7 +908,7 @@ export const 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, +@@ -856,6 +932,52 @@ export const 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.getIconName(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 GdmConst.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(); +@@ -919,7 +1041,8 @@ export const UnlockDialog = GObject.registerClass({ + this._otherUserButton.visible = this._userManager.can_switch() && + this._userManager.has_multiple_users && + this._screenSaverSettings.get_boolean('user-switch-enabled') && +- !this._lockdownSettings.get_boolean('disable-user-switching'); ++ !this._lockdownSettings.get_boolean('disable-user-switching') && ++ this._promptBox.visible; + } + + cancel() { +-- +2.51.1 + + +From b8b8b2a8baffc39e6d8a1e7887cae6995e5ce8f6 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 3 Dec 2024 07:39:32 -0500 +Subject: [PATCH 17/29] 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 0a8c208d7d..92a9f8fbbe 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -421,9 +421,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 === GdmConst.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.1 + + +From d20606a97cf0dc4df07274e930651ae9dcf6991a Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 14 Oct 2025 19:19:15 +0200 +Subject: [PATCH 18/29] gdm/util: Increase time of messages based on new + environment variable + +called 'GDM_MESSAGE_TIME_MULTIPLIER'. This is can used for testing +purposes. When no set, the multiplier is 1 which does nothing. +--- + js/gdm/util.js | 4 +++- + 1 file changed, 3 insertions(+), 1 deletion(-) + +diff --git a/js/gdm/util.js b/js/gdm/util.js +index 3ad0116686..20dcd4c49a 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -278,8 +278,10 @@ export class ShellUserVerifier extends Signals.EventEmitter { + if (!message) + return 0; + ++ const messageTimeMultiplier = GLib.getenv('GDM_MESSAGE_TIME_MULTIPLIER') ?? 1; ++ + // We probably could be smarter here +- return message.length * USER_READ_TIME; ++ return message.length * USER_READ_TIME * messageTimeMultiplier; + } + + finishMessageQueue() { +-- +2.51.1 + + +From db68ffad42a67c693c31f6731f5aa5a9eaa63558 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 15 Sep 2025 17:13:17 +0200 +Subject: [PATCH 19/29] 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 bae0f008f7..4211a486cc 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -828,11 +828,7 @@ export const AuthPrompt = GObject.registerClass({ + + this.updateSensitivity(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 20dcd4c49a..b797d1d321 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -463,8 +463,6 @@ export class ShellUserVerifier extends Signals.EventEmitter { + + if (this._userVerifier && + !this._activeServices.has(Const.FINGERPRINT_SERVICE_NAME)) { +- if (!this._hold?.isAcquired()) +- this._hold = new Batch.Hold(); + await this._maybeStartFingerprintVerification(); + } + } +@@ -528,7 +526,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + + _reportInitError(where, error, serviceName) { + logError(error, where); +- this._hold.release(); ++ this._hold?.release(); + + this._queueMessage(serviceName, _('Authentication error'), MessageType.ERROR); + this._failCounter++; +@@ -564,7 +562,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + this.reauthenticating = true; + this._connectSignals(); + this._beginVerification(); +- this._hold.release(); ++ this._hold?.release(); + } + + async _getUserVerifier() { +@@ -586,7 +584,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + + this._connectSignals(); + this._beginVerification(); +- this._hold.release(); ++ this._hold?.release(); + } + + _connectSignals() { +@@ -704,7 +702,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + async _startService(serviceName) { +- this._hold.acquire(); ++ this._hold?.acquire(); + try { + this._activeServices.add(serviceName); + +@@ -725,7 +723,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + if (!this.serviceIsForeground(serviceName)) { + logError(e, + `Failed to start ${serviceName} for ${this._userName}`); +- this._hold.release(); ++ this._hold?.release(); + return; + } + this._reportInitError( +@@ -735,7 +733,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + e, serviceName); + return; + } +- this._hold.release(); ++ this._hold?.release(); + } + + _beginVerification() { +@@ -869,7 +867,6 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + _retry(serviceName) { +- this._hold = new Batch.Hold(); + this._connectSignals(); + this._startService(serviceName); + } +-- +2.51.1 + + +From 22f9296a614bfd26790a774ab25ed4c7bf13779f Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Wed, 20 Aug 2025 10:54:33 +0200 +Subject: [PATCH 20/29] gdm/util: 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 | 154 +++++++++------------------------- + js/js-resources.gresource.xml | 1 + + js/misc/fingerprintManager.js | 122 +++++++++++++++++++++++++++ + 3 files changed, 161 insertions(+), 116 deletions(-) + create mode 100644 js/misc/fingerprintManager.js + +diff --git a/js/gdm/util.js b/js/gdm/util.js +index b797d1d321..cea0a5b213 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -5,6 +5,8 @@ import GLib from 'gi://GLib'; + import * as Signals from '../misc/signals.js'; + + import * as Batch from './batch.js'; ++import * as Const from './const.js'; ++import * as FingerprintManager from '../misc/fingerprintManager.js'; + import * as OVirt from './oVirt.js'; + import * as Vmware from './vmware.js'; + import * as Main from '../ui/main.js'; +@@ -12,11 +14,6 @@ import {loadInterfaceXML} from '../misc/fileUtils.js'; + import * as Params from '../misc/params.js'; + import * as SmartcardManager from '../misc/smartcardManager.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')); +- + Gio._promisify(Gdm.Client.prototype, 'open_reauthentication_channel'); + Gio._promisify(Gdm.Client.prototype, 'get_user_verifier'); + Gio._promisify(Gdm.UserVerifierProxy.prototype, +@@ -55,12 +52,6 @@ export const MessageType = { + ERROR: 3, + }; + +-const FingerprintReaderType = { +- NONE: 0, +- PRESS: 1, +- SWIPE: 2, +-}; +- + /** + * @param {Clutter.Actor} actor + */ +@@ -136,7 +127,8 @@ export class ShellUserVerifier extends Signals.EventEmitter { + + this._defaultService = null; + this._preemptingService = null; +- this._fingerprintReaderType = FingerprintReaderType.NONE; ++ this._fingerprintReaderType = FingerprintManager.FingerprintReaderType.NONE; ++ this._fingerprintReaderFound = false; + this._fprintStartTime = -1; + + this._messageQueue = []; +@@ -201,8 +193,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + 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 +@@ -254,6 +245,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + this._smartcardManager?.disconnectObject(this); + this._smartcardManager = null; + ++ this._fingerprintManager?.disconnectObject(this); + this._fingerprintManager = null; + + for (let service in this._credentialManagers) +@@ -367,112 +359,40 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + async _initFingerprintManager() { +- if (this._fprintManager) ++ if (this._fingerprintManager) + 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 [devicePath] = fprintManager.GetDefaultDeviceSync(); +- 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 instanceof GLib.Error) { +- if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) +- return; +- 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'); +- } ++ this._fingerprintManager = ++ new FingerprintManager.FingerprintManager(this._cancellable); ++ this._fingerprintManager.connectObject( ++ 'reader-type-changed', () => this._onFingerprintReaderTypeChanged(), ++ this); + +- async _checkForFingerprintReader() { +- if (!this._fprintManager) { +- this._updateDefaultService(); +- return; ++ 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) { +- // Wrappers don't support null cancellable, so let's ignore it in case +- const args = cancellable ? [cancellable] : []; +- const [devicePath] = await fprintManager.GetDefaultDeviceAsync(...args); +- 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(Const.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(Const.FINGERPRINT_SERVICE_NAME)) ++ this._maybeStartFingerprintVerification(); + } + + _onCredentialManagerAuthenticated(credentialManager, _token) { +@@ -638,7 +558,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + serviceIsFingerprint(serviceName) { +- return this._fingerprintReaderType !== FingerprintReaderType.NONE && ++ return this._fingerprintReaderFound && + serviceName === Const.FINGERPRINT_SERVICE_NAME; + } + +@@ -651,9 +571,11 @@ export class ShellUserVerifier extends Signals.EventEmitter { + 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(Const.FINGERPRINT_SERVICE_NAME)) +@@ -681,7 +603,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + return Const.PASSWORD_SERVICE_NAME; + else if (this._smartcardManager) + return Const.SMARTCARD_SERVICE_NAME; +- else if (this._fingerprintReaderType !== FingerprintReaderType.NONE) ++ else if (this._fingerprintReaderFound) + return Const.FINGERPRINT_SERVICE_NAME; + return null; + } +@@ -743,7 +665,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + + async _maybeStartFingerprintVerification() { + if (this._userName && +- this._fingerprintReaderType !== FingerprintReaderType.NONE && ++ this._fingerprintReaderFound && + !this.serviceIsForeground(Const.FINGERPRINT_SERVICE_NAME)) + await this._startService(Const.FINGERPRINT_SERVICE_NAME); + } +@@ -766,7 +688,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + // 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) { ++ if (this._fingerprintReaderType === FingerprintManager.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)'), +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index 254d2af958..2712bfcc84 100644 +--- a/js/js-resources.gresource.xml ++++ b/js/js-resources.gresource.xml +@@ -26,6 +26,7 @@ + misc/dependencies.js + misc/errorUtils.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..60b5163b48 +--- /dev/null ++++ b/js/misc/fingerprintManager.js +@@ -0,0 +1,122 @@ ++import Gio from 'gi://Gio'; ++import GLib from 'gi://GLib'; ++ ++import * as Signals from './signals.js'; ++ ++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, ++}; ++ ++export class FingerprintManager extends Signals.EventEmitter { ++ constructor(cancellable) { ++ 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._fingerprintReaderType = FingerprintReaderType.NONE; ++ ++ this._initFingerprintManagerProxy(cancellable); ++ } ++ ++ get readerType() { ++ return this._fingerprintReaderType; ++ } ++ ++ get readerFound() { ++ return this._fingerprintReaderFound; ++ } ++ ++ setDefaultTimeout(timeout) { ++ this._fingerprintManagerProxy.set_default_timeout(timeout); ++ } ++ ++ async checkReaderType(cancellable) { ++ 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) { ++ this._handleFingerprintError(e); ++ } ++ } ++ ++ async _initFingerprintManagerProxy(cancellable) { ++ try { ++ await this._fingerprintManagerProxy.init_async( ++ GLib.PRIORITY_DEFAULT, cancellable ?? null); ++ await this.checkReaderType(cancellable); ++ } 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 (this._fingerprintReaderType === fingerprintReaderType) ++ return; ++ ++ this._fingerprintReaderType = fingerprintReaderType; ++ ++ this._fingerprintReaderFound = ++ !!this._fingerprintReaderType && ++ this._fingerprintReaderType !== FingerprintReaderType.NONE; ++ ++ if (this._fingerprintReaderType === undefined) ++ throw new Error(`Unexpected fingerprint device type '${fingerprintReaderType}'`); ++ ++ this.emit('reader-type-changed'); ++ } ++ ++ _handleFingerprintError(e) { ++ this._setFingerprintReaderType(FingerprintReaderType.NONE); ++ ++ if (e instanceof GLib.Error) { ++ if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) ++ return; ++ 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.1 + + +From a291c648f4ff6d0e30770f497ac519a45e82c5fd Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 23 Sep 2025 16:45:59 +0200 +Subject: [PATCH 21/29] gdm/misc: Add passkeyManager + +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/dependencies.js | 1 + + js/misc/passkeyManager.js | 60 +++++++++++++++++++++++++++++++++++ + 3 files changed, 62 insertions(+) + create mode 100644 js/misc/passkeyManager.js + +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index 2712bfcc84..11de814fbd 100644 +--- a/js/js-resources.gresource.xml ++++ b/js/js-resources.gresource.xml +@@ -40,6 +40,7 @@ + misc/objectManager.js + misc/params.js + misc/parentalControlsManager.js ++ misc/passkeyManager.js + misc/permissionStore.js + misc/signals.js + misc/signalTracker.js +diff --git a/js/misc/dependencies.js b/js/misc/dependencies.js +index f8f98d4871..c3c51aa2de 100644 +--- a/js/misc/dependencies.js ++++ b/js/misc/dependencies.js +@@ -18,6 +18,7 @@ import 'gi://GdkPixbuf?version=2.0'; + import 'gi://GnomeBG?version=4.0'; + import 'gi://GnomeDesktop?version=4.0'; + import 'gi://Graphene?version=1.0'; ++import 'gi://GUdev?version=1.0'; + import 'gi://GWeather?version=4.0'; + import 'gi://IBus?version=1.0'; + import 'gi://Pango?version=1.0'; +diff --git a/js/misc/passkeyManager.js b/js/misc/passkeyManager.js +new file mode 100644 +index 0000000000..66e17e3f3c +--- /dev/null ++++ b/js/misc/passkeyManager.js +@@ -0,0 +1,60 @@ ++import GUdev from 'gi://GUdev'; ++import * as Signals from './signals.js'; ++ ++let _passkeyManager = null; ++ ++/** ++ * @returns {PasskeyManager} ++ */ ++export function getPasskeyManager() { ++ if (_passkeyManager == null) ++ _passkeyManager = new PasskeyManager(); ++ ++ return _passkeyManager; ++} ++ ++class PasskeyManager extends Signals.EventEmitter { ++ constructor() { ++ super(); ++ ++ this._insertedPasskeys = new Map(); ++ this._udevClient = new GUdev.Client({subsystems: ['hidraw']}); ++ ++ this._onLoaded(); ++ } ++ ++ hasInsertedPasskeys() { ++ return Object.keys(this._insertedPasskeys).length > 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 (this._insertedPasskeys.has(sysfsPath) || !isFido) ++ 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.1 + + +From 568c91e4cb55750ca38790d53b82d86eb9bc1773 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 18 Aug 2025 12:08:21 +0200 +Subject: [PATCH 22/29] gdm: Add AuthServices +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +All authentication mechanisms that were handled in util.js UserVerifier +are ported to a new class called AuthServices. In util.js it's left +getting the userVerifier proxies. + +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 const.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 a child class of AuthServices 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 | 42 +- + js/gdm/authServices.js | 465 +++++++++++++++++++++ + js/gdm/authServicesLegacy.js | 318 +++++++++++++++ + js/gdm/util.js | 732 +++++++--------------------------- + js/js-resources.gresource.xml | 2 + + js/misc/fingerprintManager.js | 4 - + po/POTFILES.in | 1 + + 7 files changed, 940 insertions(+), 624 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 4211a486cc..91702c9b76 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -10,7 +10,6 @@ import St from 'gi://St'; + import * as Animation from '../ui/animation.js'; + import * as AuthList from './authList.js'; + import * as Batch from './batch.js'; +-import * as Const from './const.js'; + import * as GdmUtil from './util.js'; + import * as Params from '../misc/params.js'; + import * as ShellEntry from '../ui/shellEntry.js'; +@@ -91,9 +90,6 @@ export const AuthPrompt = GObject.registerClass({ + 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.smartcardDetected = this._userVerifier.smartcardDetected; + + this.connect('destroy', this._onDestroy.bind(this)); + +@@ -429,31 +425,6 @@ export const 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(Const.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) { + let wiggleParameters = {duration: 0}; + +@@ -744,6 +715,13 @@ export const AuthPrompt = GObject.registerClass({ + 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; + } + +@@ -768,8 +746,10 @@ export const 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(); + + this._queryingService = null; + this.clear(); +diff --git a/js/gdm/authServices.js b/js/gdm/authServices.js +new file mode 100644 +index 0000000000..68a2c4c3f5 +--- /dev/null ++++ b/js/gdm/authServices.js +@@ -0,0 +1,465 @@ ++// -*- 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 * 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'); ++ ++export const AuthServices = GObject.registerClass({ ++ Signals: { ++ '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': {}, ++ }, ++}, class AuthServices extends GObject.Object { ++ static SupportedRoles = []; ++ static RoleToService = {}; ++ static supportsAny(roles) { ++ return roles.some(r => this.SupportedRoles.includes(r)); ++ } ++ ++ _init(params) { ++ super._init(); ++ 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); ++ } ++ ++ async answerQuery(serviceName, answer) { ++ try { ++ await this._waitPendingMessages(); ++ await this._handleAnswerQuery(serviceName, answer); ++ } catch (e) { ++ logErrorUnlessCancelled(e); ++ } ++ } ++ ++ async beginVerification(userName, userVerifierProxies) { ++ if (!this._cancellable) ++ this._cancellable = new Gio.Cancellable(); ++ this._userName = userName; ++ ++ try { ++ this._updateUserVerifier(userVerifierProxies); ++ await this._startServices(); ++ } 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(); ++ } ++ ++ 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 = new FingerprintManager.FingerprintManager(); ++ this._fingerprintManager.connectObject( ++ 'reader-type-changed', () => this._handleFingerprintChanged(), ++ this); ++ } ++ ++ _waitPendingMessages() { ++ const cancellable = this._cancellable; ++ return new Promise((resolve, reject) => { ++ let done = false; ++ const safeResolve = () => { ++ if (!done) { ++ done = true; ++ if (cancellable?.is_cancelled()) { ++ reject(new GLib.Error( ++ Gio.IOErrorEnum, ++ Gio.IOErrorEnum.CANCELLED, ++ 'Operation was cancelled')); ++ } else { ++ resolve(); ++ } ++ } ++ }; ++ const safeReject = err => { ++ if (!done) { ++ done = true; ++ reject(err); ++ } ++ }; ++ const waiter = { ++ resolve: safeResolve, ++ reject: safeReject, ++ }; ++ this.emit('wait-pending-messages', waiter); ++ ++ GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 10, () => { ++ safeReject(new Error('Timed out waiting for pending messages')); ++ return GLib.SOURCE_REMOVE; ++ }); ++ }); ++ } ++ ++ _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() { ++ for (const serviceName of this._getEnabledServices()) { ++ if (this._canStartService(serviceName)) { ++ // eslint-disable-next-line no-await-in-loop ++ await this._startService(serviceName); ++ } ++ } ++ } ++ ++ _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) { ++ 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 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() {} ++ ++ async _handleAnswerQuery() {} ++ ++ _handleBeginVerification() {} ++ ++ _handleSelectMechanism() {} ++ ++ _handleNeedsUsername() { ++ return true; ++ } ++ ++ _handleReset() {} ++ ++ _handleCancel() {} ++ ++ _handleClear() {} ++ ++ _handleUpdateEnabledMechanisms() {} ++ ++ _handleSmartcardChanged() {} ++ ++ _handleFingerprintChanged() {} ++ ++ _handleOnInfo() {} ++ ++ _handleOnProblem() {} ++ ++ _handleOnInfoQuery() {} ++ ++ _handleOnSecretInfoQuery() {} ++ ++ _handleOnConversationStarted() {} ++ ++ _handleOnConversationStopped() {} ++ ++ _handleOnServiceUnavailable() {} ++ ++ _handleOnVerificationComplete() {} ++ ++ _handleOnChoiceListQuery() {} ++ ++ _handleOnCustomJSONRequest() {} ++ ++ _handleVerificationFailed() {} ++ ++ _handleGetCredentialManagerServices() { ++ return []; ++ } ++ ++ _handleCanStartService() { ++ return false; ++ } ++}); +diff --git a/js/gdm/authServicesLegacy.js b/js/gdm/authServicesLegacy.js +new file mode 100644 +index 0000000000..93f55ec45d +--- /dev/null ++++ b/js/gdm/authServicesLegacy.js +@@ -0,0 +1,318 @@ ++import GLib from 'gi://GLib'; ++import GObject from 'gi://GObject'; ++ ++import * as Const from './const.js'; ++import {FingerprintReaderType} from '../misc/fingerprintManager.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: Const.PASSWORD_SERVICE_NAME, ++ role: Const.PASSWORD_ROLE_NAME, ++ name: 'Password', ++ }, ++ { ++ serviceName: Const.SMARTCARD_SERVICE_NAME, ++ role: Const.SMARTCARD_ROLE_NAME, ++ name: 'Smartcard', ++ }, ++ { ++ serviceName: Const.FINGERPRINT_SERVICE_NAME, ++ role: Const.FINGERPRINT_ROLE_NAME, ++ name: 'Fingerprint', ++ }, ++]; ++ ++export const AuthServicesLegacy = GObject.registerClass({ ++}, class AuthServicesLegacy extends AuthServices { ++ static SupportedRoles = [ ++ Const.PASSWORD_ROLE_NAME, ++ Const.SMARTCARD_ROLE_NAME, ++ Const.FINGERPRINT_ROLE_NAME, ++ ]; ++ ++ static RoleToService = { ++ [Const.PASSWORD_ROLE_NAME]: Const.PASSWORD_SERVICE_NAME, ++ [Const.SMARTCARD_ROLE_NAME]: Const.SMARTCARD_SERVICE_NAME, ++ [Const.FINGERPRINT_ROLE_NAME]: Const.FINGERPRINT_SERVICE_NAME, ++ }; ++ ++ _init(params) { ++ super._init(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, null); ++ } ++ ++ async _handleAnswerQuery(serviceName, answer) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ if (this._selectedMechanism.role === Const.SMARTCARD_ROLE_NAME) ++ this._smartcardInProgress = true; ++ ++ await this._userVerifier.call_answer_query(serviceName, ++ answer, ++ this._cancellable, ++ null); ++ } ++ ++ _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 === Const.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 !== Const.FINGERPRINT_ROLE_NAME ++ )); ++ } else { ++ this._enabledMechanisms.push(...Mechanisms.filter(m => ++ this._enabledRoles.includes(m.role) ++ )); ++ } ++ } ++ ++ _handleSmartcardChanged() { ++ if (this._selectedMechanism?.role !== Const.SMARTCARD_ROLE_NAME || ++ this._smartcardInProgress && this._smartcardManager.hasInsertedTokens()) ++ return; ++ ++ this.emit('reset', {softReset: true}); ++ } ++ ++ _handleFingerprintChanged() { ++ if (!this._enabledRoles.includes(Const.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 === Const.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 === Const.FINGERPRINT_SERVICE_NAME && ++ this._enabledMechanisms.some(m => m.serviceName === serviceName))) { ++ this.emit('queue-priority-message', ++ serviceName, ++ problem, ++ Util.MessageType.ERROR); ++ } ++ ++ if (serviceName === Const.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 !== Const.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 (this._unavailableServices.has(serviceName)) { ++ if (serviceName === Const.FINGERPRINT_SERVICE_NAME) { ++ this._enabledMechanisms = this._enabledMechanisms ++ .filter(m => m.serviceName !== serviceName); ++ this.emit('mechanisms-changed'); ++ } ++ 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 === Const.FINGERPRINT_SERVICE_NAME && ++ this._enabledMechanisms.some(m => m.serviceName === serviceName) && ++ errorMessage) { ++ this.emit('queue-message', ++ serviceName, ++ errorMessage, ++ Util.MessageType.ERROR); ++ } ++ } ++ ++ _handleVerificationFailed(serviceName) { ++ if (serviceName === Const.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 = Object.fromEntries( ++ Object.entries(list.deepUnpack()) ++ .map(([key, value]) => [key, {description: value}])); ++ ++ this.emit('show-choice-list', serviceName, promptMessage, choiceList); ++ } ++ ++ _handleGetCredentialManagerServices() { ++ return Object.keys(this._credentialManagers); ++ } ++ ++ _handleCanStartService(serviceName) { ++ return serviceName === this._selectedMechanism?.serviceName || ++ (serviceName === Const.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); ++ } ++ ++ _onCredentialManagerAuthenticated(credentialManager) { ++ this._selectedMechanism = { ++ serviceName: credentialManager.service, ++ role: Const.PASSWORD_ROLE_NAME, ++ }; ++ this.emit('reset', {softReset: true}); ++ } ++}); +diff --git a/js/gdm/util.js b/js/gdm/util.js +index cea0a5b213..f35a97b98b 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -1,24 +1,13 @@ + import Clutter from 'gi://Clutter'; +-import Gdm from 'gi://Gdm'; + import Gio from 'gi://Gio'; + import GLib from 'gi://GLib'; + import * as Signals from '../misc/signals.js'; + + import * as Batch from './batch.js'; + import * as Const from './const.js'; +-import * as FingerprintManager from '../misc/fingerprintManager.js'; +-import * as OVirt from './oVirt.js'; +-import * as Vmware from './vmware.js'; + import * as Main from '../ui/main.js'; +-import {loadInterfaceXML} from '../misc/fileUtils.js'; + import * as Params from '../misc/params.js'; +-import * as SmartcardManager from '../misc/smartcardManager.js'; +- +-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'); ++import {AuthServicesLegacy} from './authServicesLegacy.js'; + + const CLONE_FADE_ANIMATION_TIME = 250; + +@@ -37,8 +26,6 @@ export const DISABLE_USER_LIST_KEY = 'disable-user-list'; + + // Give user 48ms to read each character of a PAM message + const USER_READ_TIME = 48; +-const FINGERPRINT_SERVICE_PROXY_TIMEOUT = 5000; +-const FINGERPRINT_ERROR_TIMEOUT_WAIT = 15; + + /** + * Keep messages in order by priority +@@ -52,6 +39,15 @@ export const MessageType = { + ERROR: 3, + }; + ++export class InitError extends Error { ++ constructor(error, message, serviceName) { ++ super(message); ++ this.error = error; ++ this.where = message; ++ this.serviceName = serviceName; ++ } ++} ++ + /** + * @param {Clutter.Actor} actor + */ +@@ -125,54 +121,12 @@ export class ShellUserVerifier extends Signals.EventEmitter { + this._client = client; + this._cancellable = null; + +- this._defaultService = null; +- this._preemptingService = null; +- this._fingerprintReaderType = FingerprintManager.FingerprintReaderType.NONE; +- this._fingerprintReaderFound = false; +- this._fprintStartTime = -1; +- + 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.connect('changed', () => this._onSettingsChanged()); +- this._updateEnabledServices(); +- this._updateDefaultService(); +- +- this.addCredentialManager(OVirt.SERVICE_NAME, OVirt.getOVirtCredentialsManager()); +- this.addCredentialManager(Vmware.SERVICE_NAME, Vmware.getVmwareCredentialsManager()); +- } +- +- addCredentialManager(serviceName, credentialManager) { +- if (this._credentialManagers[serviceName]) +- return; +- +- this._credentialManagers[serviceName] = credentialManager; +- if (credentialManager.token) { +- this._onCredentialManagerAuthenticated(credentialManager, +- credentialManager.token); +- } +- +- credentialManager.connectObject('user-authenticated', +- this._onCredentialManagerAuthenticated.bind(this), this); +- } +- +- removeCredentialManager(serviceName) { +- let credentialManager = this._credentialManagers[serviceName]; +- if (!credentialManager) +- return; +- +- credentialManager.disconnectObject(this); +- delete this._credentialManagers[serviceName]; ++ this._updateAuthServices(); + } + + get hasPendingMessages() { +@@ -187,53 +141,54 @@ export class ShellUserVerifier extends Signals.EventEmitter { + return this._messageQueue ? this._messageQueue[0] : null; + } + +- begin(userName, hold) { ++ async begin(userName, hold) { + 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); ++ 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(); + } + +- cancel() { +- if (this._cancellable) +- this._cancellable.cancel(); ++ selectMechanism(mechanism) { ++ return this._authServicesLegacy?.selectMechanism(mechanism); ++ } + +- if (this._userVerifier) { +- this._userVerifier.call_cancel_sync(null); +- this.clear(); +- } ++ needsUsername() { ++ return this._authServicesLegacy?.needsUsername(); + } + +- _clearUserVerifier() { +- if (this._userVerifier) { +- this._disconnectSignals(); +- this._userVerifier.run_dispose(); +- this._userVerifier = null; +- if (this._userVerifierChoiceList) { +- this._userVerifierChoiceList.run_dispose(); +- this._userVerifierChoiceList = null; +- } +- } ++ reset() { ++ this._authServicesLegacy?.reset(); ++ ++ this._userVerifier?.call_cancel_sync(null); ++ ++ this.clear(); ++ } ++ ++ 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() { +@@ -241,29 +196,14 @@ export class ShellUserVerifier extends Signals.EventEmitter { + + this._settings.run_dispose(); + this._settings = null; +- +- this._smartcardManager?.disconnectObject(this); +- this._smartcardManager = null; +- +- this._fingerprintManager?.disconnectObject(this); +- this._fingerprintManager = null; +- +- for (let service in this._credentialManagers) +- this.removeCredentialManager(service); + } + + selectChoice(serviceName, key) { +- this._userVerifierChoiceList.call_select_choice(serviceName, key, this._cancellable, null); ++ this._authServicesLegacy?.selectChoice(serviceName, key); + } + +- async answerQuery(serviceName, answer) { +- try { +- await this._handlePendingMessages(); +- this._userVerifier.call_answer_query(serviceName, answer, this._cancellable, null); +- } catch (e) { +- if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) +- logError(e); +- } ++ answerQuery(serviceName, answer) { ++ this._authServicesLegacy?.answerQuery(serviceName, answer); + } + + _getIntervalForMessage(message) { +@@ -276,7 +216,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + return message.length * USER_READ_TIME * messageTimeMultiplier; + } + +- finishMessageQueue() { ++ _finishMessageQueue() { + if (!this.hasPendingMessages) + return; + +@@ -319,7 +259,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + this._messageQueue.shift(); + this._queueMessageTimeout(); + } else { +- this.finishMessageQueue(); ++ this._finishMessageQueue(); + } + + return GLib.SOURCE_REMOVE; +@@ -349,7 +289,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + _clearMessageQueue() { +- this.finishMessageQueue(); ++ this._finishMessageQueue(); + + if (this._messageQueueTimeoutId !== 0) { + GLib.source_remove(this._messageQueueTimeoutId); +@@ -358,485 +298,142 @@ export class ShellUserVerifier extends Signals.EventEmitter { + this.emit('show-message', null, null, MessageType.NONE); + } + +- async _initFingerprintManager() { +- if (this._fingerprintManager) +- return; +- +- this._fingerprintManager = +- new FingerprintManager.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); +- } +- } +- +- _onFingerprintReaderTypeChanged() { +- this._fingerprintReaderType = this._fingerprintManager.readerType; +- this._fingerprintReaderFound = this._fingerprintManager.readerFound; +- this._updateDefaultService(); +- +- if (this._userVerifier && +- !this._activeServices.has(Const.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._updateDefaultService(); +- +- this._smartcardManager.connectObject( +- 'smartcard-inserted', () => this._checkForSmartcard(), +- 'smartcard-removed', () => this._checkForSmartcard(), 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; ++ _reportInitError(initError) { ++ const {error, where, serviceName} = initError; + +- if (this.smartcardDetected) +- this._preemptingService = Const.SMARTCARD_SERVICE_NAME; +- else if (this._preemptingService === Const.SMARTCARD_SERVICE_NAME) +- this._preemptingService = null; +- +- this._updateDefaultService(); +- +- this.emit('smartcard-status-changed'); +- } +- } +- +- _reportInitError(where, error, serviceName) { + logError(error, where); +- this._hold?.release(); + + 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) { ++ const proxies = {}; ++ ++ if (userName) { ++ try { ++ proxies.userVerifier = await this._client.open_reauthentication_channel( ++ userName, this._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(); ++ } ++ throw new InitError(e, 'Failed to open reauthentication channel'); ++ } ++ } else { ++ try { ++ proxies.userVerifier = await this._client.get_user_verifier( ++ this._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; +- } +- +- 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._userVerifier.connectObject( +- 'info', this._onInfo.bind(this), +- 'problem', this._onProblem.bind(this), +- 'info-query', this._onInfoQuery.bind(this), +- 'secret-info-query', this._onSecretInfoQuery.bind(this), +- 'conversation-started', this._onConversationStarted.bind(this), +- 'conversation-stopped', this._onConversationStopped.bind(this), +- 'service-unavailable', this._onServiceUnavailable.bind(this), +- 'reset', this._onReset.bind(this), +- 'verification-complete', this._onVerificationComplete.bind(this), +- this); +- +- if (this._userVerifierChoiceList) { +- this._userVerifierChoiceList.connectObject('choice-query', +- this._onChoiceListQuery.bind(this), this); +- } +- } +- +- _disconnectSignals() { +- this._userVerifier?.disconnectObject(this); +- this._userVerifierChoiceList?.disconnectObject(this); +- } +- +- _getForegroundService() { +- if (this._preemptingService) +- return this._preemptingService; +- +- return this._defaultService; +- } +- +- serviceIsForeground(serviceName) { +- return serviceName === this._getForegroundService(); +- } +- +- foregroundServiceDeterminesUsername() { +- for (let serviceName in this._credentialManagers) { +- if (this.serviceIsForeground(serviceName)) +- return true; ++ throw new InitError(e, 'Failed to obtain user verifier extensions'); + } + +- return this.serviceIsForeground(Const.SMARTCARD_SERVICE_NAME); +- } +- +- serviceIsDefault(serviceName) { +- return serviceName === this._defaultService; ++ return proxies; + } + + serviceIsFingerprint(serviceName) { +- return this._fingerprintReaderFound && +- serviceName === Const.FINGERPRINT_SERVICE_NAME; ++ return serviceName === Const.FINGERPRINT_SERVICE_NAME; + } + + _onSettingsChanged() { +- this._updateEnabledServices(); +- this._updateDefaultService(); +- } +- +- _updateEnabledServices() { +- let needsReset = false; +- +- 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(Const.FINGERPRINT_SERVICE_NAME)) +- needsReset = true; +- } +- +- if (this._settings.get_boolean(SMARTCARD_AUTHENTICATION_KEY)) { +- this._initSmartcardManager(); +- } else if (this._smartcardManager) { +- this._smartcardManager.disconnectObject(this); +- this._smartcardManager = null; +- +- if (this._activeServices.has(Const.SMARTCARD_SERVICE_NAME)) +- needsReset = true; +- } +- +- if (needsReset) +- this._cancelAndReset(); ++ this._updateAuthServices(); + } + +- _getDetectedDefaultService() { +- if (this._smartcardManager?.loggedInWithToken()) +- return Const.SMARTCARD_SERVICE_NAME; +- else if (this._settings.get_boolean(PASSWORD_AUTHENTICATION_KEY)) +- return Const.PASSWORD_SERVICE_NAME; +- else if (this._smartcardManager) +- return Const.SMARTCARD_SERVICE_NAME; +- else if (this._fingerprintReaderFound) +- return Const.FINGERPRINT_SERVICE_NAME; +- return null; +- } ++ _updateAuthServices() { ++ const enabledRoles = []; + +- _updateDefaultService() { +- const oldDefaultService = this._defaultService; +- this._defaultService = this._getDetectedDefaultService(); ++ if (this._settings.get_boolean(PASSWORD_AUTHENTICATION_KEY)) ++ enabledRoles.push(Const.PASSWORD_ROLE_NAME); ++ if (this._settings.get_boolean(SMARTCARD_AUTHENTICATION_KEY)) ++ enabledRoles.push(Const.SMARTCARD_ROLE_NAME); ++ if (this._settings.get_boolean(FINGERPRINT_AUTHENTICATION_KEY)) ++ enabledRoles.push(Const.FINGERPRINT_ROLE_NAME); + +- if (!this._defaultService) { +- log('no authentication service is enabled, using password authentication'); +- this._defaultService = Const.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 (serviceName == FINGERPRINT_SERVICE_NAME) +- this._fprintStartTime = GLib.get_monotonic_time(); +- +- 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 ${serviceName} for ${this._userName}`); +- this._hold?.release(); +- return; +- } +- this._reportInitError( +- this._userName +- ? `Failed to start ${serviceName} verification for user` +- : `Failed to start ${serviceName} verification`, +- e, serviceName); ++ if (JSON.stringify(enabledRoles) === JSON.stringify(this._enabledRoles)) + return; +- } +- this._hold?.release(); +- } + +- _beginVerification() { +- this._startService(this._getForegroundService()); +- this._maybeStartFingerprintVerification().catch(logError); +- } ++ this._enabledRoles = enabledRoles; + +- async _maybeStartFingerprintVerification() { +- if (this._userName && +- this._fingerprintReaderFound && +- !this.serviceIsForeground(Const.FINGERPRINT_SERVICE_NAME)) +- await this._startService(Const.FINGERPRINT_SERVICE_NAME); ++ this._createAuthServices(); + } + +- _onChoiceListQuery(client, serviceName, promptMessage, list) { +- if (!this.serviceIsForeground(serviceName)) +- return; +- +- const choiceList = Object.fromEntries( +- Object.entries(list.deepUnpack()) +- .map(([key, value]) => [key, {description: 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 === FingerprintManager.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); +- } +- } +- } +- +- _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, () => { +- log("Generating _verificationFailed!"); +- 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?.disconnectObject(this); ++ this._authServicesLegacy?.clear(); ++ 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(_client, serviceName) { +- const isCredentialManager = !!this._credentialManagers[serviceName]; +- const isForeground = this.serviceIsForeground(serviceName); +- if (isCredentialManager && isForeground) { +- this._credentialManagers[serviceName].token = null; +- this._preemptingService = null; +- } +- +- 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); + } + +- async _verificationFailed(serviceName, shouldRetry) { +- if (serviceName === Const.FINGERPRINT_SERVICE_NAME) { +- if (this._fingerprintFailedId) +- GLib.source_remove(this._fingerprintFailedId); +- +- // On Fedora we have the problem that fingerprint auth fails +- // immediately if the PAM configuration has not been updated and no +- // prints are enrolled. +- // So, consider a verification failure within one second to be a service +- // failure instead. +- if (this._fprintStartTime > GLib.get_monotonic_time() - GLib.USEC_PER_SEC) { +- log("Fingerprint service failed almost immediately, considering it unavailable."); +- log("Please fix your configuration by running: authselect select --force sssd with-fingerprint with-silent-lastlog"); +- this._onServiceUnavailable(this._client, serviceName, null); +- return; +- } +- } +- +- // 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(); +- +- this.emit('verification-failed', serviceName, !doneTrying); ++ async _waitPendingMessages(waiter) { + try { +- if (doneTrying) { +- this._disconnectSignals(); +- await this._handlePendingMessages(); +- this._cancelAndReset(); +- } else { +- await this._handlePendingMessages(); +- this._retry(serviceName); +- } ++ await this._handlePendingMessages(); ++ waiter.resolve(); + } catch (e) { +- if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) +- logError(e); ++ waiter.reject(e); + } + } + +@@ -855,47 +452,4 @@ export class ShellUserVerifier extends Signals.EventEmitter { + }); + }); + } +- +- _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. +- const isCredentialManager = !!this._credentialManagers[serviceName]; +- const isForeground = this.serviceIsForeground(serviceName); +- if (isCredentialManager && isForeground) { +- this._credentialManagers[serviceName].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 (isForeground) +- this._failCounter++; +- +- this._verificationFailed(serviceName, true); +- } + } +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index 11de814fbd..e7020fe013 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/const.js + gdm/credentialManager.js +diff --git a/js/misc/fingerprintManager.js b/js/misc/fingerprintManager.js +index 60b5163b48..cddc9f5425 100644 +--- a/js/misc/fingerprintManager.js ++++ b/js/misc/fingerprintManager.js +@@ -44,10 +44,6 @@ export class FingerprintManager extends Signals.EventEmitter { + return this._fingerprintReaderFound; + } + +- setDefaultTimeout(timeout) { +- this._fingerprintManagerProxy.set_default_timeout(timeout); +- } +- + async checkReaderType(cancellable) { + try { + // Wrappers don't support null cancellable, so let's ignore it in case +diff --git a/po/POTFILES.in b/po/POTFILES.in +index 8be425615d..782fed0d11 100644 +--- a/po/POTFILES.in ++++ b/po/POTFILES.in +@@ -12,6 +12,7 @@ js/dbusServices/extensions/extensionPrefsDialog.js + js/dbusServices/extensions/ui/extension-error-page.ui + js/gdm/authList.js + js/gdm/authPrompt.js ++js/gdm/authServicesLegacy.js + js/gdm/loginDialog.js + js/gdm/util.js + js/misc/breakManager.js +-- +2.51.1 + + +From e611f7d6ac07c91707d34301ccd21b68a52e908a Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 6 Feb 2024 14:09:34 -0500 +Subject: [PATCH 23/29] gdm: Add authServicesSwitchable + +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, it uses its role 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, it uses the selected mechanism role with + a switch to determine how to format the response and sends it using + _userVerifierCustomJson. +6. When the verification succeeds, the '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/authServicesSwitchable.js | 162 +++++++++++++++++++++++++++++++ + js/gdm/loginDialog.js | 5 +- + js/gdm/util.js | 43 ++++++-- + js/js-resources.gresource.xml | 1 + + js/ui/unlockDialog.js | 1 + + po/POTFILES.in | 1 + + 6 files changed, 205 insertions(+), 8 deletions(-) + create mode 100644 js/gdm/authServicesSwitchable.js + +diff --git a/js/gdm/authServicesSwitchable.js b/js/gdm/authServicesSwitchable.js +new file mode 100644 +index 0000000000..4913cb2f41 +--- /dev/null ++++ b/js/gdm/authServicesSwitchable.js +@@ -0,0 +1,162 @@ ++import GObject from 'gi://GObject'; ++ ++import * as Const from './const.js'; ++import * as Util from './util.js'; ++import {AuthServices} from './authServices.js'; ++ ++export const AuthServicesSwitchable = GObject.registerClass({ ++}, class AuthServicesSwitchable extends AuthServices { ++ static SupportedRoles = [ ++ Const.PASSWORD_ROLE_NAME, ++ ]; ++ ++ static RoleToService = { ++ [Const.PASSWORD_ROLE_NAME]: Const.SWITCHABLE_AUTH_SERVICE_NAME, ++ }; ++ ++ _init(params) { ++ super._init(params); ++ } ++ ++ _handleAnswerQuery(serviceName, answer) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ let response; ++ switch (this._selectedMechanism.role) { ++ case Const.PASSWORD_ROLE_NAME: ++ response = this._formatResponse(answer); ++ this._sendResponse(response); ++ break; ++ } ++ } ++ ++ _handleSelectMechanism() { ++ switch (this._selectedMechanism?.role) { ++ case Const.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: Const.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 === Const.SWITCHABLE_AUTH_SERVICE_NAME && ++ !this._enabledMechanisms; ++ } ++ ++ _formatResponse(answer) { ++ const {role, id} = this._selectedMechanism; ++ ++ let response; ++ switch (role) { ++ case Const.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, null); ++ } ++ ++ _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 f578f6c88a..df4c6ea0d0 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -451,7 +451,10 @@ export const 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 { + } + +diff --git a/js/gdm/util.js b/js/gdm/util.js +index f35a97b98b..d94f4de897 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -8,6 +8,7 @@ import * as Const from './const.js'; + import * as Main from '../ui/main.js'; + import * as Params from '../misc/params.js'; + import {AuthServicesLegacy} from './authServicesLegacy.js'; ++import {AuthServicesSwitchable} from './authServicesSwitchable.js'; + + const CLONE_FADE_ANIMATION_TIME = 250; + +@@ -15,6 +16,7 @@ export const LOGIN_SCREEN_SCHEMA = 'org.gnome.login-screen'; + export const PASSWORD_AUTHENTICATION_KEY = 'enable-password-authentication'; + export const FINGERPRINT_AUTHENTICATION_KEY = 'enable-fingerprint-authentication'; + export const SMARTCARD_AUTHENTICATION_KEY = 'enable-smartcard-authentication'; ++export const SWITCHABLE_AUTHENTICATION_KEY = 'enable-switchable-authentication'; + export const BANNER_MESSAGE_KEY = 'banner-message-enable'; + export const BANNER_MESSAGE_SOURCE_KEY = 'banner-message-source'; + export const BANNER_MESSAGE_TEXT_KEY = 'banner-message-text'; +@@ -146,6 +148,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + + try { + const proxies = await this._getUserVerifierProxies(userName); ++ await this._authServicesSwitchable?.beginVerification(userName, proxies); + await this._authServicesLegacy?.beginVerification(userName, proxies); + this._userVerifier = proxies.userVerifier; + } catch (e) { +@@ -157,14 +160,17 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + selectMechanism(mechanism) { +- return this._authServicesLegacy?.selectMechanism(mechanism); ++ return this._authServicesSwitchable?.selectMechanism(mechanism) || ++ this._authServicesLegacy?.selectMechanism(mechanism); + } + + needsUsername() { +- return this._authServicesLegacy?.needsUsername(); ++ return this._authServicesSwitchable?.needsUsername() || ++ this._authServicesLegacy?.needsUsername(); + } + + reset() { ++ this._authServicesSwitchable?.reset(); + this._authServicesLegacy?.reset(); + + this._userVerifier?.call_cancel_sync(null); +@@ -173,6 +179,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + cancel() { ++ this._authServicesSwitchable?.cancel(); + this._authServicesLegacy?.cancel(); + + this._userVerifier?.call_cancel_sync(null); +@@ -181,6 +188,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + clear() { ++ this._authServicesSwitchable?.clear(); + this._authServicesLegacy?.clear(); + + this._clearMessageQueue(); +@@ -199,10 +207,12 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + selectChoice(serviceName, key) { ++ this._authServicesSwitchable?.selectChoice(serviceName, key); + this._authServicesLegacy?.selectChoice(serviceName, key); + } + + answerQuery(serviceName, answer) { ++ this._authServicesSwitchable?.answerQuery(serviceName, answer); + this._authServicesLegacy?.answerQuery(serviceName, answer); + } + +@@ -367,10 +377,15 @@ export class ShellUserVerifier extends Signals.EventEmitter { + if (this._settings.get_boolean(FINGERPRINT_AUTHENTICATION_KEY)) + enabledRoles.push(Const.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(); + } +@@ -384,20 +399,30 @@ export class ShellUserVerifier extends Signals.EventEmitter { + allowedFailures: this.allowedFailures, + reauthOnly: this._reauthOnly, + }; +- if (AuthServicesLegacy.supportsAny(this._enabledRoles)) ++ if (this._switchableAuthenticationEnabled && ++ AuthServicesSwitchable.supportsAny(this._enabledRoles)) ++ this._authServicesSwitchable = new AuthServicesSwitchable(params); ++ else if (AuthServicesLegacy.supportsAny(this._enabledRoles)) + this._authServicesLegacy = new AuthServicesLegacy(params); + + this._connectAuthServices(); + } + + _clearAuthServices() { ++ this._authServicesSwitchable?.disconnectObject(this); ++ this._authServicesSwitchable?.clear(); ++ this._authServicesSwitchable = null; ++ + this._authServicesLegacy?.disconnectObject(this); + this._authServicesLegacy?.clear(); + this._authServicesLegacy = null; + } + + _connectAuthServices() { +- [this._authServicesLegacy].forEach(authServices => { ++ [ ++ this._authServicesSwitchable, ++ this._authServicesLegacy, ++ ].forEach(authServices => { + authServices?.connectObject( + 'ask-question', (_, ...args) => this.emit('ask-question', ...args), + 'queue-message', (_, ...args) => this._queueMessage(...args), +@@ -419,9 +444,13 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + _onMechanismsChanged() { +- const mechanisms = this._authServicesLegacy?.enabledMechanisms ?? []; ++ const mechanismsSwitchable = this._authServicesSwitchable?.enabledMechanisms ?? []; ++ const mechanismsLegacy = this._authServicesLegacy?.enabledMechanisms ?? []; ++ const mechanisms = [...mechanismsSwitchable, ...mechanismsLegacy]; + +- const selectedMechanism = this._authServicesLegacy?.selectedMechanism ?? ++ const selectedMechanism = ++ this._authServicesSwitchable?.selectedMechanism ?? ++ this._authServicesLegacy?.selectedMechanism ?? + mechanisms.find(m => isSelectable(m)) ?? + {}; + +diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml +index e7020fe013..ca4856e565 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/authServicesSwitchable.js + gdm/batch.js + gdm/const.js + gdm/credentialManager.js +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index 92a9f8fbbe..f1f5f84af2 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -568,6 +568,7 @@ export const UnlockDialog = GObject.registerClass({ + try { + this._gdmClient.set_enabled_extensions([ + Gdm.UserVerifierChoiceList.interface_info().name, ++ Gdm.UserVerifierCustomJSON.interface_info().name, + ]); + } catch { + } +diff --git a/po/POTFILES.in b/po/POTFILES.in +index 782fed0d11..6c4ed8012e 100644 +--- a/po/POTFILES.in ++++ b/po/POTFILES.in +@@ -13,6 +13,7 @@ js/dbusServices/extensions/ui/extension-error-page.ui + js/gdm/authList.js + js/gdm/authPrompt.js + js/gdm/authServicesLegacy.js ++js/gdm/authServicesSwitchable.js + js/gdm/loginDialog.js + js/gdm/util.js + js/misc/breakManager.js +-- +2.51.1 + + +From 5d0300a3eca7876d30e9eb56244cc4ce3f6f05d5 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Wed, 12 Nov 2025 17:25:33 +0100 +Subject: [PATCH 24/29] authServicesSwitchable: 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/authServicesSwitchable.js | 27 ++++++++++++++++++++++++++- + 1 file changed, 26 insertions(+), 1 deletion(-) + +diff --git a/js/gdm/authServicesSwitchable.js b/js/gdm/authServicesSwitchable.js +index 4913cb2f41..48a0be4433 100644 +--- a/js/gdm/authServicesSwitchable.js ++++ b/js/gdm/authServicesSwitchable.js +@@ -18,10 +18,19 @@ export const AuthServicesSwitchable = GObject.registerClass({ + super._init(params); + } + +- _handleAnswerQuery(serviceName, answer) { ++ async _handleAnswerQuery(serviceName, answer) { + if (serviceName !== this._selectedMechanism?.serviceName) + return; + ++ if (this._selectedMechanism.role === Const.PASSWORD_ROLE_NAME && ++ this._resettingPassword) { ++ await this._userVerifier.call_answer_query(serviceName, ++ answer, ++ this._cancellable, ++ null); ++ return; ++ } ++ + let response; + switch (this._selectedMechanism.role) { + case Const.PASSWORD_ROLE_NAME: +@@ -53,6 +62,8 @@ export const AuthServicesSwitchable = GObject.registerClass({ + this._priorityList = null; + this._enabledMechanisms = null; + this._selectedMechanism = null; ++ ++ this._resettingPassword = false; + } + + _handleOnCustomJSONRequest(_serviceName, _protocol, _version, json) { +@@ -97,6 +108,13 @@ export const AuthServicesSwitchable = GObject.registerClass({ + } + + _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 === Const.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); + } +@@ -110,6 +128,13 @@ export const AuthServicesSwitchable = GObject.registerClass({ + } + } + ++ _handleOnSecretInfoQuery(serviceName, secretQuestion) { ++ if (serviceName === this._selectedMechanism?.serviceName && ++ this._selectedMechanism.role === Const.PASSWORD_ROLE_NAME && ++ this._resettingPassword) ++ this.emit('ask-question', serviceName, secretQuestion, true); ++ } ++ + _handleOnConversationStopped(serviceName) { + if (serviceName !== this._selectedMechanism?.serviceName) + return; +-- +2.51.1 + + +From 0867f2249add126605bbe37127d1c83c820bf7c0 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Thu, 21 Aug 2025 22:25:02 +0200 +Subject: [PATCH 25/29] gdm: Allow starting authServicesLegacy as fallback + +There can be cases where authServicesSwitchable doesn't support some +authentication methods. +In such cases, we should start authServicesLegacy as a fallback for +those unsupported methods. + +authServicesSwitchable leads when the fallback will happen emitting +'mechanisms-changed'. Then unsupportedRoles, from authServicesSwitchable will +be used to update the enabledRoles in authServicesLegacy. + +While authServicesSwitchable hasn't started its service and didn't receive +any mechanisms from JSON protocol, all roles are considered unsupported. +If it receives any message from GDM userVerifier before receiving +'auth-selection' JSON message, that will mean that pam_unix is being used and +it should fallback to authServicesLegacy (all roles are unsupported). +--- + js/gdm/authServices.js | 22 ++++++++++++ + js/gdm/authServicesLegacy.js | 5 +++ + js/gdm/authServicesSwitchable.js | 62 ++++++++++++++++++++++++++++++-- + js/gdm/util.js | 24 +++++++++++-- + 4 files changed, 109 insertions(+), 4 deletions(-) + +diff --git a/js/gdm/authServices.js b/js/gdm/authServices.js +index 68a2c4c3f5..75fc0681d9 100644 +--- a/js/gdm/authServices.js ++++ b/js/gdm/authServices.js +@@ -91,6 +91,10 @@ export const AuthServices = GObject.registerClass({ + return this.constructor.SupportedRoles; + } + ++ get unsupportedRoles() { ++ return this._handleGetUnsupportedRoles(); ++ } ++ + selectChoice(serviceName, key) { + this._handleSelectChoice(serviceName, key); + } +@@ -166,6 +170,18 @@ export const AuthServices = GObject.registerClass({ + 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 const AuthServices = GObject.registerClass({ + } + } + ++ _handleGetUnsupportedRoles() { ++ return []; ++ } ++ + _handleSelectChoice() {} + + async _handleAnswerQuery() {} +@@ -427,6 +447,8 @@ export const AuthServices = GObject.registerClass({ + + _handleClear() {} + ++ _handleUpdateEnabledRoles() {} ++ + _handleUpdateEnabledMechanisms() {} + + _handleSmartcardChanged() {} +diff --git a/js/gdm/authServicesLegacy.js b/js/gdm/authServicesLegacy.js +index 93f55ec45d..594ffc09a0 100644 +--- a/js/gdm/authServicesLegacy.js ++++ b/js/gdm/authServicesLegacy.js +@@ -100,6 +100,11 @@ export const AuthServicesLegacy = GObject.registerClass({ + this._smartcardInProgress = false; + } + ++ _handleUpdateEnabledRoles() { ++ this._selectedMechanism = null; ++ this._updateEnabledMechanisms(); ++ } ++ + _handleUpdateEnabledMechanisms() { + if (!this._fingerprintManager?.readerFound) { + this._enabledMechanisms.push(...Mechanisms.filter(m => +diff --git a/js/gdm/authServicesSwitchable.js b/js/gdm/authServicesSwitchable.js +index 48a0be4433..e2be3d3133 100644 +--- a/js/gdm/authServicesSwitchable.js ++++ b/js/gdm/authServicesSwitchable.js +@@ -4,6 +4,12 @@ import * as Const from './const.js'; + import * as Util from './util.js'; + import {AuthServices} from './authServices.js'; + ++const MechanismsStatus = { ++ WAITING: 0, ++ NOT_FOUND: 1, ++ FOUND: 2, ++}; ++ + export const AuthServicesSwitchable = GObject.registerClass({ + }, class AuthServicesSwitchable extends AuthServices { + static SupportedRoles = [ +@@ -16,6 +22,8 @@ export const AuthServicesSwitchable = GObject.registerClass({ + + _init(params) { + super._init(params); ++ ++ this._mechanismsStatus = MechanismsStatus.WAITING; + } + + async _handleAnswerQuery(serviceName, answer) { +@@ -48,13 +56,30 @@ export const AuthServicesSwitchable = GObject.registerClass({ + } + } + ++ _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() { +@@ -84,6 +109,10 @@ export const AuthServicesSwitchable = GObject.registerClass({ + if (this._mechanisms) + this._updateEnabledMechanisms(); + } ++ ++ this._mechanismsStatus = authSelection ++ ? MechanismsStatus.FOUND ++ : MechanismsStatus.NOT_FOUND; + } + + _handleUpdateEnabledMechanisms() { +@@ -108,6 +137,9 @@ export const AuthServicesSwitchable = GObject.registerClass({ + } + + _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 && +@@ -120,6 +152,9 @@ export const AuthServicesSwitchable = GObject.registerClass({ + } + + _handleOnProblem(serviceName, problem) { ++ if (!this._eventExpected()) ++ return; ++ + if (serviceName === this._selectedMechanism?.serviceName) { + this.emit('queue-priority-message', + serviceName, +@@ -128,7 +163,16 @@ export const AuthServicesSwitchable = GObject.registerClass({ + } + } + ++ _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 === Const.PASSWORD_ROLE_NAME && + this._resettingPassword) +@@ -148,7 +192,7 @@ export const AuthServicesSwitchable = GObject.registerClass({ + + _handleCanStartService(serviceName) { + return serviceName === Const.SWITCHABLE_AUTH_SERVICE_NAME && +- !this._enabledMechanisms; ++ this._mechanismsStatus === MechanismsStatus.WAITING; + } + + _formatResponse(answer) { +@@ -179,6 +223,20 @@ export const AuthServicesSwitchable = GObject.registerClass({ + serviceName, JSON.stringify(response), this._cancellable, null); + } + ++ _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 d94f4de897..b088ef20ce 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -191,6 +191,11 @@ export class ShellUserVerifier extends Signals.EventEmitter { + this._authServicesSwitchable?.clear(); + this._authServicesLegacy?.clear(); + ++ if (this._authServicesSwitchable) { ++ this._authServicesLegacy?.updateEnabledRoles( ++ this._authServicesSwitchable.unsupportedRoles); ++ } ++ + this._clearMessageQueue(); + + this._cancellable?.cancel(); +@@ -400,10 +405,14 @@ export class ShellUserVerifier extends Signals.EventEmitter { + reauthOnly: this._reauthOnly, + }; + if (this._switchableAuthenticationEnabled && +- AuthServicesSwitchable.supportsAny(this._enabledRoles)) ++ AuthServicesSwitchable.supportsAny(this._enabledRoles)) { + this._authServicesSwitchable = new AuthServicesSwitchable(params); +- else if (AuthServicesLegacy.supportsAny(this._enabledRoles)) ++ ++ params.enabledRoles = this._authServicesSwitchable.unsupportedRoles; + this._authServicesLegacy = new AuthServicesLegacy(params); ++ } else if (AuthServicesLegacy.supportsAny(this._enabledRoles)) { ++ this._authServicesLegacy = new AuthServicesLegacy(params); ++ } + + this._connectAuthServices(); + } +@@ -444,6 +453,9 @@ export class ShellUserVerifier extends Signals.EventEmitter { + } + + _onMechanismsChanged() { ++ if (this._enableFallbackMechanisms()) ++ return; ++ + const mechanismsSwitchable = this._authServicesSwitchable?.enabledMechanisms ?? []; + const mechanismsLegacy = this._authServicesLegacy?.enabledMechanisms ?? []; + const mechanisms = [...mechanismsSwitchable, ...mechanismsLegacy]; +@@ -457,6 +469,14 @@ export class ShellUserVerifier extends Signals.EventEmitter { + this.emit('mechanisms-changed', mechanisms, selectedMechanism); + } + ++ _enableFallbackMechanisms() { ++ if (!this._authServicesSwitchable || !this._authServicesLegacy) ++ return false; ++ ++ return this._authServicesLegacy.updateEnabledRoles( ++ this._authServicesSwitchable.unsupportedRoles); ++ } ++ + async _waitPendingMessages(waiter) { + try { + await this._handlePendingMessages(); +-- +2.51.1 + + +From d3467ed0fec0c3350cf3050510fd8d2adeb34e12 Mon Sep 17 00:00:00 2001 +From: Ray Strode +Date: Tue, 6 Feb 2024 14:18:24 -0500 +Subject: [PATCH 26/29] gdm: Add support for Web Login in + authServicesSwitchable +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) +--- + .../gnome-shell-sass/widgets/_login-lock.scss | 100 ++++ + js/gdm/authPrompt.js | 102 +++- + js/gdm/authServices.js | 6 + + js/gdm/authServicesSwitchable.js | 82 ++++ + js/gdm/const.js | 1 + + js/gdm/loginDialog.js | 5 +- + js/gdm/util.js | 5 + + js/gdm/webLogin.js | 435 ++++++++++++++++++ + js/js-resources.gresource.xml | 1 + + js/misc/dependencies.js | 1 + + js/ui/unlockDialog.js | 3 +- + js/ui/userWidget.js | 8 + + po/POTFILES.in | 1 + + 13 files changed, 739 insertions(+), 11 deletions(-) + create mode 100644 js/gdm/webLogin.js + +diff --git a/data/theme/gnome-shell-sass/widgets/_login-lock.scss b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +index 58c2ed495c..c591edd940 100644 +--- a/data/theme/gnome-shell-sass/widgets/_login-lock.scss ++++ b/data/theme/gnome-shell-sass/widgets/_login-lock.scss +@@ -15,6 +15,10 @@ $_gdm_dialog_width: 25em; + .login-dialog-prompt-layout { + width: $_gdm_dialog_width; + spacing: $base_padding * 1.5; ++ ++ &.web-login-active { ++ width: $_gdm_dialog_width * 1.5; ++ } + } + + .login-dialog-prompt-entry-area { +@@ -320,8 +324,88 @@ $_gdm_dialog_width: 25em; + } + } + ++.web-login-spinner { ++ background-color: rgba(0, 0, 0, 0.5); ++ border: 5px rgba(0, 0, 0, 0.0); ++ border-radius: 50px; ++} ++ ++.web-login-title-label { ++ @include fontsize($base_font_size); ++ color: darken($_gdm_fg,30%); ++ text-align: center; ++} ++ ++.web-login-url-label { ++ @include fontsize($base_font_size); ++ @extend %monospace; ++ color: $_gdm_fg; ++ text-align: center; ++ ++ &.web-login-url-label-long { ++ @include fontsize($base_font_size - 2); ++ } ++} ++ ++.web-login-code-title-label { ++ @include fontsize($base_font_size); ++ color: $_gdm_fg; ++ text-align: center; ++} ++ ++.web-login-code-label { ++ @include fontsize($base_font_size); ++ color: $_gdm_fg; ++ font-weight: bold; ++ text-align: center; ++} ++ ++.web-login-prompt { ++ padding-top: $base_padding; ++ padding-bottom: $base_padding; ++ padding-left: $base_padding * 4.5; ++ padding-right: $base_padding * 4.5; ++ spacing: 1.75em; ++} ++ ++.web-login-button-label { ++ @include fontsize($base_font_size + 2); ++ color: $_gdm_fg; ++ min-width: 12em; ++ text-align: center; ++ font-weight: bold; ++} ++ ++@mixin web-login-buttons($context){ ++ .web-login-intro-button, ++ .web-login-prompt-button { ++ @if $context == 'login' { @include login_dialog_item_button(); } ++ @if $context == 'unlock' { @extend %lockscreen_button; } ++ border-radius: $base_border_radius * 4; ++ } ++ ++ .web-login-intro-button { ++ padding: 0; ++ } ++ ++ .web-login-prompt-button { ++ padding: $base_padding * 2.5 $base_padding * 4; ++ margin: $base_margin * 6 $base_margin * 2; ++ width: 8em; ++ } ++} ++ ++.login-dialog { ++ @include web-login-buttons(login); ++} ++ ++.unlock-dialog { ++ @include web-login-buttons(unlock); ++} ++ + // Screen Shield + // a.k.a. the lockscreen, uses transparent styles ++ + .unlock-dialog { + background-color: transparent; + +@@ -479,3 +563,19 @@ $_gdm_dialog_width: 25em; + } + } + } ++ ++// 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; ++ color: $fg_color; ++ } @else { ++ background-color: $_gdm_fg; ++ border-color: $_gdm_fg; ++ color: $_gdm_bg; ++ } ++} +diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js +index 91702c9b76..d26314e016 100644 +--- a/js/gdm/authPrompt.js ++++ b/js/gdm/authPrompt.js +@@ -14,6 +14,7 @@ import * as GdmUtil from './util.js'; + import * as Params from '../misc/params.js'; + import * as ShellEntry from '../ui/shellEntry.js'; + import * as UserWidget from '../ui/userWidget.js'; ++import * as WebLogin from './webLogin.js'; + import {wiggle} from '../misc/animationUtils.js'; + + const DEFAULT_BUTTON_WELL_ICON_SIZE = 16; +@@ -87,6 +88,7 @@ export const AuthPrompt = GObject.registerClass({ + this._userVerifier.connect('show-message', this._onShowMessage.bind(this)); + this._userVerifier.connect('show-choice-list', this._onShowChoiceList.bind(this)); + this._userVerifier.connect('mechanisms-changed', (_, ...args) => this.emit('mechanisms-changed', ...args)); ++ this._userVerifier.connect('web-login', this._onWebLogin.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)); +@@ -289,6 +291,28 @@ export const 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.add_child(this._webLoginDialog); + } + + _updateCancelButton() { +@@ -425,6 +449,51 @@ export const AuthPrompt = GObject.registerClass({ + this.emit('prompted'); + } + ++ _onWebLogin(_, 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.remove_style_class_name('web-login-active'); ++ } ++ ++ _openWebLoginDialog() { ++ this._userWell.get_child()?.hideAvatar(); ++ this._mainBox.hide(); ++ ++ this._webLoginDialog.update(this._webLoginParams); ++ if (!this._webLoginDialog.visible) ++ this._fadeInElement(this._webLoginDialog); ++ ++ this.add_style_class_name('web-login-active'); ++ } ++ + _onShowMessage(_userVerifier, serviceName, message, type) { + let wiggleParameters = {duration: 0}; + +@@ -463,12 +532,14 @@ export const AuthPrompt = GObject.registerClass({ + this.stopSpinning(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'); +@@ -540,6 +611,9 @@ export const AuthPrompt = GObject.registerClass({ + stopSpinning(animate) { + this.emit('loading', false); + this.setActorInDefaultButtonWell(this._nextButton, animate); ++ ++ if (this._webLoginDialog.isLoading) ++ this._webLoginDialog.stopLoading(); + } + + clear() { +@@ -550,10 +624,14 @@ export const 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) { +@@ -565,6 +643,8 @@ export const AuthPrompt = GObject.registerClass({ + this._entry.hint_text = question; + + this._authList.hide(); ++ this._webLoginIntro.hide(); ++ this._closeWebLoginDialog(); + + this._fadeInElement(this._entryArea); + } +@@ -650,6 +730,10 @@ export const AuthPrompt = GObject.registerClass({ + + if (this._authList.visible) + authWidget = this._authList; ++ else if (this._webLoginIntro.visible) ++ authWidget = this._webLoginIntro; ++ else if (this._webLoginDialog.visible) ++ authWidget = this._webLoginDialog; + else + authWidget = this._entry; + +diff --git a/js/gdm/authServices.js b/js/gdm/authServices.js +index 75fc0681d9..066276b786 100644 +--- a/js/gdm/authServices.js ++++ b/js/gdm/authServices.js +@@ -43,6 +43,12 @@ export const AuthServices = GObject.registerClass({ + 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, ++ ], ++ }, + }, + }, class AuthServices extends GObject.Object { + static SupportedRoles = []; +diff --git a/js/gdm/authServicesSwitchable.js b/js/gdm/authServicesSwitchable.js +index e2be3d3133..d13020e642 100644 +--- a/js/gdm/authServicesSwitchable.js ++++ b/js/gdm/authServicesSwitchable.js +@@ -1,3 +1,4 @@ ++import GLib from 'gi://GLib'; + import GObject from 'gi://GObject'; + + import * as Const from './const.js'; +@@ -14,10 +15,12 @@ export const AuthServicesSwitchable = GObject.registerClass({ + }, class AuthServicesSwitchable extends AuthServices { + static SupportedRoles = [ + Const.PASSWORD_ROLE_NAME, ++ Const.WEB_LOGIN_ROLE_NAME, + ]; + + static RoleToService = { + [Const.PASSWORD_ROLE_NAME]: Const.SWITCHABLE_AUTH_SERVICE_NAME, ++ [Const.WEB_LOGIN_ROLE_NAME]: Const.SWITCHABLE_AUTH_SERVICE_NAME, + }; + + _init(params) { +@@ -53,6 +56,9 @@ export const AuthServicesSwitchable = GObject.registerClass({ + case Const.PASSWORD_ROLE_NAME: + this._startPasswordLogin(); + break; ++ case Const.WEB_LOGIN_ROLE_NAME: ++ this._startWebLogin(); ++ break; + } + } + +@@ -89,6 +95,8 @@ export const AuthServicesSwitchable = GObject.registerClass({ + this._selectedMechanism = null; + + this._resettingPassword = false; ++ ++ this._clearWebLoginTimeout(); + } + + _handleOnCustomJSONRequest(_serviceName, _protocol, _version, json) { +@@ -125,6 +133,8 @@ export const AuthServicesSwitchable = GObject.registerClass({ + // 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) ?? +@@ -136,6 +146,31 @@ export const AuthServicesSwitchable = GObject.registerClass({ + this._savedMechanism = null; + } + ++ _trackWebLoginTimeout() { ++ this._clearWebLoginTimeout(); ++ ++ const webLoginMechanism = this._enabledMechanisms ++ .find(m => m.role === Const.WEB_LOGIN_ROLE_NAME); ++ if (!webLoginMechanism) ++ return; ++ ++ const {timeout} = webLoginMechanism; ++ if (!timeout) ++ return; ++ ++ this._webLoginTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, ++ timeout, () => { ++ if (this._selectedMechanism?.role !== Const.WEB_LOGIN_ROLE_NAME) ++ webLoginMechanism.needsRefresh = true; ++ else ++ this.emit('reset', {softReset: true}); ++ ++ this._webLoginTimeoutId = 0; ++ ++ return GLib.SOURCE_REMOVE; ++ }); ++ } ++ + _handleOnInfo(serviceName, info) { + if (!this._eventExpected()) + return; +@@ -204,6 +239,10 @@ export const AuthServicesSwitchable = GObject.registerClass({ + response = {password: answer}; + break; + } ++ case Const.WEB_LOGIN_ROLE_NAME: { ++ response = {}; ++ break; ++ } + default: + throw new GObject.NotImplementedError(`formatResponse: ${role}`); + } +@@ -242,4 +281,47 @@ export const AuthServicesSwitchable = GObject.registerClass({ + + 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 !== Const.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/const.js b/js/gdm/const.js +index 2f37446c8a..e8ca48625a 100644 +--- a/js/gdm/const.js ++++ b/js/gdm/const.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 df4c6ea0d0..02c1d1fb38 100644 +--- a/js/gdm/loginDialog.js ++++ b/js/gdm/loginDialog.js +@@ -742,7 +742,10 @@ export const LoginDialog = GObject.registerClass({ + let authPromptAllocation = null; + let authPromptWidth = 0; + if (this._authPrompt.visible) { +- authPromptAllocation = this._getFixedTopActorAllocation(dialogBox, this._authPrompt); ++ if (this._authPrompt.has_style_class_name('web-login-active')) ++ 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 b088ef20ce..7c54009763 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -17,6 +17,7 @@ export const PASSWORD_AUTHENTICATION_KEY = 'enable-password-authentication'; + export const FINGERPRINT_AUTHENTICATION_KEY = 'enable-fingerprint-authentication'; + export const SMARTCARD_AUTHENTICATION_KEY = 'enable-smartcard-authentication'; + export const SWITCHABLE_AUTHENTICATION_KEY = 'enable-switchable-authentication'; ++export const WEB_AUTHENTICATION_KEY = 'enable-web-authentication'; + export const BANNER_MESSAGE_KEY = 'banner-message-enable'; + export const BANNER_MESSAGE_SOURCE_KEY = 'banner-message-source'; + export const BANNER_MESSAGE_TEXT_KEY = 'banner-message-text'; +@@ -91,6 +92,7 @@ export function isSelectable(mechanism) { + switch (mechanism.role) { + case Const.PASSWORD_ROLE_NAME: + case Const.SMARTCARD_ROLE_NAME: ++ case Const.WEB_LOGIN_ROLE_NAME: + return true; + case Const.FINGERPRINT_ROLE_NAME: + return false; +@@ -381,6 +383,8 @@ export class ShellUserVerifier extends Signals.EventEmitter { + enabledRoles.push(Const.SMARTCARD_ROLE_NAME); + if (this._settings.get_boolean(FINGERPRINT_AUTHENTICATION_KEY)) + enabledRoles.push(Const.FINGERPRINT_ROLE_NAME); ++ if (this._settings.get_boolean(WEB_AUTHENTICATION_KEY)) ++ enabledRoles.push(Const.WEB_LOGIN_ROLE_NAME); + + const switchableAuthentication = + this._settings.get_boolean(SWITCHABLE_AUTHENTICATION_KEY); +@@ -443,6 +447,7 @@ export class ShellUserVerifier extends Signals.EventEmitter { + '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..2da29582f3 +--- /dev/null ++++ b/js/gdm/webLogin.js +@@ -0,0 +1,435 @@ ++// -*- 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 Cogl from 'gi://Cogl'; ++import GObject from 'gi://GObject'; ++import Gio from 'gi://Gio'; ++import GnomeQR from 'gi://GnomeQR'; ++import Pango from 'gi://Pango'; ++import St from 'gi://St'; ++import {Spinner} from '../ui/animation.js'; ++import * as Params from '../misc/params.js'; ++import {logErrorUnlessCancelled} from '../misc/errorUtils.js'; ++ ++Gio._promisify(GnomeQR, 'generate_qr_code_async'); ++ ++const QR_CODE_SIZE = 150; ++const WEB_LOGIN_SPINNER_SIZE = 35; ++ ++export const QrCode = GObject.registerClass( ++class QrCode extends St.Bin { ++ _init(params) { ++ const themeContext = St.ThemeContext.get_for_stage(global.stage); ++ const {iconSize, url} = Params.parse(params, { ++ iconSize: QR_CODE_SIZE, ++ url: null, ++ }); ++ ++ super._init({ ++ width: QR_CODE_SIZE, ++ height: QR_CODE_SIZE, ++ x_align: Clutter.ActorAlign.CENTER, ++ }); ++ ++ this._bgColor = null; ++ this._fgColor = null; ++ ++ this._iconSize = iconSize; ++ this._url = url; ++ this.child = new St.Icon({ ++ icon_size: this._iconSize, ++ style_class: 'qr-code', ++ }); ++ ++ themeContext.connectObject('notify::scale-factor', ++ () => this.update(), this); ++ } ++ ++ vfunc_style_changed() { ++ super.vfunc_style_changed(); ++ ++ let changed = false; ++ const node = this.child.get_theme_node(); ++ const [found, iconSize] = node.lookup_length('icon-size', false); ++ ++ if (found) { ++ const themeContext = St.ThemeContext.get_for_stage(global.stage); ++ const newIconSize = iconSize / themeContext.scaleFactor; ++ if (this._iconSize !== newIconSize) { ++ this._iconSize = newIconSize; ++ changed = true; ++ } ++ } ++ ++ const bgColor = node.get_background_color(); ++ const fgColor = node.get_foreground_color(); ++ ++ if (!this._bgColor?.equal(bgColor)) { ++ this._bgColor = bgColor; ++ changed = true; ++ } ++ ++ if (!this._fgColor?.equal(fgColor)) { ++ this._fgColor = fgColor; ++ changed = true; ++ } ++ ++ if (!changed) ++ return; ++ ++ this.update(); ++ } ++ ++ setUrl(url) { ++ if (this._url === url) ++ return; ++ ++ this._url = url; ++ this.update(); ++ } ++ ++ async update() { ++ const {scaleFactor} = St.ThemeContext.get_for_stage(global.stage); ++ const iconSize = this._iconSize * scaleFactor; ++ const bgColor = this._getGnomeQRColor(this._bgColor); ++ const fgColor = this._getGnomeQRColor(this._fgColor); ++ ++ this._cancellable?.cancel(); ++ this._cancellable = new Gio.Cancellable(); ++ ++ try { ++ const [pixelData, qrSize] = await GnomeQR.generate_qr_code_async( ++ this._url, ++ iconSize, ++ bgColor, ++ fgColor, ++ GnomeQR.PixelFormat.RGBA_8888, ++ GnomeQR.EccLevel.LOW, ++ this._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.set_size(qrSize, qrSize); ++ this.child.gicon = content; ++ } catch (e) { ++ logErrorUnlessCancelled(e, 'Failed to generate QR code'); ++ } ++ } ++ ++ _getGnomeQRColor(color) { ++ if (!color) ++ return null; ++ ++ return new GnomeQR.Color({ ++ red: color.red, ++ green: color.green, ++ blue: color.blue, ++ alpha: color.alpha, ++ }); ++ } ++}); ++ ++export const WebLoginPrompt = GObject.registerClass( ++class WebLoginPrompt extends St.BoxLayout { ++ _init(params) { ++ const {iconSize, message, url, code} = Params.parse(params, { ++ iconSize: QR_CODE_SIZE, ++ message: null, ++ url: null, ++ code: null, ++ }); ++ ++ super._init({ ++ 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({iconSize, url: null}); ++ 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, ++ }); ++ ++ 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.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.setUrl(url); ++ ++ const formattedUrl = this._formatURLForDisplay(url); ++ this._urlLabel.text = formattedUrl; ++ ++ if (formattedUrl.length > 45) ++ 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; ++ if (!this._codeBox.get_parent()) ++ this.add_child(this._codeBox); ++ } else { ++ // eslint-disable-next-line no-lonely-if ++ if (this._codeBox.get_parent()) ++ this.remove_child(this._codeBox); ++ } ++ } ++ ++ _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 { ++ _init(params) { ++ const {message, url, code, buttons} = Params.parse(params, { ++ message: null, ++ url: null, ++ code: null, ++ buttons: [], ++ }); ++ ++ super._init({ ++ 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.get_children().forEach(b => b.disconnectObject(this)); ++ this._buttonBox.remove_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 { ++ _init(params) { ++ const {message} = Params.parse(params, { ++ message: null, ++ }); ++ ++ const label = new St.Label({ ++ text: message, ++ style_class: 'web-login-button-label', ++ }); ++ ++ super._init({ ++ 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 ca4856e565..0bdbbd8809 100644 +--- a/js/js-resources.gresource.xml ++++ b/js/js-resources.gresource.xml +@@ -15,6 +15,7 @@ + gdm/realmd.js + gdm/util.js + gdm/vmware.js ++ gdm/webLogin.js + + extensions/extension.js + extensions/sharedInternals.js +diff --git a/js/misc/dependencies.js b/js/misc/dependencies.js +index c3c51aa2de..fbb83d1164 100644 +--- a/js/misc/dependencies.js ++++ b/js/misc/dependencies.js +@@ -16,6 +16,7 @@ import 'gi://GioUnix?version=2.0'; + import 'gi://GDesktopEnums?version=3.0'; + import 'gi://GdkPixbuf?version=2.0'; + import 'gi://GnomeBG?version=4.0'; ++import 'gi://GnomeQR?version=4.0'; + import 'gi://GnomeDesktop?version=4.0'; + import 'gi://Graphene?version=1.0'; + import 'gi://GUdev?version=1.0'; +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index f1f5f84af2..428104e67b 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -492,7 +492,8 @@ class UnlockDialogLayout extends Clutter.LayoutManager { + + // Authentication Box + let stackY; +- if (this._activePage === this._clock) { ++ if (this._activePage === this._clock || ++ this._authPrompt?.has_style_class_name('web-login-active')) { + 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 0331a1d328..e0ffd32278 100644 +--- a/js/ui/userWidget.js ++++ b/js/ui/userWidget.js +@@ -215,4 +215,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 6c4ed8012e..12001308f3 100644 +--- a/po/POTFILES.in ++++ b/po/POTFILES.in +@@ -16,6 +16,7 @@ js/gdm/authServicesLegacy.js + js/gdm/authServicesSwitchable.js + js/gdm/loginDialog.js + js/gdm/util.js ++js/gdm/webLogin.js + js/misc/breakManager.js + js/misc/brightnessManager.js + js/misc/systemActions.js +-- +2.51.1 + + +From 00968e0004247e44f4bccf1ac0b0fb3a1929fcfb Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Tue, 14 Jan 2025 07:31:59 -0500 +Subject: [PATCH 27/29] gdm: Add support for Smartcard in + authServicesSwitchable + +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. To allow smartcard mechanism, if it's enabled in GDM, add an empty +smartcard mechanism for every user. + +When a smartcard is inserted, authServicesSwitchable 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/authServicesSwitchable.js | 64 ++++++++++++++++++++++++++++++++ + 1 file changed, 64 insertions(+) + +diff --git a/js/gdm/authServicesSwitchable.js b/js/gdm/authServicesSwitchable.js +index d13020e642..b99128dc15 100644 +--- a/js/gdm/authServicesSwitchable.js ++++ b/js/gdm/authServicesSwitchable.js +@@ -15,11 +15,13 @@ export const AuthServicesSwitchable = GObject.registerClass({ + }, class AuthServicesSwitchable extends AuthServices { + static SupportedRoles = [ + Const.PASSWORD_ROLE_NAME, ++ Const.SMARTCARD_ROLE_NAME, + Const.WEB_LOGIN_ROLE_NAME, + ]; + + static RoleToService = { + [Const.PASSWORD_ROLE_NAME]: Const.SWITCHABLE_AUTH_SERVICE_NAME, ++ [Const.SMARTCARD_ROLE_NAME]: Const.SWITCHABLE_AUTH_SERVICE_NAME, + [Const.WEB_LOGIN_ROLE_NAME]: Const.SWITCHABLE_AUTH_SERVICE_NAME, + }; + +@@ -29,6 +31,18 @@ export const AuthServicesSwitchable = GObject.registerClass({ + this._mechanismsStatus = MechanismsStatus.WAITING; + } + ++ _handleSelectChoice(serviceName, key) { ++ if (serviceName !== this._selectedMechanism?.serviceName) ++ return; ++ ++ if (this._selectedMechanism.role === Const.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; +@@ -45,6 +59,7 @@ export const AuthServicesSwitchable = GObject.registerClass({ + let response; + switch (this._selectedMechanism.role) { + case Const.PASSWORD_ROLE_NAME: ++ case Const.SMARTCARD_ROLE_NAME: + response = this._formatResponse(answer); + this._sendResponse(response); + break; +@@ -56,6 +71,9 @@ export const AuthServicesSwitchable = GObject.registerClass({ + case Const.PASSWORD_ROLE_NAME: + this._startPasswordLogin(); + break; ++ case Const.SMARTCARD_ROLE_NAME: ++ this._startSmartcardLogin(); ++ break; + case Const.WEB_LOGIN_ROLE_NAME: + this._startWebLogin(); + break; +@@ -93,6 +111,7 @@ export const AuthServicesSwitchable = GObject.registerClass({ + this._priorityList = null; + this._enabledMechanisms = null; + this._selectedMechanism = null; ++ this._selectedSmartcard = null; + + this._resettingPassword = false; + +@@ -171,6 +190,14 @@ export const AuthServicesSwitchable = GObject.registerClass({ + }); + } + ++ _handleSmartcardChanged() { ++ if (!this._selectedMechanism || ++ !this._enabledMechanisms.some(({role}) => role === Const.SMARTCARD_ROLE_NAME)) ++ return; ++ ++ this.emit('reset', {softReset: true, reuseEntryText: true}); ++ } ++ + _handleOnInfo(serviceName, info) { + if (!this._eventExpected()) + return; +@@ -239,6 +266,11 @@ export const AuthServicesSwitchable = GObject.registerClass({ + response = {password: answer}; + break; + } ++ case Const.SMARTCARD_ROLE_NAME: { ++ const {tokenName, moduleName, keyId, label} = this._selectedSmartcard; ++ response = {pin: answer, tokenName, moduleName, keyId, label}; ++ break; ++ } + case Const.WEB_LOGIN_ROLE_NAME: { + response = {}; + break; +@@ -282,6 +314,38 @@ export const AuthServicesSwitchable = GObject.registerClass({ + 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 {description, commonName, organization}; ++ } ++ + _startWebLogin() { + const { + serviceName, +-- +2.51.1 + + +From c6ab4497f8b6c2b0fafee864629868ee027af2d7 Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Mon, 15 Sep 2025 16:36:42 +0200 +Subject: [PATCH 28/29] gdm: Add support for Passkey in authServicesSwitchable + +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/authServicesSwitchable.js | 46 +++++++++++++++++++++++++++++++- + js/gdm/const.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 066276b786..2c5f96ff57 100644 +--- a/js/gdm/authServices.js ++++ b/js/gdm/authServices.js +@@ -2,6 +2,7 @@ + + import * as FingerprintManager from '../misc/fingerprintManager.js'; + import * as Params from '../misc/params.js'; ++import * as PasskeyManager from '../misc/passkeyManager.js'; + import * as SmartcardManager from '../misc/smartcardManager.js'; + import {logErrorUnlessCancelled} from '../misc/errorUtils.js'; + import * as Util from './util.js'; +@@ -78,6 +79,7 @@ export const AuthServices = GObject.registerClass({ + this._cancellable = null; + + this._connectSmartcardManager(); ++ this._connectPasskeyManager(); + this._connectFingerprintManager(); + } + +@@ -218,6 +220,14 @@ export const AuthServices = GObject.registerClass({ + this); + } + ++ _connectPasskeyManager() { ++ this._passkeyManager = PasskeyManager.getPasskeyManager(); ++ this._passkeyManager.connectObject( ++ 'passkey-inserted', () => this._handlePasskeyChanged(), ++ 'passkey-removed', () => this._handlePasskeyChanged(), ++ this); ++ } ++ + _connectFingerprintManager() { + // Fingerprint can only work on lockscreen + if (!this._reauthOnly) +@@ -459,6 +469,8 @@ export const AuthServices = GObject.registerClass({ + + _handleSmartcardChanged() {} + ++ _handlePasskeyChanged() {} ++ + _handleFingerprintChanged() {} + + _handleOnInfo() {} +diff --git a/js/gdm/authServicesSwitchable.js b/js/gdm/authServicesSwitchable.js +index b99128dc15..eb90ad7ece 100644 +--- a/js/gdm/authServicesSwitchable.js ++++ b/js/gdm/authServicesSwitchable.js +@@ -16,12 +16,14 @@ export const AuthServicesSwitchable = GObject.registerClass({ + static SupportedRoles = [ + Const.PASSWORD_ROLE_NAME, + Const.SMARTCARD_ROLE_NAME, ++ Const.PASSKEY_ROLE_NAME, + Const.WEB_LOGIN_ROLE_NAME, + ]; + + static RoleToService = { + [Const.PASSWORD_ROLE_NAME]: Const.SWITCHABLE_AUTH_SERVICE_NAME, + [Const.SMARTCARD_ROLE_NAME]: Const.SWITCHABLE_AUTH_SERVICE_NAME, ++ [Const.PASSKEY_ROLE_NAME]: Const.SWITCHABLE_AUTH_SERVICE_NAME, + [Const.WEB_LOGIN_ROLE_NAME]: Const.SWITCHABLE_AUTH_SERVICE_NAME, + }; + +@@ -63,6 +65,13 @@ export const AuthServicesSwitchable = GObject.registerClass({ + response = this._formatResponse(answer); + this._sendResponse(response); + break; ++ case Const.PASSKEY_ROLE_NAME: ++ response = this._formatResponse(answer); ++ this._sendResponse(response); ++ ++ this.emit('show-choice-list', serviceName, ++ this._selectedMechanism.touchInstruction, {}); ++ break; + } + } + +@@ -74,6 +83,9 @@ export const AuthServicesSwitchable = GObject.registerClass({ + case Const.SMARTCARD_ROLE_NAME: + this._startSmartcardLogin(); + break; ++ case Const.PASSKEY_ROLE_NAME: ++ this._startPasskeyLogin(); ++ break; + case Const.WEB_LOGIN_ROLE_NAME: + this._startWebLogin(); + break; +@@ -198,6 +210,14 @@ export const AuthServicesSwitchable = GObject.registerClass({ + this.emit('reset', {softReset: true, reuseEntryText: true}); + } + ++ _handlePasskeyChanged() { ++ if (!this._selectedMechanism || ++ !this._enabledMechanisms.some(({role}) => role === Const.PASSKEY_ROLE_NAME)) ++ return; ++ ++ this.emit('reset', {softReset: true, reuseEntryText: true}); ++ } ++ + _handleOnInfo(serviceName, info) { + if (!this._eventExpected()) + return; +@@ -258,7 +278,7 @@ export const AuthServicesSwitchable = GObject.registerClass({ + } + + _formatResponse(answer) { +- const {role, id} = this._selectedMechanism; ++ const {role, id, kerberos, cryptoChallenge} = this._selectedMechanism; + + let response; + switch (role) { +@@ -271,6 +291,10 @@ export const AuthServicesSwitchable = GObject.registerClass({ + response = {pin: answer, tokenName, moduleName, keyId, label}; + break; + } ++ case Const.PASSKEY_ROLE_NAME: { ++ response = {pin: answer, kerberos, cryptoChallenge}; ++ break; ++ } + case Const.WEB_LOGIN_ROLE_NAME: { + response = {}; + break; +@@ -346,6 +370,26 @@ export const AuthServicesSwitchable = GObject.registerClass({ + return {description, commonName, organization}; + } + ++ _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/const.js b/js/gdm/const.js +index e8ca48625a..7c48599e64 100644 +--- a/js/gdm/const.js ++++ b/js/gdm/const.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 7c54009763..8b999c363f 100644 +--- a/js/gdm/util.js ++++ b/js/gdm/util.js +@@ -16,6 +16,7 @@ export const LOGIN_SCREEN_SCHEMA = 'org.gnome.login-screen'; + export const PASSWORD_AUTHENTICATION_KEY = 'enable-password-authentication'; + export const FINGERPRINT_AUTHENTICATION_KEY = 'enable-fingerprint-authentication'; + export const SMARTCARD_AUTHENTICATION_KEY = 'enable-smartcard-authentication'; ++export const PASSKEY_AUTHENTICATION_KEY = 'enable-passkey-authentication'; + export const SWITCHABLE_AUTHENTICATION_KEY = 'enable-switchable-authentication'; + export const WEB_AUTHENTICATION_KEY = 'enable-web-authentication'; + export const BANNER_MESSAGE_KEY = 'banner-message-enable'; +@@ -92,6 +93,7 @@ export function isSelectable(mechanism) { + switch (mechanism.role) { + case Const.PASSWORD_ROLE_NAME: + case Const.SMARTCARD_ROLE_NAME: ++ case Const.PASSKEY_ROLE_NAME: + case Const.WEB_LOGIN_ROLE_NAME: + return true; + case Const.FINGERPRINT_ROLE_NAME: +@@ -381,6 +383,8 @@ export class ShellUserVerifier extends Signals.EventEmitter { + enabledRoles.push(Const.PASSWORD_ROLE_NAME); + if (this._settings.get_boolean(SMARTCARD_AUTHENTICATION_KEY)) + enabledRoles.push(Const.SMARTCARD_ROLE_NAME); ++ if (this._settings.get_boolean(PASSKEY_AUTHENTICATION_KEY)) ++ enabledRoles.push(Const.PASSKEY_ROLE_NAME); + if (this._settings.get_boolean(FINGERPRINT_AUTHENTICATION_KEY)) + enabledRoles.push(Const.FINGERPRINT_ROLE_NAME); + if (this._settings.get_boolean(WEB_AUTHENTICATION_KEY)) +diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js +index 428104e67b..7ba379f7c7 100644 +--- a/js/ui/unlockDialog.js ++++ b/js/ui/unlockDialog.js +@@ -426,6 +426,8 @@ class UnlockDialogClock extends St.BoxLayout { + + if (authMechanism?.role === GdmConst.SMARTCARD_ROLE_NAME) + text = _('Insert smartcard'); ++ else if (authMechanism?.role === GdmConst.PASSKEY_ROLE_NAME) ++ text = _('Insert security key'); + else if (this._seat.touch_mode) + text = _('Swipe up'); + else +-- +2.51.1 + + +From 508d07a0ee495a6e31373859cd4a7c2b288f0e7c Mon Sep 17 00:00:00 2001 +From: Joan Torres Lopez +Date: Wed, 24 Dec 2025 11:28:39 +0100 +Subject: [PATCH 29/29] gdm: Add extra fix remove logErrorUnlessCancelled + +--- + js/gdm/authServices.js | 7 ++++--- + js/gdm/webLogin.js | 4 ++-- + 2 files changed, 6 insertions(+), 5 deletions(-) + +diff --git a/js/gdm/authServices.js b/js/gdm/authServices.js +index 2c5f96ff57..7102e76149 100644 +--- a/js/gdm/authServices.js ++++ b/js/gdm/authServices.js +@@ -4,7 +4,6 @@ import * as FingerprintManager from '../misc/fingerprintManager.js'; + import * as Params from '../misc/params.js'; + import * as PasskeyManager from '../misc/passkeyManager.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'; +@@ -112,7 +111,8 @@ export const AuthServices = GObject.registerClass({ + await this._waitPendingMessages(); + await this._handleAnswerQuery(serviceName, answer); + } catch (e) { +- logErrorUnlessCancelled(e); ++ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) ++ logError(e); + } + } + +@@ -381,7 +381,8 @@ export const AuthServices = GObject.registerClass({ + await this._waitPendingMessages(); + this.emit('reset', {softReset: !doneTrying}); + } catch (e) { +- logErrorUnlessCancelled(e); ++ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) ++ logError(e); + } + } + +diff --git a/js/gdm/webLogin.js b/js/gdm/webLogin.js +index 2da29582f3..5a864587dc 100644 +--- a/js/gdm/webLogin.js ++++ b/js/gdm/webLogin.js +@@ -12,7 +12,6 @@ import Pango from 'gi://Pango'; + import St from 'gi://St'; + import {Spinner} from '../ui/animation.js'; + import * as Params from '../misc/params.js'; +-import {logErrorUnlessCancelled} from '../misc/errorUtils.js'; + + Gio._promisify(GnomeQR, 'generate_qr_code_async'); + +@@ -126,7 +125,8 @@ class QrCode extends St.Bin { + this.set_size(qrSize, qrSize); + this.child.gicon = content; + } catch (e) { +- logErrorUnlessCancelled(e, 'Failed to generate QR code'); ++ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) ++ logError(e); + } + } + +-- +2.51.1 + diff --git a/gnome-shell.spec b/gnome-shell.spec index e102d9f..f80d3e3 100644 --- a/gnome-shell.spec +++ b/gnome-shell.spec @@ -51,6 +51,9 @@ Patch: 0001-screenShield-unblank-when-inserting-smartcard.patch Patch: enforce-smartcard-at-unlock.patch Patch: disable-unlock-entry-until-question.patch Patch: 0001-main-Register-session-with-GDM-on-startup.patch +# Passwordless work +# https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3212 +Patch: 0001-Support-for-web-login-and-unified-auth-mechanism.patch # Extensions Patch: 0001-extensionDownloader-Refuse-to-override-system-extens.patch