Skip to content

Commit

Permalink
fix: autostart options when installed through Windows Store (#1231)
Browse files Browse the repository at this point in the history
  • Loading branch information
oliverschwendener authored Oct 12, 2024
1 parent 93234a8 commit d00d9f5
Show file tree
Hide file tree
Showing 8 changed files with 350 additions and 7 deletions.
4 changes: 4 additions & 0 deletions src/main/Core/Autostart/AutostartManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface AutostartManager {
setAutostartOptions(openAtLogin: boolean): void;
autostartIsEnabled(): boolean;
}
16 changes: 10 additions & 6 deletions src/main/Core/Autostart/AutostartModule.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
import type { Dependencies } from "@Core/Dependencies";
import type { DependencyRegistry } from "@Core/DependencyRegistry";
import { DefaultAutostartManager } from "./DefaultAutostartManager";
import { WindowsStoreAutostartManager } from "./WindowsStoreAutostartManager";

export class AutostartModule {
public static bootstrap(dependencyRegistry: DependencyRegistry<Dependencies>) {
const app = dependencyRegistry.get("App");
const logger = dependencyRegistry.get("Logger");
const ipcMain = dependencyRegistry.get("IpcMain");
const shell = dependencyRegistry.get("Shell");
const fileSystemUtility = dependencyRegistry.get("FileSystemUtility");

const autostartManager = process.windowsStore
? new WindowsStoreAutostartManager(app, shell, process, fileSystemUtility, logger)
: new DefaultAutostartManager(app, process);

const setAutostartOptions = (openAtLogin: boolean) => {
if (!app.isPackaged) {
logger.info("Skipping setAutostartOptions. Reason: app is not packaged");
return;
}

app.setLoginItemSettings({
args: [],
openAtLogin,
path: process.execPath,
});
autostartManager.setAutostartOptions(openAtLogin);
};

ipcMain.on("autostartIsEnabled", (event) => (event.returnValue = app.getLoginItemSettings().openAtLogin));
ipcMain.on("autostartIsEnabled", (event) => (event.returnValue = autostartManager.autostartIsEnabled()));

ipcMain.on("autostartSettingsChanged", (_, { autostartIsEnabled }: { autostartIsEnabled: boolean }) =>
setAutostartOptions(autostartIsEnabled),
Expand Down
40 changes: 40 additions & 0 deletions src/main/Core/Autostart/DefaultAutostartManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { App } from "electron";
import { describe, expect, it, vi } from "vitest";
import { DefaultAutostartManager } from "./DefaultAutostartManager";

describe(DefaultAutostartManager, () => {
describe(DefaultAutostartManager.prototype.setAutostartOptions, () => {
const testSetAutostartOptions = ({ openAtLogin }: { openAtLogin: boolean }) => {
const process = <NodeJS.Process>{ execPath: "execPath" };
const setLoginItemSettingsMock = vi.fn();
const app = <App>{ setLoginItemSettings: (s) => setLoginItemSettingsMock(s) };

new DefaultAutostartManager(app, process).setAutostartOptions(openAtLogin);

expect(setLoginItemSettingsMock).toHaveBeenCalledOnce();
expect(setLoginItemSettingsMock).toHaveBeenCalledWith({ args: [], openAtLogin, path: "execPath" });
};

it("should set autostart the correct login items when openAtLogin is true", () =>
testSetAutostartOptions({ openAtLogin: true }));

it("should set autostart the correct login items when openAtLogin is false", () =>
testSetAutostartOptions({ openAtLogin: false }));
});

describe(DefaultAutostartManager.prototype.autostartIsEnabled, () => {
it("should return true when openAtLogin is true", () => {
const getLoginItemSettingsMock = vi.fn().mockReturnValue({ openAtLogin: true });
const app = <App>{ getLoginItemSettings: () => getLoginItemSettingsMock() };
expect(new DefaultAutostartManager(app, <NodeJS.Process>{}).autostartIsEnabled()).toBe(true);
expect(getLoginItemSettingsMock).toHaveBeenCalledOnce();
});

it("should return true when openAtLogin is true", () => {
const getLoginItemSettingsMock = vi.fn().mockReturnValue({ openAtLogin: false });
const app = <App>{ getLoginItemSettings: () => getLoginItemSettingsMock() };
expect(new DefaultAutostartManager(app, <NodeJS.Process>{}).autostartIsEnabled()).toBe(false);
expect(getLoginItemSettingsMock).toHaveBeenCalledOnce();
});
});
});
21 changes: 21 additions & 0 deletions src/main/Core/Autostart/DefaultAutostartManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { App } from "electron";
import type { AutostartManager } from "./AutostartManager";

export class DefaultAutostartManager implements AutostartManager {
public constructor(
private readonly app: App,
private readonly process: NodeJS.Process,
) {}

public setAutostartOptions(openAtLogin: boolean): void {
this.app.setLoginItemSettings({
args: [],
openAtLogin,
path: this.process.execPath,
});
}

public autostartIsEnabled(): boolean {
return this.app.getLoginItemSettings().openAtLogin;
}
}
202 changes: 202 additions & 0 deletions src/main/Core/Autostart/WindowsStoreAutostartManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import type { FileSystemUtility } from "@Core/FileSystemUtility";
import type { Logger } from "@Core/Logger";
import type { App, Shell } from "electron";
import { join } from "path";
import { describe, expect, it, vi } from "vitest";
import { WindowsStoreAutostartManager } from "./WindowsStoreAutostartManager";

describe(WindowsStoreAutostartManager, () => {
describe(WindowsStoreAutostartManager.prototype.autostartIsEnabled, () => {
const testAutostartIsEnabled = ({
execPath,
shortcutFileExists,
shortcutFileReadError,
shortcutTarget,
expected,
}: {
execPath: string;
shortcutFileExists: boolean;
shortcutFileReadError?: string;
shortcutTarget?: string;
expected: boolean;
}) => {
const getPathMock = vi.fn().mockReturnValue("AppData");
const app = <App>{ getPath: (p) => getPathMock(p) };

const existsSyncMock = vi.fn().mockReturnValue(shortcutFileExists);
const fileSystemUtility = <FileSystemUtility>{ existsSync: (p) => existsSyncMock(p) };

const readShortcutLinkMock = shortcutFileReadError
? vi.fn().mockImplementationOnce(() => {
throw new Error(shortcutFileReadError);
})
: vi.fn().mockReturnValue({ target: shortcutTarget });

const shell = <Shell>{ readShortcutLink: (p) => readShortcutLinkMock(p) };

const logErrorMock = vi.fn();
const logger = <Logger>{ error: (m) => logErrorMock(m) };

const process = <NodeJS.Process>{ execPath };

expect(
new WindowsStoreAutostartManager(app, shell, process, fileSystemUtility, logger).autostartIsEnabled(),
).toBe(expected);

expect(getPathMock).toHaveBeenCalledOnce();
expect(getPathMock).toHaveBeenCalledWith("appData");

const shortcutFilePath = join(
"AppData",
"Microsoft",
"Windows",
"Start Menu",
"Programs",
"Startup",
"Ueli.lnk",
);

expect(existsSyncMock).toHaveBeenCalledOnce();
expect(existsSyncMock).toHaveBeenCalledWith(shortcutFilePath);

if (shortcutFileReadError) {
expect(logErrorMock).toHaveBeenCalledOnce();
expect(logErrorMock).toHaveBeenCalledWith(
`Failed to read shortcut link "${shortcutFilePath}". Reason: ${new Error(shortcutFileReadError)}`,
);
}
};

it("should return false when shortcut file does not exist", () => {
testAutostartIsEnabled({
shortcutFileExists: false,
expected: false,
execPath: "execPath",
});
});

it("should return false when shortcut file exists but cant be read", () => {
testAutostartIsEnabled({
shortcutFileExists: true,
expected: false,
shortcutFileReadError: "some error",
execPath: "execPath",
});
});

it("should return false when shortcut file exists but target is not current process exec path", () => {
testAutostartIsEnabled({
shortcutFileExists: true,
shortcutTarget: "other target",
execPath: "execPath",
expected: false,
});
});

it("should return true when shortcut file exists and target is same as current process exec path", () => {
testAutostartIsEnabled({
shortcutFileExists: true,
shortcutTarget: "execPath",
execPath: "execPath",
expected: true,
});
});
});

describe(WindowsStoreAutostartManager.prototype.setAutostartOptions, () => {
const testSetAutostartOptions = ({
openAtLogin,
shortcutFileExists,
expectedShortcutOperation,
expectShortcutFileRemoval,
}: {
openAtLogin: boolean;
shortcutFileExists: boolean;
expectedShortcutOperation?: "create" | "replace";
expectShortcutFileRemoval: boolean;
}) => {
const getPathMock = vi.fn().mockReturnValue("AppData");
const app = <App>{ getPath: (p) => getPathMock(p) };

const existsSyncMock = vi.fn().mockReturnValue(shortcutFileExists);
const removeFileSyncMock = vi.fn();
const fileSystemUtility = <FileSystemUtility>{
existsSync: (p) => existsSyncMock(p),
removeFileSync: (p) => removeFileSyncMock(p),
};

const writeShortcutLinkMock = vi.fn();
const shell = <Shell>{
writeShortcutLink: (path, operation, options) => writeShortcutLinkMock(path, operation, options),
};

const process = <NodeJS.Process>{ execPath: "execPath" };

const shortcutFilePath = join(
"AppData",
"Microsoft",
"Windows",
"Start Menu",
"Programs",
"Startup",
"Ueli.lnk",
);

new WindowsStoreAutostartManager(app, shell, process, fileSystemUtility, null).setAutostartOptions(
openAtLogin,
);

expect(getPathMock).toHaveBeenCalledOnce();
expect(getPathMock).toHaveBeenCalledWith("appData");

expect(existsSyncMock).toHaveBeenCalledOnce();
expect(existsSyncMock).toHaveBeenCalledWith(shortcutFilePath);

if (expectShortcutFileRemoval) {
expect(removeFileSyncMock).toHaveBeenCalledOnce();
expect(removeFileSyncMock).toHaveBeenCalledWith(shortcutFilePath);
}

if (expectedShortcutOperation) {
expect(writeShortcutLinkMock).toHaveBeenCalledOnce();
expect(writeShortcutLinkMock).toHaveBeenCalledWith(shortcutFilePath, expectedShortcutOperation, {
target: "execPath",
});
}
};

it("should create autostart shortcut when openAtLogin is true and shortcut does not exist yet", () => {
testSetAutostartOptions({
openAtLogin: true,
shortcutFileExists: false,
expectedShortcutOperation: "create",
expectShortcutFileRemoval: false,
});
});

it("should update autostart shortcut when openAtLogin is true and shortcut already exists", () => {
testSetAutostartOptions({
openAtLogin: true,
shortcutFileExists: true,
expectedShortcutOperation: "replace",
expectShortcutFileRemoval: false,
});
});

it("should remove shortcut file when openAtLogin is false and shorcut file exists", () => {
testSetAutostartOptions({
openAtLogin: false,
shortcutFileExists: true,
expectShortcutFileRemoval: true,
});
});

it("should do nothing when openAtLogin is false and shorcut file does notexists", () => {
testSetAutostartOptions({
openAtLogin: false,
shortcutFileExists: false,
expectShortcutFileRemoval: false,
});
});
});
});
67 changes: 67 additions & 0 deletions src/main/Core/Autostart/WindowsStoreAutostartManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { FileSystemUtility } from "@Core/FileSystemUtility";
import type { Logger } from "@Core/Logger";
import type { App, Shell } from "electron";
import { join } from "path";
import type { AutostartManager } from "./AutostartManager";

export class WindowsStoreAutostartManager implements AutostartManager {
public constructor(
private readonly app: App,
private readonly shell: Shell,
private readonly process: NodeJS.Process,
private readonly fileSystemUtility: FileSystemUtility,
private readonly logger: Logger,
) {}

public setAutostartOptions(openAtLogin: boolean): void {
const shortcutFilePath = this.getShortcutFilePath();

if (openAtLogin) {
this.createAutostartShortcut(shortcutFilePath);
} else {
this.removeAutostartShortcut(shortcutFilePath);
}
}

public autostartIsEnabled(): boolean {
const shortcutFilePath = this.getShortcutFilePath();

if (!this.fileSystemUtility.existsSync(shortcutFilePath)) {
return false;
}

try {
const shortcutLink = this.shell.readShortcutLink(shortcutFilePath);
return shortcutLink.target === this.process.execPath;
} catch (error) {
this.logger.error(`Failed to read shortcut link "${shortcutFilePath}". Reason: ${error}`);
return false;
}
}

private getShortcutFilePath(): string {
return join(
this.app.getPath("appData"),
"Microsoft",
"Windows",
"Start Menu",
"Programs",
"Startup",
"Ueli.lnk",
);
}

private createAutostartShortcut(shortcutFilePath: string): void {
const shortcutFileExists = this.fileSystemUtility.existsSync(shortcutFilePath);

this.shell.writeShortcutLink(shortcutFilePath, shortcutFileExists ? "replace" : "create", {
target: this.process.execPath,
});
}

private removeAutostartShortcut(shortcutFilePath: string): void {
if (this.fileSystemUtility.existsSync(shortcutFilePath)) {
this.fileSystemUtility.removeFileSync(shortcutFilePath);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface FileSystemUtility {
readJsonFileSync<T>(filePath: string): T;
readDirectory(folderPath: string, recursive?: boolean): Promise<string[]>;
removeFile(filePath: string): Promise<void>;
removeFileSync(filePath: string): void;
writeTextFile(data: string, filePath: string): Promise<void>;
writeJsonFile<T>(data: T, filePath: string): Promise<void>;
writeJsonFileSync<T>(data: T, filePath: string): void;
Expand Down
Loading

0 comments on commit d00d9f5

Please sign in to comment.