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 8488b8a613.

* Revert "ipc handlers in init"

This reverts commit a23999edcf.

* 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 9fc32c5fcf.

* 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
This commit is contained in:
neuronull
2025-12-16 13:00:56 -07:00
committed by GitHub
parent b63e1cb26c
commit ac0a0fd219
8 changed files with 220 additions and 109 deletions

View File

@@ -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));

View File

@@ -489,6 +489,7 @@ const safeProviders: SafeProvider[] = [
PlatformUtilsServiceAbstraction,
BillingAccountProfileStateService,
DesktopAutotypeDefaultSettingPolicy,
LogService,
],
}),
safeProvider({

View File

@@ -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[]) {

View File

@@ -0,0 +1,3 @@
export interface AutotypeConfig {
keyboardShortcut: string[];
}

View File

@@ -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;

View File

@@ -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: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> =>
@@ -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);
}
});
},

View File

@@ -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<string[]>(
{ 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<boolean> = of(false);
resolvedAutotypeEnabled$: Observable<boolean> = of(false);
// if the user's account is Premium
private readonly isPremiumAccount$: Observable<boolean>;
// The enabled/disabled state from the user settings menu
autotypeEnabledUserSetting$: Observable<boolean>;
// The keyboard shortcut from the user settings menu
autotypeKeyboardShortcut$: Observable<string[]> = of(defaultWindowsAutotypeKeyboardShortcut);
private destroy$ = new Subject<void>();
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<boolean> {
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<void> {
@@ -176,6 +235,11 @@ export class DesktopAutotypeService {
return possibleCiphers;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
/**

View File

@@ -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();
});
}