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:
@@ -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;
|
||||
|
||||
@@ -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$,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
352
apps/desktop/src/platform/auto-start/auto-start.service.spec.ts
Normal file
352
apps/desktop/src/platform/auto-start/auto-start.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
130
apps/desktop/src/platform/auto-start/auto-start.service.ts
Normal file
130
apps/desktop/src/platform/auto-start/auto-start.service.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
2
apps/desktop/src/platform/auto-start/index.ts
Normal file
2
apps/desktop/src/platform/auto-start/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AutoStartService, AutoStartStatus } from "./auto-start.service.abstraction";
|
||||
export { DefaultAutoStartService } from "./auto-start.service";
|
||||
Reference in New Issue
Block a user