1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-04 18:53:20 +00:00

Refactored auto-start into its own service.

This commit is contained in:
Todd Martin
2026-01-03 12:21:24 -05:00
parent 3558db0e0c
commit 755eb0e7e1
7 changed files with 573 additions and 67 deletions

View File

@@ -66,6 +66,7 @@ import { SshAgentPromptType } from "../../autofill/models/ssh-agent-setting";
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 { AutoStartService } from "../../platform/auto-start";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { DesktopPremiumUpgradePromptService } from "../../services/desktop-premium-upgrade-prompt.service";
import { NativeMessagingManifestService } from "../services/native-messaging-manifest.service";
@@ -220,6 +221,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private changeDetectorRef: ChangeDetectorRef,
private toastService: ToastService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private autoStartService: AutoStartService,
) {
this.isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
this.isLinux = this.platformUtilsService.getDevice() === DeviceType.LinuxDesktop;
@@ -244,7 +246,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.startToTrayText = this.i18nService.t(startToTrayKey);
this.startToTrayDescText = this.i18nService.t(startToTrayKey + "Desc");
this.showOpenAtLoginOption = !ipc.platform.isWindowsStore;
// Only show the auto-start setting if it's supported on this platform.
// The service handles platform-specific checks (Windows Store, Snap, etc.)
this.showOpenAtLoginOption = this.autoStartService.shouldDisplaySetting();
// DuckDuckGo browser is only for macos initially
this.showDuckDuckGoIntegrationOption = this.isMac;

View File

@@ -47,6 +47,7 @@ import { PowerMonitorMain } from "./main/power-monitor.main";
import { TrayMain } from "./main/tray.main";
import { UpdaterMain } from "./main/updater.main";
import { WindowMain } from "./main/window.main";
import { AutoStartService, AutoStartStatus, DefaultAutoStartService } from "./platform/auto-start";
import { NativeAutofillMain } from "./platform/main/autofill/native-autofill.main";
import { ClipboardMain } from "./platform/main/clipboard.main";
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
@@ -91,6 +92,7 @@ export class Main {
sshAgentService: MainSshAgentService;
sdkLoadService: SdkLoadService;
mainDesktopAutotypeService: MainDesktopAutotypeService;
autoStartService: AutoStartService;
constructor() {
// Set paths for portable builds
@@ -220,7 +222,12 @@ export class Main {
this.mainCryptoFunctionService,
);
this.messagingMain = new MessagingMain(this, this.desktopSettingsService);
this.autoStartService = new DefaultAutoStartService(this.logService);
this.messagingMain = new MessagingMain(
this,
this.desktopSettingsService,
this.autoStartService,
);
this.updaterMain = new UpdaterMain(this.i18nService, this.logService, this.windowMain);
const messageSubject = new Subject<Message<Record<string, unknown>>>();
@@ -323,11 +330,13 @@ export class Main {
this.migrationRunner.run().then(
async () => {
await this.toggleHardwareAcceleration();
// Reset modal mode to make sure main window is displayed correctly
await this.desktopSettingsService.resetModalMode();
// Initialize and reset desktop settings to ensure consistency
await this.initializeDesktopSettings();
await this.windowMain.init();
await this.i18nService.init();
await this.messagingMain.init();
this.messagingMain.init();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.menuMain.init();
@@ -411,6 +420,29 @@ export class Main {
});
}
/**
* Initializes desktop settings to ensure consistency between stored values and system state.
* This includes:
* - Resetting modal mode to prevent the app from being stuck in modal mode after force-close
* - Synchronizing openAtLogin with the actual system auto-start state (when queryable)
*/
private async initializeDesktopSettings(): Promise<void> {
// Reset modal mode to make sure main window is displayed correctly
await this.desktopSettingsService.resetModalMode();
// Initialize the openAtLogin setting based on the current system state.
// This allows for cases where the user modifies the system state outside of our application.
const autoStartStatus = await this.autoStartService.isEnabled();
// Only sync when we can reliably determine the system state.
// For platforms like Flatpak/Snap where the state is unknown, trust the stored value
// and don't update from the system.
if (autoStartStatus !== AutoStartStatus.Unknown) {
const isEnabled = autoStartStatus === AutoStartStatus.Enabled;
await this.desktopSettingsService.setOpenAtLogin(isEnabled);
}
}
private async toggleHardwareAcceleration(): Promise<void> {
const hardwareAcceleration = await firstValueFrom(
this.desktopSettingsService.hardwareAcceleration$,

View File

@@ -1,16 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as fs from "fs";
import * as path from "path";
import { app, ipcMain } from "electron";
import { ipcMain } from "electron";
import { firstValueFrom } from "rxjs";
import { autostart } from "@bitwarden/desktop-napi";
import { Main } from "../main";
import { AutoStartService } from "../platform/auto-start";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { isFlatpak, isLinux, isSnapStore } from "../utils";
import { MenuUpdateRequest } from "./menu/menu.updater";
@@ -22,19 +17,11 @@ export class MessagingMain {
constructor(
private main: Main,
private desktopSettingsService: DesktopSettingsService,
private autoStartService: AutoStartService,
) {}
async init() {
init() {
this.scheduleNextSync();
if (isLinux()) {
// Flatpak and snap don't have access to or use the startup file. On flatpak, the autostart portal is used
if (!isFlatpak() && !isSnapStore()) {
await this.desktopSettingsService.setOpenAtLogin(fs.existsSync(this.linuxStartupFile()));
}
} else {
const loginSettings = app.getLoginItemSettings();
await this.desktopSettingsService.setOpenAtLogin(loginSettings.openAtLogin);
}
ipcMain.on(
"messagingService",
async (event: any, message: any) => await this.onMessage(message),
@@ -78,10 +65,10 @@ export class MessagingMain {
this.main.trayMain.hideToTray();
break;
case "addOpenAtLogin":
this.addOpenAtLogin();
await this.autoStartService.enable();
break;
case "removeOpenAtLogin":
this.removeOpenAtLogin();
await this.autoStartService.disable();
break;
case "setFocus":
this.setFocus();
@@ -126,49 +113,6 @@ export class MessagingMain {
this.main.trayMain.updateContextMenu();
}
private addOpenAtLogin() {
if (process.platform === "linux") {
if (isFlatpak()) {
autostart.setAutostart(true, []).catch((e) => {});
} else {
const data = `[Desktop Entry]
Type=Application
Version=${app.getVersion()}
Name=Bitwarden
Comment=Bitwarden startup script
Exec=${app.getPath("exe")}
StartupNotify=false
Terminal=false`;
const dir = path.dirname(this.linuxStartupFile());
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
fs.writeFileSync(this.linuxStartupFile(), data);
}
} else {
app.setLoginItemSettings({ openAtLogin: true });
}
}
private removeOpenAtLogin() {
if (process.platform === "linux") {
if (isFlatpak()) {
autostart.setAutostart(false, []).catch((e) => {});
} else {
if (fs.existsSync(this.linuxStartupFile())) {
fs.unlinkSync(this.linuxStartupFile());
}
}
} else {
app.setLoginItemSettings({ openAtLogin: false });
}
}
private linuxStartupFile(): string {
return path.join(app.getPath("home"), ".config", "autostart", "bitwarden.desktop");
}
private setFocus() {
this.main.trayMain.restoreFromTray();
this.main.windowMain.win.focusOnWebView();

View File

@@ -0,0 +1,42 @@
/**
* Represents the state of the auto-start configuration.
*/
export const AutoStartStatus = {
/** Auto-start is enabled */
Enabled: "enabled",
/** Auto-start is disabled */
Disabled: "disabled",
/** Auto-start state cannot be determined (e.g., Flatpak/Snap) */
Unknown: "unknown",
} as const;
export type AutoStartStatus = (typeof AutoStartStatus)[keyof typeof AutoStartStatus];
/**
* Service for managing the application's auto-start behavior at system login.
*/
export abstract class AutoStartService {
/**
* Enables the application to automatically start when the user logs into their system.
*/
abstract enable(): Promise<void>;
/**
* Disables the application from automatically starting when the user logs into their system.
*/
abstract disable(): Promise<void>;
/**
* Checks whether the application is currently configured to start at login.
* @returns The auto-start status: `Enabled`, `Disabled`, or `Unknown` if the state cannot be determined.
*/
abstract isEnabled(): Promise<AutoStartStatus>;
/**
* Determines whether the auto-start setting should be displayed in the application UI.
* Some platforms (e.g., Snap) manage auto-start externally via package configuration,
* so the setting should be hidden from the user.
* @returns `true` if the setting should be shown, `false` if it should be hidden.
*/
abstract shouldDisplaySetting(): boolean;
}

View File

@@ -0,0 +1,352 @@
import * as fs from "fs";
import * as path from "path";
import { app } from "electron";
import { mock, MockProxy } from "jest-mock-extended";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { autostart } from "@bitwarden/desktop-napi";
import * as utils from "../../utils";
import { DefaultAutoStartService } from "./auto-start.service";
import { AutoStartStatus } from "./auto-start.service.abstraction";
// Mock modules
jest.mock("fs");
jest.mock("electron", () => ({
app: {
getVersion: jest.fn(),
getPath: jest.fn(),
setLoginItemSettings: jest.fn(),
getLoginItemSettings: jest.fn(),
},
}));
jest.mock("@bitwarden/desktop-napi", () => ({
autostart: {
setAutostart: jest.fn(),
},
}));
jest.mock("../../utils", () => ({
isFlatpak: jest.fn(),
isSnapStore: jest.fn(),
isWindowsStore: jest.fn(),
}));
describe("DefaultAutoStartService", () => {
let service: DefaultAutoStartService;
let logService: MockProxy<LogService>;
let originalPlatform: NodeJS.Platform;
beforeEach(() => {
logService = mock<LogService>();
service = new DefaultAutoStartService(logService);
originalPlatform = process.platform;
jest.clearAllMocks();
// Default mock implementations
(app.getVersion as jest.Mock).mockReturnValue("1.0.0");
(app.getPath as jest.Mock).mockImplementation((name: string) => {
if (name === "exe") {return "/usr/bin/bitwarden";}
if (name === "home") {return "/home/user";}
return "";
});
(utils.isFlatpak as jest.Mock).mockReturnValue(false);
(utils.isSnapStore as jest.Mock).mockReturnValue(false);
(utils.isWindowsStore as jest.Mock).mockReturnValue(false);
});
afterEach(() => {
Object.defineProperty(process, "platform", {
value: originalPlatform,
});
});
describe("Linux (Flatpak)", () => {
beforeEach(() => {
Object.defineProperty(process, "platform", {
value: "linux",
});
(utils.isFlatpak as jest.Mock).mockReturnValue(true);
(utils.isSnapStore as jest.Mock).mockReturnValue(false);
});
it("should enable autostart using the portal", async () => {
(autostart.setAutostart as jest.Mock).mockResolvedValue(undefined);
await service.enable();
expect(autostart.setAutostart).toHaveBeenCalledWith(true, []);
});
it("should disable autostart using the portal", async () => {
(autostart.setAutostart as jest.Mock).mockResolvedValue(undefined);
await service.disable();
expect(autostart.setAutostart).toHaveBeenCalledWith(false, []);
});
it("should handle portal errors gracefully when enabling", async () => {
const error = new Error("Portal error");
(autostart.setAutostart as jest.Mock).mockRejectedValue(error);
await service.enable();
expect(logService.error).toHaveBeenCalledWith(
"Failed to enable autostart via portal:",
error,
);
});
it("should handle portal errors gracefully when disabling", async () => {
const error = new Error("Portal error");
(autostart.setAutostart as jest.Mock).mockRejectedValue(error);
await service.disable();
expect(logService.error).toHaveBeenCalledWith(
"Failed to disable autostart via portal:",
error,
);
});
it("should return Unknown for isEnabled (cannot query portal state)", async () => {
const result = await service.isEnabled();
expect(result).toBe(AutoStartStatus.Unknown);
});
it("should display setting in UI", () => {
const result = service.shouldDisplaySetting();
expect(result).toBe(true);
});
});
describe("Linux (Snap)", () => {
beforeEach(() => {
Object.defineProperty(process, "platform", {
value: "linux",
});
(utils.isFlatpak as jest.Mock).mockReturnValue(false);
(utils.isSnapStore as jest.Mock).mockReturnValue(true);
});
it("should not create desktop file when enabling (snap manages autostart)", async () => {
await service.enable();
expect(fs.writeFileSync).not.toHaveBeenCalled();
expect(fs.mkdirSync).not.toHaveBeenCalled();
});
it("should not remove desktop file when disabling (snap manages autostart)", async () => {
await service.disable();
expect(fs.unlinkSync).not.toHaveBeenCalled();
});
it("should return Unknown for isEnabled (snap state cannot be queried)", async () => {
const result = await service.isEnabled();
expect(result).toBe(AutoStartStatus.Unknown);
});
it("should hide setting from UI (snap manages autostart)", () => {
const result = service.shouldDisplaySetting();
expect(result).toBe(false);
});
});
describe("Linux (Standard)", () => {
beforeEach(() => {
Object.defineProperty(process, "platform", {
value: "linux",
});
(utils.isFlatpak as jest.Mock).mockReturnValue(false);
(utils.isSnapStore as jest.Mock).mockReturnValue(false);
});
it("should create desktop file when enabling", async () => {
(fs.existsSync as jest.Mock).mockReturnValue(true);
await service.enable();
const expectedPath = path.join("/home/user", ".config", "autostart", "bitwarden.desktop");
const expectedContent = `[Desktop Entry]
Type=Application
Version=1.0.0
Name=Bitwarden
Comment=Bitwarden startup script
Exec=/usr/bin/bitwarden
StartupNotify=false
Terminal=false`;
expect(fs.writeFileSync).toHaveBeenCalledWith(expectedPath, expectedContent);
});
it("should create autostart directory if it does not exist", async () => {
(fs.existsSync as jest.Mock).mockReturnValue(false);
await service.enable();
const expectedDir = path.join("/home/user", ".config", "autostart");
expect(fs.mkdirSync).toHaveBeenCalledWith(expectedDir, { recursive: true });
});
it("should remove desktop file when disabling", async () => {
(fs.existsSync as jest.Mock).mockReturnValue(true);
await service.disable();
const expectedPath = path.join("/home/user", ".config", "autostart", "bitwarden.desktop");
expect(fs.unlinkSync).toHaveBeenCalledWith(expectedPath);
});
it("should not throw error when removing non-existent desktop file", async () => {
(fs.existsSync as jest.Mock).mockReturnValue(false);
await expect(service.disable()).resolves.not.toThrow();
expect(fs.unlinkSync).not.toHaveBeenCalled();
});
it("should return Enabled when desktop file exists", async () => {
(fs.existsSync as jest.Mock).mockReturnValue(true);
const result = await service.isEnabled();
expect(result).toBe(AutoStartStatus.Enabled);
const expectedPath = path.join("/home/user", ".config", "autostart", "bitwarden.desktop");
expect(fs.existsSync).toHaveBeenCalledWith(expectedPath);
});
it("should return Disabled when desktop file does not exist", async () => {
(fs.existsSync as jest.Mock).mockReturnValue(false);
const result = await service.isEnabled();
expect(result).toBe(AutoStartStatus.Disabled);
});
it("should display setting in UI", () => {
const result = service.shouldDisplaySetting();
expect(result).toBe(true);
});
});
describe("macOS", () => {
beforeEach(() => {
Object.defineProperty(process, "platform", {
value: "darwin",
});
});
it("should enable autostart using Electron API", async () => {
await service.enable();
expect(app.setLoginItemSettings).toHaveBeenCalledWith({ openAtLogin: true });
});
it("should disable autostart using Electron API", async () => {
await service.disable();
expect(app.setLoginItemSettings).toHaveBeenCalledWith({ openAtLogin: false });
});
it("should return Enabled when openAtLogin is enabled", async () => {
(app.getLoginItemSettings as jest.Mock).mockReturnValue({
openAtLogin: true,
openAsHidden: false,
});
const result = await service.isEnabled();
expect(result).toBe(AutoStartStatus.Enabled);
});
it("should return Disabled when openAtLogin is disabled", async () => {
(app.getLoginItemSettings as jest.Mock).mockReturnValue({
openAtLogin: false,
openAsHidden: false,
});
const result = await service.isEnabled();
expect(result).toBe(AutoStartStatus.Disabled);
});
it("should display setting in UI", () => {
const result = service.shouldDisplaySetting();
expect(result).toBe(true);
});
});
describe("Windows", () => {
beforeEach(() => {
Object.defineProperty(process, "platform", {
value: "win32",
});
(utils.isFlatpak as jest.Mock).mockReturnValue(false);
(utils.isSnapStore as jest.Mock).mockReturnValue(false);
});
it("should enable autostart using Electron API", async () => {
await service.enable();
expect(app.setLoginItemSettings).toHaveBeenCalledWith({ openAtLogin: true });
});
it("should disable autostart using Electron API", async () => {
await service.disable();
expect(app.setLoginItemSettings).toHaveBeenCalledWith({ openAtLogin: false });
});
it("should return Enabled when openAtLogin is enabled", async () => {
(app.getLoginItemSettings as jest.Mock).mockReturnValue({
openAtLogin: true,
openAsHidden: false,
});
const result = await service.isEnabled();
expect(result).toBe(AutoStartStatus.Enabled);
});
it("should return Disabled when openAtLogin is disabled", async () => {
(app.getLoginItemSettings as jest.Mock).mockReturnValue({
openAtLogin: false,
openAsHidden: false,
});
const result = await service.isEnabled();
expect(result).toBe(AutoStartStatus.Disabled);
});
it("should display setting in UI", () => {
const result = service.shouldDisplaySetting();
expect(result).toBe(true);
});
});
describe("Windows Store", () => {
beforeEach(() => {
Object.defineProperty(process, "platform", {
value: "win32",
});
(utils.isWindowsStore as jest.Mock).mockReturnValue(true);
});
it("should hide setting from UI (Windows Store doesn't support auto-start)", () => {
const result = service.shouldDisplaySetting();
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,130 @@
import * as fs from "fs";
import * as path from "path";
import { app } from "electron";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { autostart } from "@bitwarden/desktop-napi";
import { isFlatpak, isSnapStore, isWindowsStore } from "../../utils";
import { AutoStartService, AutoStartStatus } from "./auto-start.service.abstraction";
/**
* Default implementation of the AutoStartService for managing desktop auto-start behavior.
*
* The implementation varies by platform:
* - **Linux (Flatpak)**: Uses the XDG autostart portal via desktop-napi
* - **Linux (Standard)**: Creates a .desktop file in ~/.config/autostart/
* - **Linux (Snap)**: Auto-start is managed by snap configuration (not handled here)
* - **macOS/Windows**: Uses Electron's app.setLoginItemSettings() API
*/
export class DefaultAutoStartService implements AutoStartService {
constructor(private logService: LogService) {}
async enable(): Promise<void> {
if (process.platform === "linux") {
if (isFlatpak()) {
// Use the XDG autostart portal for Flatpak
await autostart.setAutostart(true, []).catch((e) => {
this.logService.error("Failed to enable autostart via portal:", e);
});
} else if (!isSnapStore()) {
// For standard Linux, create a .desktop file in autostart directory
// Snap auto-start is configured via electron-builder snap configuration
this.createDesktopFile();
}
} else {
// macOS and Windows use Electron's native API
app.setLoginItemSettings({ openAtLogin: true });
}
}
async disable(): Promise<void> {
if (process.platform === "linux") {
if (isFlatpak()) {
// Use the XDG autostart portal for Flatpak
await autostart.setAutostart(false, []).catch((e) => {
this.logService.error("Failed to disable autostart via portal:", e);
});
} else if (!isSnapStore()) {
// For standard Linux, remove the .desktop file
// Snap auto-start is configured via electron-builder snap configuration
this.removeDesktopFile();
}
} else {
// macOS and Windows use Electron's native API
app.setLoginItemSettings({ openAtLogin: false });
}
}
async isEnabled(): Promise<AutoStartStatus> {
if (process.platform === "linux") {
if (isFlatpak() || isSnapStore()) {
// For Flatpak/Snap, we can't reliably check the state from within the app.
// The autostart portal (Flatpak) and snap configuration don't provide query APIs.
return AutoStartStatus.Unknown;
} else {
// For standard Linux, check if the desktop file exists.
return fs.existsSync(this.getLinuxDesktopFilePath())
? AutoStartStatus.Enabled
: AutoStartStatus.Disabled;
}
} else {
// macOS and Windows use Electron's native API.
const loginSettings = app.getLoginItemSettings();
return loginSettings.openAtLogin ? AutoStartStatus.Enabled : AutoStartStatus.Disabled;
}
}
/**
* Creates the .desktop file for Linux autostart.
*/
private createDesktopFile(): void {
const desktopFileContent = `[Desktop Entry]
Type=Application
Version=${app.getVersion()}
Name=Bitwarden
Comment=Bitwarden startup script
Exec=${app.getPath("exe")}
StartupNotify=false
Terminal=false`;
const filePath = this.getLinuxDesktopFilePath();
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, desktopFileContent);
}
/**
* Removes the .desktop file for Linux autostart.
*/
private removeDesktopFile(): void {
const filePath = this.getLinuxDesktopFilePath();
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
shouldDisplaySetting(): boolean {
// Windows Store apps don't support auto-start functionality.
// On Snap, auto-start is managed by the snap configuration (electron-builder.json).
if (isWindowsStore() || isSnapStore()) {
return false;
}
// All other platforms support user-configurable auto-start
return true;
}
/**
* Gets the path to the Linux autostart .desktop file.
*/
private getLinuxDesktopFilePath(): string {
return path.join(app.getPath("home"), ".config", "autostart", "bitwarden.desktop");
}
}

View File

@@ -0,0 +1,2 @@
export { AutoStartService, AutoStartStatus } from "./auto-start.service.abstraction";
export { DefaultAutoStartService } from "./auto-start.service";