From ac0a0fd2198d833c2bc047dbb0104beb02dac28e Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:00:56 -0700 Subject: [PATCH] Desktop Autotype toggle on vault lock/unlock (#17062) * Desktop Autotype toggle on vault lock/unlock * lint * add back disable on will-quit signal * improve IPC message args * claude: takeUntilDestroyed * claude: try/catch * claude: multiple listeners * claude: === * claude: concatMap * claude: IPC Handler Registration in Constructor * claude: helper function * claude: Type Safety for IPC Messages * fix claude suggestion? * bit by commit hook file write again * remove the type qualifier * add log svc dep * move the initialized ipcs back to constructor * frageele? * try disable premium check * replace takeUntilDestroy with takeUntil(destroy) * add import * create separate observable for premium check * clean up and remove distinctUntilChanged * re-add distinctUntilChanged * ipc handlers in init * check double initialization * Revert "check double initialization" This reverts commit 8488b8a6130e69a31497c7c0148dde256f2c9667. * Revert "ipc handlers in init" This reverts commit a23999edcfda396eb0c0910d8a318b084a3c4120. * ipc out of constructor * claude suggestion does not compile, awesome * add a dispose method for cleanup of ipc handlers * claude: remove of(false) on observable initializing * claude: remove the init/init'd * claude: remove takeUntil on isPremiumAccount * Revert "claude: remove takeUntil on isPremiumAccount" This reverts commit 9fc32c5fcf47964df63ed4198d96223e26d9ae1f. * align models file name with interface name * rename ipc listeners function * improve debug log message * improve debug log message * remove reference to not present observable in unit test * add function comment * make `autotypeKeyboardShortcut` private --- .../app/accounts/settings.component.spec.ts | 1 - .../src/app/services/services.module.ts | 1 + .../main/main-desktop-autotype.service.ts | 103 +++++++--- .../src/autofill/models/autotype-config.ts | 3 + .../src/autofill/models/ipc-channels.ts | 9 + apps/desktop/src/autofill/preload.ts | 17 +- .../services/desktop-autotype.service.ts | 184 ++++++++++++------ apps/desktop/src/main.ts | 11 +- 8 files changed, 220 insertions(+), 109 deletions(-) create mode 100644 apps/desktop/src/autofill/models/autotype-config.ts create mode 100644 apps/desktop/src/autofill/models/ipc-channels.ts diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index a424f230778..d518ac29aa4 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -187,7 +187,6 @@ describe("SettingsComponent", () => { i18nService.userSetLocale$ = of("en"); pinServiceAbstraction.isPinSet.mockResolvedValue(false); policyService.policiesByType$.mockReturnValue(of([null])); - desktopAutotypeService.resolvedAutotypeEnabled$ = of(false); desktopAutotypeService.autotypeEnabledUserSetting$ = of(false); desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Shift", "B"]); billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 59021a556e4..874a4d851da 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -489,6 +489,7 @@ const safeProviders: SafeProvider[] = [ PlatformUtilsServiceAbstraction, BillingAccountProfileStateService, DesktopAutotypeDefaultSettingPolicy, + LogService, ], }), safeProvider({ diff --git a/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts b/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts index 4dcf05a4220..ea2bdd1fe12 100644 --- a/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts @@ -5,51 +5,46 @@ import { LogService } from "@bitwarden/logging"; import { WindowMain } from "../../main/window.main"; import { stringIsNotUndefinedNullAndEmpty } from "../../utils"; +import { AutotypeConfig } from "../models/autotype-config"; import { AutotypeMatchError } from "../models/autotype-errors"; import { AutotypeVaultData } from "../models/autotype-vault-data"; +import { AUTOTYPE_IPC_CHANNELS } from "../models/ipc-channels"; import { AutotypeKeyboardShortcut } from "../models/main-autotype-keyboard-shortcut"; export class MainDesktopAutotypeService { - autotypeKeyboardShortcut: AutotypeKeyboardShortcut; + private autotypeKeyboardShortcut: AutotypeKeyboardShortcut; constructor( private logService: LogService, private windowMain: WindowMain, ) { this.autotypeKeyboardShortcut = new AutotypeKeyboardShortcut(); + + this.registerIpcListeners(); } - init() { - ipcMain.on("autofill.configureAutotype", (event, data) => { - if (data.enabled) { - const newKeyboardShortcut = new AutotypeKeyboardShortcut(); - const newKeyboardShortcutIsValid = newKeyboardShortcut.set(data.keyboardShortcut); - - if (newKeyboardShortcutIsValid) { - this.disableAutotype(); - this.autotypeKeyboardShortcut = newKeyboardShortcut; - this.enableAutotype(); - } else { - this.logService.error( - "Attempting to configure autotype but the shortcut given is invalid.", - ); - } + registerIpcListeners() { + ipcMain.on(AUTOTYPE_IPC_CHANNELS.TOGGLE, (_event, enable: boolean) => { + if (enable) { + this.enableAutotype(); } else { this.disableAutotype(); - - // Deregister the incoming keyboard shortcut if needed - const setCorrectly = this.autotypeKeyboardShortcut.set(data.keyboardShortcut); - if ( - setCorrectly && - globalShortcut.isRegistered(this.autotypeKeyboardShortcut.getElectronFormat()) - ) { - globalShortcut.unregister(this.autotypeKeyboardShortcut.getElectronFormat()); - this.logService.info("Autotype disabled."); - } } }); - ipcMain.on("autofill.completeAutotypeRequest", (_event, vaultData: AutotypeVaultData) => { + ipcMain.on(AUTOTYPE_IPC_CHANNELS.CONFIGURE, (_event, config: AutotypeConfig) => { + const newKeyboardShortcut = new AutotypeKeyboardShortcut(); + const newKeyboardShortcutIsValid = newKeyboardShortcut.set(config.keyboardShortcut); + + if (!newKeyboardShortcutIsValid) { + this.logService.error("Configure autotype failed: the keyboard shortcut is invalid."); + return; + } + + this.setKeyboardShortcut(newKeyboardShortcut); + }); + + ipcMain.on(AUTOTYPE_IPC_CHANNELS.EXECUTE, (_event, vaultData: AutotypeVaultData) => { if ( stringIsNotUndefinedNullAndEmpty(vaultData.username) && stringIsNotUndefinedNullAndEmpty(vaultData.password) @@ -67,30 +62,74 @@ export class MainDesktopAutotypeService { }); } + // Deregister the keyboard shortcut if registered. disableAutotype() { - // Deregister the current keyboard shortcut if needed const formattedKeyboardShortcut = this.autotypeKeyboardShortcut.getElectronFormat(); + if (globalShortcut.isRegistered(formattedKeyboardShortcut)) { globalShortcut.unregister(formattedKeyboardShortcut); - this.logService.info("Autotype disabled."); + this.logService.debug("Autotype disabled."); + } else { + this.logService.debug("Autotype is not registered, implicitly disabled."); } } + dispose() { + ipcMain.removeAllListeners(AUTOTYPE_IPC_CHANNELS.TOGGLE); + ipcMain.removeAllListeners(AUTOTYPE_IPC_CHANNELS.CONFIGURE); + ipcMain.removeAllListeners(AUTOTYPE_IPC_CHANNELS.EXECUTE); + + // Also unregister the global shortcut + this.disableAutotype(); + } + + // Register the current keyboard shortcut if not already registered. private enableAutotype() { + const formattedKeyboardShortcut = this.autotypeKeyboardShortcut.getElectronFormat(); + if (globalShortcut.isRegistered(formattedKeyboardShortcut)) { + this.logService.debug( + "Autotype is already enabled with this keyboard shortcut: " + formattedKeyboardShortcut, + ); + return; + } + const result = globalShortcut.register( this.autotypeKeyboardShortcut.getElectronFormat(), () => { const windowTitle = autotype.getForegroundWindowTitle(); - this.windowMain.win.webContents.send("autofill.listenAutotypeRequest", { + this.windowMain.win.webContents.send(AUTOTYPE_IPC_CHANNELS.LISTEN, { windowTitle, }); }, ); result - ? this.logService.info("Autotype enabled.") - : this.logService.info("Enabling autotype failed."); + ? this.logService.debug("Autotype enabled.") + : this.logService.error("Failed to enable Autotype."); + } + + // Set the keyboard shortcut if it differs from the present one. If + // the keyboard shortcut is set, de-register the old shortcut first. + private setKeyboardShortcut(keyboardShortcut: AutotypeKeyboardShortcut) { + if ( + keyboardShortcut.getElectronFormat() !== this.autotypeKeyboardShortcut.getElectronFormat() + ) { + const registered = globalShortcut.isRegistered( + this.autotypeKeyboardShortcut.getElectronFormat(), + ); + if (registered) { + this.disableAutotype(); + } + this.autotypeKeyboardShortcut = keyboardShortcut; + if (registered) { + this.enableAutotype(); + } + } else { + this.logService.debug( + "setKeyboardShortcut() called but shortcut is not different from current.", + ); + } } private doAutotype(vaultData: AutotypeVaultData, keyboardShortcut: string[]) { diff --git a/apps/desktop/src/autofill/models/autotype-config.ts b/apps/desktop/src/autofill/models/autotype-config.ts new file mode 100644 index 00000000000..dda39023c8c --- /dev/null +++ b/apps/desktop/src/autofill/models/autotype-config.ts @@ -0,0 +1,3 @@ +export interface AutotypeConfig { + keyboardShortcut: string[]; +} diff --git a/apps/desktop/src/autofill/models/ipc-channels.ts b/apps/desktop/src/autofill/models/ipc-channels.ts new file mode 100644 index 00000000000..5fea2daf0cf --- /dev/null +++ b/apps/desktop/src/autofill/models/ipc-channels.ts @@ -0,0 +1,9 @@ +export const AUTOTYPE_IPC_CHANNELS = { + INIT: "autofill.initAutotype", + INITIALIZED: "autofill.autotypeIsInitialized", + TOGGLE: "autofill.toggleAutotype", + CONFIGURE: "autofill.configureAutotype", + LISTEN: "autofill.listenAutotypeRequest", + EXECUTION_ERROR: "autofill.autotypeExecutionError", + EXECUTE: "autofill.executeAutotype", +} as const; diff --git a/apps/desktop/src/autofill/preload.ts b/apps/desktop/src/autofill/preload.ts index 6a7a8459ea9..f4f5552944c 100644 --- a/apps/desktop/src/autofill/preload.ts +++ b/apps/desktop/src/autofill/preload.ts @@ -5,8 +5,10 @@ import type { autofill } from "@bitwarden/desktop-napi"; import { Command } from "../platform/main/autofill/command"; import { RunCommandParams, RunCommandResult } from "../platform/main/autofill/native-autofill.main"; +import { AutotypeConfig } from "./models/autotype-config"; import { AutotypeMatchError } from "./models/autotype-errors"; import { AutotypeVaultData } from "./models/autotype-vault-data"; +import { AUTOTYPE_IPC_CHANNELS } from "./models/ipc-channels"; export default { runCommand: (params: RunCommandParams): Promise> => @@ -132,7 +134,6 @@ export default { }, ); }, - listenNativeStatus: ( fn: (clientId: number, sequenceNumber: number, status: { key: string; value: string }) => void, ) => { @@ -151,8 +152,11 @@ export default { }, ); }, - configureAutotype: (enabled: boolean, keyboardShortcut: string[]) => { - ipcRenderer.send("autofill.configureAutotype", { enabled, keyboardShortcut }); + configureAutotype: (config: AutotypeConfig) => { + ipcRenderer.send(AUTOTYPE_IPC_CHANNELS.CONFIGURE, config); + }, + toggleAutotype: (enable: boolean) => { + ipcRenderer.send(AUTOTYPE_IPC_CHANNELS.TOGGLE, enable); }, listenAutotypeRequest: ( fn: ( @@ -161,7 +165,7 @@ export default { ) => void, ) => { ipcRenderer.on( - "autofill.listenAutotypeRequest", + AUTOTYPE_IPC_CHANNELS.LISTEN, ( _event, data: { @@ -176,11 +180,12 @@ export default { windowTitle, errorMessage: error.message, }; - ipcRenderer.send("autofill.completeAutotypeError", matchError); + ipcRenderer.send(AUTOTYPE_IPC_CHANNELS.EXECUTION_ERROR, matchError); return; } + if (vaultData !== null) { - ipcRenderer.send("autofill.completeAutotypeRequest", vaultData); + ipcRenderer.send(AUTOTYPE_IPC_CHANNELS.EXECUTE, vaultData); } }); }, diff --git a/apps/desktop/src/autofill/services/desktop-autotype.service.ts b/apps/desktop/src/autofill/services/desktop-autotype.service.ts index 7ee889e7b81..46fec662d7a 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype.service.ts @@ -1,4 +1,17 @@ -import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; +import { Injectable, OnDestroy } from "@angular/core"; +import { + combineLatest, + concatMap, + distinctUntilChanged, + filter, + firstValueFrom, + map, + Observable, + of, + Subject, + switchMap, + takeUntil, +} from "rxjs"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; @@ -15,8 +28,10 @@ import { } from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LogService } from "@bitwarden/logging"; import { UserId } from "@bitwarden/user-core"; +import { AutotypeConfig } from "../models/autotype-config"; import { AutotypeVaultData } from "../models/autotype-vault-data"; import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service"; @@ -44,16 +59,26 @@ export const AUTOTYPE_KEYBOARD_SHORTCUT = new KeyDefinition( { deserializer: (b) => b }, ); -export class DesktopAutotypeService { +@Injectable({ + providedIn: "root", +}) +export class DesktopAutotypeService implements OnDestroy { private readonly autotypeEnabledState = this.globalStateProvider.get(AUTOTYPE_ENABLED); private readonly autotypeKeyboardShortcut = this.globalStateProvider.get( AUTOTYPE_KEYBOARD_SHORTCUT, ); - autotypeEnabledUserSetting$: Observable = of(false); - resolvedAutotypeEnabled$: Observable = of(false); + // if the user's account is Premium + private readonly isPremiumAccount$: Observable; + + // The enabled/disabled state from the user settings menu + autotypeEnabledUserSetting$: Observable; + + // The keyboard shortcut from the user settings menu autotypeKeyboardShortcut$: Observable = of(defaultWindowsAutotypeKeyboardShortcut); + private destroy$ = new Subject(); + constructor( private accountService: AccountService, private authService: AuthService, @@ -63,76 +88,110 @@ export class DesktopAutotypeService { private platformUtilsService: PlatformUtilsService, private billingAccountProfileStateService: BillingAccountProfileStateService, private desktopAutotypePolicy: DesktopAutotypeDefaultSettingPolicy, + private logService: LogService, ) { + this.autotypeEnabledUserSetting$ = this.autotypeEnabledState.state$.pipe( + map((enabled) => enabled ?? false), + distinctUntilChanged(), // Only emit when the boolean result changes + takeUntil(this.destroy$), + ); + + this.isPremiumAccount$ = this.accountService.activeAccount$.pipe( + filter((account): account is Account => !!account), + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + distinctUntilChanged(), // Only emit when the boolean result changes + takeUntil(this.destroy$), + ); + + this.autotypeKeyboardShortcut$ = this.autotypeKeyboardShortcut.state$.pipe( + map((shortcut) => shortcut ?? defaultWindowsAutotypeKeyboardShortcut), + takeUntil(this.destroy$), + ); + } + + async init() { + // Currently Autotype is only supported for Windows + if (this.platformUtilsService.getDevice() !== DeviceType.WindowsDesktop) { + return; + } + ipc.autofill.listenAutotypeRequest(async (windowTitle, callback) => { const possibleCiphers = await this.matchCiphersToWindowTitle(windowTitle); const firstCipher = possibleCiphers?.at(0); const [error, vaultData] = getAutotypeVaultData(firstCipher); callback(error, vaultData); }); - } - async init() { - this.autotypeEnabledUserSetting$ = this.autotypeEnabledState.state$; - this.autotypeKeyboardShortcut$ = this.autotypeKeyboardShortcut.state$.pipe( - map((shortcut) => shortcut ?? defaultWindowsAutotypeKeyboardShortcut), - ); - - // Currently Autotype is only supported for Windows - if (this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop) { - // If `autotypeDefaultPolicy` is `true` for a user's organization, and the - // user has never changed their local autotype setting (`autotypeEnabledState`), - // we set their local setting to `true` (once the local user setting is changed - // by this policy or the user themselves, the default policy should - // never change the user setting again). - combineLatest([ - this.autotypeEnabledState.state$, - this.desktopAutotypePolicy.autotypeDefaultSetting$, - ]) - .pipe( - map(async ([autotypeEnabledState, autotypeDefaultPolicy]) => { + // If `autotypeDefaultPolicy` is `true` for a user's organization, and the + // user has never changed their local autotype setting (`autotypeEnabledState`), + // we set their local setting to `true` (once the local user setting is changed + // by this policy or the user themselves, the default policy should + // never change the user setting again). + combineLatest([ + this.autotypeEnabledState.state$, + this.desktopAutotypePolicy.autotypeDefaultSetting$, + ]) + .pipe( + concatMap(async ([autotypeEnabledState, autotypeDefaultPolicy]) => { + try { if (autotypeDefaultPolicy === true && autotypeEnabledState === null) { await this.setAutotypeEnabledState(true); } - }), - ) - .subscribe(); + } catch { + this.logService.error("Failed to set Autotype enabled state."); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); - // autotypeEnabledUserSetting$ publicly represents the value the - // user has set for autotyeEnabled in their local settings. - this.autotypeEnabledUserSetting$ = this.autotypeEnabledState.state$; + // listen for changes in keyboard shortcut settings + this.autotypeKeyboardShortcut$ + .pipe( + concatMap(async (keyboardShortcut) => { + const config: AutotypeConfig = { + keyboardShortcut, + }; + ipc.autofill.configureAutotype(config); + }), + takeUntil(this.destroy$), + ) + .subscribe(); - // resolvedAutotypeEnabled$ represents the final determination if the Autotype - // feature should be on or off. - this.resolvedAutotypeEnabled$ = combineLatest([ - this.autotypeEnabledState.state$, - this.configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype), - this.accountService.activeAccount$.pipe( - map((activeAccount) => activeAccount?.id), - switchMap((userId) => this.authService.authStatusFor$(userId)), - ), - this.accountService.activeAccount$.pipe( - filter((account): account is Account => !!account), - switchMap((account) => - this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), - ), - ), - ]).pipe( - map( - ([autotypeEnabled, windowsDesktopAutotypeFeatureFlag, authStatus, hasPremium]) => - autotypeEnabled && - windowsDesktopAutotypeFeatureFlag && - authStatus == AuthenticationStatus.Unlocked && - hasPremium, - ), - ); + this.autotypeFeatureEnabled$ + .pipe( + concatMap(async (enabled) => { + ipc.autofill.toggleAutotype(enabled); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } - combineLatest([this.resolvedAutotypeEnabled$, this.autotypeKeyboardShortcut$]).subscribe( - ([resolvedAutotypeEnabled, autotypeKeyboardShortcut]) => { - ipc.autofill.configureAutotype(resolvedAutotypeEnabled, autotypeKeyboardShortcut); - }, - ); - } + // Returns an observable that represents whether autotype is enabled for the current user. + private get autotypeFeatureEnabled$(): Observable { + return combineLatest([ + // if the user has enabled the setting + this.autotypeEnabledUserSetting$, + // if the feature flag is set + this.configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype), + // if there is an active account with an unlocked vault + this.authService.activeAccountStatus$, + // if the active user's account is Premium + this.isPremiumAccount$, + ]).pipe( + map( + ([settingsEnabled, ffEnabled, authStatus, isPremiumAcct]) => + settingsEnabled && + ffEnabled && + authStatus === AuthenticationStatus.Unlocked && + isPremiumAcct, + ), + distinctUntilChanged(), // Only emit when the boolean result changes + takeUntil(this.destroy$), + ); } async setAutotypeEnabledState(enabled: boolean): Promise { @@ -176,6 +235,11 @@ export class DesktopAutotypeService { return possibleCiphers; } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } /** diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index fbb83a1bf56..4734288f3c1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -311,17 +311,8 @@ export class Main { this.windowMain, ); - app - .whenReady() - .then(() => { - this.mainDesktopAutotypeService.init(); - }) - .catch((reason) => { - this.logService.error("Error initializing Autotype.", reason); - }); - app.on("will-quit", () => { - this.mainDesktopAutotypeService.disableAutotype(); + this.mainDesktopAutotypeService.dispose(); }); }