// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- import Clutter from 'gi://Clutter'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Pango from 'gi://Pango'; import Shell from 'gi://Shell'; 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 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 {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; /** @enum {number} */ export const AuthPromptMode = { UNLOCK_ONLY: 0, UNLOCK_OR_LOG_IN: 1, }; /** @enum {number} */ export const AuthPromptStatus = { NOT_VERIFYING: 0, VERIFYING: 1, VERIFICATION_FAILED: 2, VERIFICATION_SUCCEEDED: 3, VERIFICATION_CANCELLED: 4, VERIFICATION_IN_PROGRESS: 5, }; /** @enum {number} */ export const BeginRequestType = { PROVIDE_USERNAME: 0, DONT_PROVIDE_USERNAME: 1, REUSE_USERNAME: 2, }; export const AuthPrompt = GObject.registerClass({ Signals: { 'cancelled': {}, 'failed': {}, 'next': {}, 'prompted': {}, 'reset': { param_types: [GObject.TYPE_UINT] }, }, }, class AuthPrompt extends St.BoxLayout { _init(gdmClient, mode) { super._init({ style_class: 'login-dialog-prompt-layout', vertical: true, x_expand: true, x_align: Clutter.ActorAlign.CENTER, reactive: true, }); this.verificationStatus = AuthPromptStatus.NOT_VERIFYING; this._gdmClient = gdmClient; this._mode = mode; this._defaultButtonWellActor = null; this._cancelledRetries = 0; let reauthenticationOnly; if (this._mode == AuthPromptMode.UNLOCK_ONLY) reauthenticationOnly = true; else if (this._mode == AuthPromptMode.UNLOCK_OR_LOG_IN) reauthenticationOnly = false; this._userVerifier = new GdmUtil.ShellUserVerifier(this._gdmClient, { reauthenticationOnly }); this._userVerifier.connect('ask-question', this._onAskQuestion.bind(this)); this._userVerifier.connect('show-message', this._onShowMessage.bind(this)); this._userVerifier.connect('show-choice-list', this._onShowChoiceList.bind(this)); this._userVerifier.connect('verification-failed', this._onVerificationFailed.bind(this)); this._userVerifier.connect('verification-complete', this._onVerificationComplete.bind(this)); this._userVerifier.connect('reset', this._onReset.bind(this)); this._userVerifier.connect('smartcard-status-changed', this._onSmartcardStatusChanged.bind(this)); this._userVerifier.connect('credential-manager-authenticated', this._onCredentialManagerAuthenticated.bind(this)); this.smartcardDetected = this._userVerifier.smartcardDetected; this.connect('destroy', this._onDestroy.bind(this)); this._userWell = new St.Bin({ x_expand: true, y_expand: true, }); this.add_child(this._userWell); this._hasCancelButton = this._mode === AuthPromptMode.UNLOCK_OR_LOG_IN; this._initInputRow(); let capsLockPlaceholder = new St.Label(); this.add_child(capsLockPlaceholder); this._capsLockWarningLabel = new ShellEntry.CapsLockWarning({ x_expand: true, x_align: Clutter.ActorAlign.CENTER, }); this.add_child(this._capsLockWarningLabel); this._capsLockWarningLabel.bind_property('visible', capsLockPlaceholder, 'visible', GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.INVERT_BOOLEAN); this._message = new St.Label({ opacity: 0, styleClass: 'login-dialog-message', y_expand: true, x_expand: true, y_align: Clutter.ActorAlign.START, x_align: Clutter.ActorAlign.CENTER, }); this._message.clutter_text.line_wrap = true; this._message.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; this.add_child(this._message); } _onDestroy() { this._inactiveEntry.destroy(); this._inactiveEntry = null; this._userVerifier.destroy(); this._userVerifier = null; } vfunc_key_press_event(keyPressEvent) { if (keyPressEvent.keyval == Clutter.KEY_Escape) this.cancel(); return super.vfunc_key_press_event(keyPressEvent); } _initInputRow() { this._mainBox = new St.BoxLayout({ style_class: 'login-dialog-button-box', vertical: false, }); this.add_child(this._mainBox); this.cancelButton = new St.Button({ 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, x_align: Clutter.ActorAlign.START, y_align: Clutter.ActorAlign.CENTER, icon_name: 'go-previous-symbolic', }); if (this._hasCancelButton) this.cancelButton.connect('clicked', () => this.cancel()); else this.cancelButton.opacity = 0; this._mainBox.add_child(this.cancelButton); this._authList = new AuthList.AuthList(); this._authList.set({ visible: false, }); this._authList.connect('activate', (list, key) => { this._authList.reactive = false; this._authList.ease({ opacity: 0, duration: MESSAGE_FADE_OUT_ANIMATION_TIME, mode: Clutter.AnimationMode.EASE_OUT_QUAD, onComplete: () => { this._authList.clear(); this._authList.hide(); this._userVerifier.selectChoice(this._queryingService, key); }, }); }); this._mainBox.add_child(this._authList); let entryParams = { style_class: 'login-dialog-prompt-entry', can_focus: true, x_expand: true, }; this._entry = null; this._textEntry = new St.Entry(entryParams); ShellEntry.addContextMenu(this._textEntry, {actionMode: Shell.ActionMode.NONE}); this._passwordEntry = new St.PasswordEntry(entryParams); ShellEntry.addContextMenu(this._passwordEntry, {actionMode: Shell.ActionMode.NONE}); this._entry = this._passwordEntry; this._mainBox.add_child(this._entry); this._entry.grab_key_focus(); this._inactiveEntry = this._textEntry; this._timedLoginIndicator = new St.Bin({ style_class: 'login-dialog-timed-login-indicator', scale_x: 0, }); this.add_child(this._timedLoginIndicator); [this._textEntry, this._passwordEntry].forEach(entry => { entry.clutter_text.connect('text-changed', () => { if (!this._userVerifier.hasPendingMessages) this._fadeOutMessage(); }); entry.clutter_text.connect('activate', () => { let shouldSpin = entry === this._passwordEntry; if (entry.reactive) this._activateNext(shouldSpin); }); }); this._defaultButtonWell = new St.Widget({ layout_manager: new Clutter.BinLayout(), 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._spinner = new Animation.Spinner(DEFAULT_BUTTON_WELL_ICON_SIZE); this._defaultButtonWell.add_child(this._spinner); } showTimedLoginIndicator(time) { let hold = new Batch.Hold(); this.hideTimedLoginIndicator(); const startTime = GLib.get_monotonic_time(); this._timedLoginTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 33, () => { const currentTime = GLib.get_monotonic_time(); const elapsedTime = (currentTime - startTime) / GLib.USEC_PER_SEC; this._timedLoginIndicator.scale_x = elapsedTime / time; if (elapsedTime >= time) { this._timedLoginTimeoutId = 0; hold.release(); return GLib.SOURCE_REMOVE; } return GLib.SOURCE_CONTINUE; }); GLib.Source.set_name_by_id(this._timedLoginTimeoutId, '[gnome-shell] this._timedLoginTimeoutId'); return hold; } hideTimedLoginIndicator() { if (this._timedLoginTimeoutId) { GLib.source_remove(this._timedLoginTimeoutId); this._timedLoginTimeoutId = 0; } this._timedLoginIndicator.scale_x = 0.; } _activateNext(shouldSpin) { this.verificationStatus = AuthPromptStatus.VERIFICATION_IN_PROGRESS; this.updateSensitivity(false); if (shouldSpin) this.startSpinning(); if (this._queryingService) this._userVerifier.answerQuery(this._queryingService, this._entry.text); else this._preemptiveAnswer = this._entry.text; this.emit('next'); } _updateEntry(secret) { if (secret && this._entry !== this._passwordEntry) { this._mainBox.replace_child(this._entry, this._passwordEntry); this._entry = this._passwordEntry; this._inactiveEntry = this._textEntry; } else if (!secret && this._entry !== this._textEntry) { this._mainBox.replace_child(this._entry, this._textEntry); this._entry = this._textEntry; this._inactiveEntry = this._passwordEntry; } this._capsLockWarningLabel.visible = secret; } _onAskQuestion(verifier, serviceName, question, secret) { if (this._queryingService) this.clear(); this._queryingService = serviceName; if (this._preemptiveAnswer) { this._userVerifier.answerQuery(this._queryingService, this._preemptiveAnswer); this._preemptiveAnswer = null; return; } this._updateEntry(secret); // Hack: The question string comes directly from PAM, if it's "Password:" // we replace it with our own to allow localization, if it's something // else we remove the last colon and any trailing or leading spaces. if (question === 'Password:' || question === 'Password: ') this.setQuestion(_('Password')); else this.setQuestion(question.replace(/[::] *$/, '').trim()); this.updateSensitivity(true); this.emit('prompted'); } _onShowChoiceList(userVerifier, serviceName, promptMessage, choiceList) { if (this._queryingService) this.clear(); this._queryingService = serviceName; if (this._preemptiveAnswer) this._preemptiveAnswer = null; this.setChoiceList(promptMessage, choiceList); this.updateSensitivity(true); 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(GdmUtil.SMARTCARD_SERVICE_NAME) && this.verificationStatus == AuthPromptStatus.VERIFYING && this.smartcardDetected) return; if (this.verificationStatus != AuthPromptStatus.VERIFICATION_SUCCEEDED) this.reset(); } _onShowMessage(_userVerifier, serviceName, message, type) { let wiggleParameters = {duration: 0}; if (type === GdmUtil.MessageType.ERROR && this._userVerifier.serviceIsFingerprint(serviceName)) { // TODO: Use Await for wiggle to be over before unfreezing the user verifier queue wiggleParameters = { duration: 65, wiggleCount: 3, }; this._userVerifier.increaseCurrentMessageTimeout( wiggleParameters.duration * (wiggleParameters.wiggleCount + 2)); } this.setMessage(message, type, wiggleParameters); this.emit('prompted'); } _onVerificationFailed(userVerifier, serviceName, canRetry) { const wasQueryingService = this._queryingService === serviceName; if (wasQueryingService) { this._queryingService = null; this.clear(); } this.updateSensitivity(canRetry); this.setActorInDefaultButtonWell(null); if (!canRetry) this.verificationStatus = AuthPromptStatus.VERIFICATION_FAILED; if (wasQueryingService) wiggle(this._entry); } _onVerificationComplete() { this.setActorInDefaultButtonWell(null); this.verificationStatus = AuthPromptStatus.VERIFICATION_SUCCEEDED; this.cancelButton.reactive = false; this.cancelButton.can_focus = false; } _onReset() { this.verificationStatus = AuthPromptStatus.NOT_VERIFYING; this.reset(); } setActorInDefaultButtonWell(actor, animate) { if (!this._defaultButtonWellActor && !actor) return; let oldActor = this._defaultButtonWellActor; 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; if (this._defaultButtonWellActor != actor && oldActor) { if (!animate) { 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 (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, }); } } this._defaultButtonWellActor = actor; } startSpinning() { this.setActorInDefaultButtonWell(this._spinner, true); } stopSpinning() { this.setActorInDefaultButtonWell(null, false); } clear() { this._entry.text = ''; this.stopSpinning(); this._authList.clear(); this._authList.hide(); } setQuestion(question) { this._entry.hint_text = question; this._authList.hide(); this._entry.show(); this._entry.grab_key_focus(); } _fadeInChoiceList() { this._authList.set({ opacity: 0, visible: true, reactive: false, }); this._authList.ease({ opacity: 255, duration: MESSAGE_FADE_OUT_ANIMATION_TIME, transition: Clutter.AnimationMode.EASE_OUT_QUAD, onComplete: () => (this._authList.reactive = true), }); } 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._entry.hide(); if (this._message.text === '') this._message.hide(); this._fadeInChoiceList(); } getAnswer() { let text; if (this._preemptiveAnswer) { text = this._preemptiveAnswer; this._preemptiveAnswer = null; } else { text = this._entry.get_text(); } return text; } _fadeOutMessage() { if (this._message.opacity == 0) return; this._message.remove_all_transitions(); this._message.ease({ opacity: 0, duration: MESSAGE_FADE_OUT_ANIMATION_TIME, mode: Clutter.AnimationMode.EASE_OUT_QUAD, }); } setMessage(message, type, wiggleParameters = {duration: 0}) { if (type == GdmUtil.MessageType.ERROR) this._message.add_style_class_name('login-dialog-message-warning'); else this._message.remove_style_class_name('login-dialog-message-warning'); if (type == GdmUtil.MessageType.HINT) this._message.add_style_class_name('login-dialog-message-hint'); else this._message.remove_style_class_name('login-dialog-message-hint'); this._message.show(); if (message) { this._message.remove_all_transitions(); this._message.text = message; this._message.opacity = 255; } else { this._message.opacity = 0; } wiggle(this._message, wiggleParameters); } updateSensitivity(sensitive) { if (this._entry.reactive === sensitive) return; this._entry.reactive = sensitive; if (sensitive) { this._entry.grab_key_focus(); } else { this.grab_key_focus(); if (this._entry === this._passwordEntry) this._entry.password_visible = false; } } vfunc_hide() { this.setActorInDefaultButtonWell(null, true); super.vfunc_hide(); this._message.opacity = 0; this.setUser(null); this.updateSensitivity(true); this._entry.set_text(''); } setUser(user) { let oldChild = this._userWell.get_child(); if (oldChild) oldChild.destroy(); let userWidget = new UserWidget.UserWidget(user, Clutter.Orientation.VERTICAL); this._userWell.set_child(userWidget); if (!user) this._updateEntry(false); } reset() { let oldStatus = this.verificationStatus; this.verificationStatus = AuthPromptStatus.NOT_VERIFYING; this.cancelButton.reactive = this._hasCancelButton; this.cancelButton.can_focus = this._hasCancelButton; this._preemptiveAnswer = null; if (this._userVerifier) this._userVerifier.cancel(); this._queryingService = null; this.clear(); this._message.opacity = 0; this.setUser(null); this._updateEntry(true); this.stopSpinning(); 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; } this.emit('reset', beginRequestType); } addCharacter(unichar) { if (!this._entry.visible) return; this._entry.grab_key_focus(); this._entry.clutter_text.insert_unichar(unichar); } begin(params) { params = Params.parse(params, { userName: null, hold: null, }); this.updateSensitivity(false); let hold = params.hold; if (!hold) hold = new Batch.Hold(); this._userVerifier.begin(params.userName, hold); this.verificationStatus = AuthPromptStatus.VERIFYING; } finish(onComplete) { if (!this._userVerifier.hasPendingMessages) { this._userVerifier.clear(); onComplete(); return; } let signalId = this._userVerifier.connect('no-more-messages', () => { this._userVerifier.disconnect(signalId); this._userVerifier.clear(); onComplete(); }); } cancel() { if (this.verificationStatus == AuthPromptStatus.VERIFICATION_SUCCEEDED) return; if (this.verificationStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS) { this._cancelledRetries++; if (this._cancelledRetries > this._userVerifier.allowedFailures) this.verificationStatus = AuthPromptStatus.VERIFICATION_FAILED; } else { this.verificationStatus = AuthPromptStatus.VERIFICATION_CANCELLED; } this.reset(); } });