1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

[PM-22789] Handle Autotype Hotkey Event (#15840)

* [PM-22789] Handle full hotkey event for autotype

* [PM-22789] Move userId into windows conditional check

* [PM-22789] Refactor autotype service observables

* [PM-22789] Address PR comments

* [PM-22789] Refactor stringIsNotUndefinedNullAndEmpty() function
This commit is contained in:
Colton Hurst
2025-08-05 12:59:32 -04:00
committed by GitHub
parent 033642c0db
commit a9c7936334
8 changed files with 197 additions and 110 deletions

View File

@@ -91,6 +91,7 @@ export class InitService {
containerService.attachToGlobal(this.win); containerService.attachToGlobal(this.win);
await this.autofillService.init(); await this.autofillService.init();
await this.autotypeService.init();
}; };
} }
} }

View File

@@ -49,7 +49,7 @@ import {
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { ClientType, DeviceType } from "@bitwarden/common/enums"; import { ClientType } from "@bitwarden/common/enums";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@@ -455,17 +455,15 @@ const safeProviders: SafeProvider[] = [
}), }),
safeProvider({ safeProvider({
provide: DesktopAutotypeService, provide: DesktopAutotypeService,
useFactory: ( useClass: DesktopAutotypeService,
configService: ConfigService, deps: [
globalStateProvider: GlobalStateProvider, AccountService,
platformUtilsService: PlatformUtilsServiceAbstraction, AuthService,
) => CipherServiceAbstraction,
new DesktopAutotypeService( ConfigService,
configService, GlobalStateProvider,
globalStateProvider, PlatformUtilsServiceAbstraction,
platformUtilsService.getDevice() === DeviceType.WindowsDesktop, ],
),
deps: [ConfigService, GlobalStateProvider, PlatformUtilsServiceAbstraction],
}), }),
]; ];

View File

@@ -1,33 +1,70 @@
import { autotype } from "@bitwarden/desktop-napi"; import { ipcMain, globalShortcut } from "electron";
import { DesktopAutotypeService } from "../services/desktop-autotype.service"; import { autotype } from "@bitwarden/desktop-napi";
import { LogService } from "@bitwarden/logging";
import { WindowMain } from "../../main/window.main";
import { stringIsNotUndefinedNullAndEmpty } from "../../utils";
export class MainDesktopAutotypeService { export class MainDesktopAutotypeService {
constructor(private desktopAutotypeService: DesktopAutotypeService) {} keySequence: string = "Alt+CommandOrControl+I";
constructor(
private logService: LogService,
private windowMain: WindowMain,
) {}
init() { init() {
this.desktopAutotypeService.autotypeEnabled$.subscribe((enabled) => { ipcMain.on("autofill.configureAutotype", (event, data) => {
if (enabled) { if (data.enabled === true && !globalShortcut.isRegistered(this.keySequence)) {
this.enableAutotype(); this.enableAutotype();
} else { } else if (data.enabled === false && globalShortcut.isRegistered(this.keySequence)) {
this.disableAutotype(); this.disableAutotype();
} }
}); });
ipcMain.on("autofill.completeAutotypeRequest", (event, data) => {
const { response } = data;
if (
stringIsNotUndefinedNullAndEmpty(response.username) &&
stringIsNotUndefinedNullAndEmpty(response.password)
) {
this.doAutotype(response.username, response.password);
}
});
}
disableAutotype() {
if (globalShortcut.isRegistered(this.keySequence)) {
globalShortcut.unregister(this.keySequence);
}
this.logService.info("Autotype disabled.");
} }
// TODO: this will call into desktop native code
private enableAutotype() { private enableAutotype() {
// eslint-disable-next-line no-console const result = globalShortcut.register(this.keySequence, () => {
console.log("Enabling Autotype..."); const windowTitle = autotype.getForegroundWindowTitle();
const result = autotype.getForegroundWindowTitle(); this.windowMain.win.webContents.send("autofill.listenAutotypeRequest", {
// eslint-disable-next-line no-console windowTitle,
console.log("Window Title: " + result); });
});
result
? this.logService.info("Autotype enabled.")
: this.logService.info("Enabling autotype failed.");
} }
// TODO: this will call into desktop native code private doAutotype(username: string, password: string) {
private disableAutotype() { const inputPattern = username + "\t" + password;
// eslint-disable-next-line no-console const inputArray = new Array<number>(inputPattern.length);
console.log("Disabling Autotype...");
for (let i = 0; i < inputPattern.length; i++) {
inputArray[i] = inputPattern.charCodeAt(i);
}
autotype.typeInput(inputArray);
} }
} }

View File

@@ -127,4 +127,43 @@ export default {
}, },
); );
}, },
configureAutotype: (enabled: boolean) => {
ipcRenderer.send("autofill.configureAutotype", { enabled });
},
listenAutotypeRequest: (
fn: (
windowTitle: string,
completeCallback: (
error: Error | null,
response: { username?: string; password?: string },
) => void,
) => void,
) => {
ipcRenderer.on(
"autofill.listenAutotypeRequest",
(
event,
data: {
windowTitle: string;
},
) => {
const { windowTitle } = data;
fn(windowTitle, (error, response) => {
if (error) {
ipcRenderer.send("autofill.completeError", {
windowTitle,
error: error.message,
});
return;
}
ipcRenderer.send("autofill.completeAutotypeRequest", {
windowTitle,
response,
});
});
},
);
},
}; };

View File

@@ -1,12 +1,20 @@
import { combineLatest, map, Observable, of } from "rxjs"; import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { import {
GlobalStateProvider, GlobalStateProvider,
AUTOTYPE_SETTINGS_DISK, AUTOTYPE_SETTINGS_DISK,
KeyDefinition, KeyDefinition,
} from "@bitwarden/common/platform/state"; } 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 { UserId } from "@bitwarden/user-core";
export const AUTOTYPE_ENABLED = new KeyDefinition<boolean>( export const AUTOTYPE_ENABLED = new KeyDefinition<boolean>(
AUTOTYPE_SETTINGS_DISK, AUTOTYPE_SETTINGS_DISK,
@@ -20,28 +28,83 @@ export class DesktopAutotypeService {
autotypeEnabled$: Observable<boolean> = of(false); autotypeEnabled$: Observable<boolean> = of(false);
constructor( constructor(
private accountService: AccountService,
private authService: AuthService,
private cipherService: CipherService,
private configService: ConfigService, private configService: ConfigService,
private globalStateProvider: GlobalStateProvider, private globalStateProvider: GlobalStateProvider,
private isWindows: boolean, private platformUtilsService: PlatformUtilsService,
) { ) {
if (this.isWindows) { ipc.autofill.listenAutotypeRequest(async (windowTitle, callback) => {
const possibleCiphers = await this.matchCiphersToWindowTitle(windowTitle);
const firstCipher = possibleCiphers?.at(0);
return callback(null, {
username: firstCipher?.login?.username,
password: firstCipher?.login?.password,
});
});
}
async init() {
if (this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop) {
this.autotypeEnabled$ = combineLatest([ this.autotypeEnabled$ = combineLatest([
this.autotypeEnabledState.state$, this.autotypeEnabledState.state$,
this.configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype), this.configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype),
this.accountService.activeAccount$.pipe(
map((account) => account?.id),
switchMap((userId) => this.authService.authStatusFor$(userId)),
),
]).pipe( ]).pipe(
map( map(
([autotypeEnabled, windowsDesktopAutotypeFeatureFlag]) => ([autotypeEnabled, windowsDesktopAutotypeFeatureFlag, authStatus]) =>
autotypeEnabled && windowsDesktopAutotypeFeatureFlag, autotypeEnabled &&
windowsDesktopAutotypeFeatureFlag &&
authStatus == AuthenticationStatus.Unlocked,
), ),
); );
}
}
init() {} this.autotypeEnabled$.subscribe((enabled) => {
ipc.autofill.configureAutotype(enabled);
});
}
}
async setAutotypeEnabledState(enabled: boolean): Promise<void> { async setAutotypeEnabledState(enabled: boolean): Promise<void> {
await this.autotypeEnabledState.update(() => enabled, { await this.autotypeEnabledState.update(() => enabled, {
shouldUpdate: (currentlyEnabled) => currentlyEnabled !== enabled, shouldUpdate: (currentlyEnabled) => currentlyEnabled !== enabled,
}); });
} }
async matchCiphersToWindowTitle(windowTitle: string): Promise<CipherView[]> {
const URI_PREFIX = "APP:";
windowTitle = windowTitle.toLowerCase();
const ciphers = await firstValueFrom(
this.accountService.activeAccount$.pipe(
map((account) => account?.id),
filter((userId): userId is UserId => userId != null),
switchMap((userId) => this.cipherService.cipherViews$(userId)),
),
);
const possibleCiphers = ciphers.filter((c) => {
return (
c.login?.username &&
c.login?.password &&
c.deletedDate == null &&
c.login?.uris.some((u) => {
if (u.uri?.indexOf(URI_PREFIX) !== 0) {
return false;
}
const uri = u.uri.substring(4).toLowerCase();
return windowTitle.indexOf(uri) > -1;
})
);
});
return possibleCiphers;
}
} }

View File

@@ -37,7 +37,6 @@ import { NodeCryptoFunctionService } from "@bitwarden/node/services/node-crypto-
import { MainDesktopAutotypeService } from "./autofill/main/main-desktop-autotype.service"; import { MainDesktopAutotypeService } from "./autofill/main/main-desktop-autotype.service";
import { MainSshAgentService } from "./autofill/main/main-ssh-agent.service"; import { MainSshAgentService } from "./autofill/main/main-ssh-agent.service";
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service"; import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
import { DesktopAutotypeService } from "./autofill/services/desktop-autotype.service";
import { DesktopBiometricsService } from "./key-management/biometrics/desktop.biometrics.service"; import { DesktopBiometricsService } from "./key-management/biometrics/desktop.biometrics.service";
import { MainBiometricsIPCListener } from "./key-management/biometrics/main-biometrics-ipc.listener"; import { MainBiometricsIPCListener } from "./key-management/biometrics/main-biometrics-ipc.listener";
import { MainBiometricsService } from "./key-management/biometrics/main-biometrics.service"; import { MainBiometricsService } from "./key-management/biometrics/main-biometrics.service";
@@ -48,7 +47,6 @@ import { PowerMonitorMain } from "./main/power-monitor.main";
import { TrayMain } from "./main/tray.main"; import { TrayMain } from "./main/tray.main";
import { UpdaterMain } from "./main/updater.main"; import { UpdaterMain } from "./main/updater.main";
import { WindowMain } from "./main/window.main"; import { WindowMain } from "./main/window.main";
import { SlimConfigService } from "./platform/config/slim-config.service";
import { NativeAutofillMain } from "./platform/main/autofill/native-autofill.main"; import { NativeAutofillMain } from "./platform/main/autofill/native-autofill.main";
import { ClipboardMain } from "./platform/main/clipboard.main"; import { ClipboardMain } from "./platform/main/clipboard.main";
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener"; import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
@@ -307,13 +305,22 @@ export class Main {
void this.nativeAutofillMain.init(); void this.nativeAutofillMain.init();
this.mainDesktopAutotypeService = new MainDesktopAutotypeService( this.mainDesktopAutotypeService = new MainDesktopAutotypeService(
new DesktopAutotypeService( this.logService,
new SlimConfigService(this.environmentService, globalStateProvider), this.windowMain,
globalStateProvider,
process.platform === "win32",
),
); );
app
.whenReady()
.then(() => {
this.mainDesktopAutotypeService.init(); this.mainDesktopAutotypeService.init();
})
.catch((reason) => {
this.logService.error("Error initializing Autotype.", reason);
});
app.on("will-quit", () => {
this.mainDesktopAutotypeService.disableAutotype();
});
} }
bootstrap() { bootstrap() {

View File

@@ -1,66 +0,0 @@
import { combineLatest, map, Observable, throwError } from "rxjs";
import { SemVer } from "semver";
import {
FeatureFlag,
FeatureFlagValueType,
getFeatureFlagValue,
} from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { ServerSettings } from "@bitwarden/common/platform/models/domain/server-settings";
import { GLOBAL_SERVER_CONFIGURATIONS } from "@bitwarden/common/platform/services/config/default-config.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/user-core";
/*
NOT FOR GENERAL USE
If you have more uses for the config service in the main process,
please reach out to platform.
*/
export class SlimConfigService implements ConfigService {
constructor(
private environmentService: EnvironmentService,
private globalStateProvider: GlobalStateProvider,
) {}
serverConfig$: Observable<ServerConfig> = throwError(() => {
return new Error("Method not implemented.");
});
serverSettings$: Observable<ServerSettings> = throwError(() => {
return new Error("Method not implemented.");
});
cloudRegion$: Observable<Region> = throwError(() => {
return new Error("Method not implemented.");
});
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
return combineLatest([
this.environmentService.environment$,
this.globalStateProvider.get(GLOBAL_SERVER_CONFIGURATIONS).state$,
]).pipe(
map(([environment, serverConfigMap]) =>
getFeatureFlagValue(serverConfigMap?.[environment.getApiUrl()], key),
),
);
}
userCachedFeatureFlag$<Flag extends FeatureFlag>(
key: Flag,
userId: UserId,
): Observable<FeatureFlagValueType<Flag>> {
throw new Error("Method not implemented.");
}
getFeatureFlag<Flag extends FeatureFlag>(key: Flag): Promise<FeatureFlagValueType<Flag>> {
throw new Error("Method not implemented.");
}
checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer): Observable<boolean> {
throw new Error("Method not implemented.");
}
ensureConfigFetched(): Promise<void> {
throw new Error("Method not implemented.");
}
}

View File

@@ -98,3 +98,11 @@ export function cleanUserAgent(userAgent: string): string {
.replace(userAgentItem("Bitwarden", " "), "") .replace(userAgentItem("Bitwarden", " "), "")
.replace(userAgentItem("Electron", " "), ""); .replace(userAgentItem("Electron", " "), "");
} }
/**
* Returns `true` if the provided string is not undefined, not null, and not empty.
* Otherwise, returns `false`.
*/
export function stringIsNotUndefinedNullAndEmpty(str: string): boolean {
return str?.length > 0;
}