Repository: oliverschwendener/ueli (ts)

Issue:
[Bug] Autostart option does not work when Ueli is installed via Microsoft Store
Autostart option does not work when Ueli is installed via Microsoft Store

**To Reproduce**
Steps to reproduce the behavior:
1. Open settings
2. Go to "General" settings
3. Enable "Autostart"
4. Check if app has been added to autostart apps in Windows Task Manager
5. The app is not listed

**Expected behavior**
That the app is added/removed to/from the autostart apps when toggling the "Autostart" option

**Environment**
 - OS: Windows
 - Version 9.0.2


Pull request description:
fix(Autostart): when app is installed through Windows Store
This fixes an issue where the autostart option wouldn't work when the app is installed through the Windows Store.

Fixes #1137

Patch:
diff --git a/src/main/Core/Autostart/AutostartManager.ts b/src/main/Core/Autostart/AutostartManager.ts
new file mode 100644
index 00000000..c8102117
--- /dev/null
+++ b/src/main/Core/Autostart/AutostartManager.ts
@@ -0,0 +1,4 @@
+export interface AutostartManager {
+    setAutostartOptions(openAtLogin: boolean): void;
+    autostartIsEnabled(): boolean;
+}
diff --git a/src/main/Core/Autostart/AutostartModule.ts b/src/main/Core/Autostart/AutostartModule.ts
index 5f1adc4c..e02cba24 100644
--- a/src/main/Core/Autostart/AutostartModule.ts
+++ b/src/main/Core/Autostart/AutostartModule.ts
@@ -1,11 +1,19 @@
 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) {
@@ -13,14 +21,10 @@ export class AutostartModule {
                 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),
diff --git a/src/main/Core/Autostart/DefaultAutostartManager.ts b/src/main/Core/Autostart/DefaultAutostartManager.ts
new file mode 100644
index 00000000..3865a5ca
--- /dev/null
+++ b/src/main/Core/Autostart/DefaultAutostartManager.ts
@@ -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;
+    }
+}
diff --git a/src/main/Core/Autostart/WindowsStoreAutostartManager.ts b/src/main/Core/Autostart/WindowsStoreAutostartManager.ts
new file mode 100644
index 00000000..4a9835da
--- /dev/null
+++ b/src/main/Core/Autostart/WindowsStoreAutostartManager.ts
@@ -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);
+        }
+    }
+}
diff --git a/src/main/Core/FileSystemUtility/Contract/FileSystemUtility.ts b/src/main/Core/FileSystemUtility/Contract/FileSystemUtility.ts
index 7b2e8dae..c3f4d032 100644
--- a/src/main/Core/FileSystemUtility/Contract/FileSystemUtility.ts
+++ b/src/main/Core/FileSystemUtility/Contract/FileSystemUtility.ts
@@ -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;
diff --git a/src/main/Core/FileSystemUtility/NodeJsFileSystemUtility.ts b/src/main/Core/FileSystemUtility/NodeJsFileSystemUtility.ts
index a8ea8b20..3ddda88b 100644
--- a/src/main/Core/FileSystemUtility/NodeJsFileSystemUtility.ts
+++ b/src/main/Core/FileSystemUtility/NodeJsFileSystemUtility.ts
@@ -1,4 +1,4 @@
-import { access, accessSync, existsSync, readFileSync, statSync, writeFileSync } from "fs";
+import { access, accessSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
 import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
 import { join } from "path";
 import type { FileSystemUtility } from "./Contract";
@@ -39,6 +39,10 @@ export class NodeJsFileSystemUtility implements FileSystemUtility {
         await rm(filePath, { recursive: true });
     }
 
+    public removeFileSync(filePath: string): void {
+        rmSync(filePath, { recursive: true });
+    }
+
     public async writeTextFile(data: string, filePath: string): Promise<void> {
         await writeFile(filePath, data, "utf-8");
     }


Test patch:
diff --git a/src/main/Core/Autostart/DefaultAutostartManager.test.ts b/src/main/Core/Autostart/DefaultAutostartManager.test.ts
new file mode 100644
index 00000000..fb45bbbd
--- /dev/null
+++ b/src/main/Core/Autostart/DefaultAutostartManager.test.ts
@@ -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();
+        });
+    });
+});
diff --git a/src/main/Core/Autostart/WindowsStoreAutostartManager.test.ts b/src/main/Core/Autostart/WindowsStoreAutostartManager.test.ts
new file mode 100644
index 00000000..4ff44506
--- /dev/null
+++ b/src/main/Core/Autostart/WindowsStoreAutostartManager.test.ts
@@ -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,
+            });
+        });
+    });
+});