Repository: oliverschwendener/ueli (ts)

Issue:
[Feature Request] sharing settings between computers, win/mac specially
The beauty of a cross-system app only makes sense if user can share settings between systems, so aliases, links, etc, are always updated.

Is there a folder settings that user can point somewhere else, like dropbox, google drive, nextcloud, and sync settings?

Pull request:
feat(CustomSettingsFilePath): add new implementation
Here we go again...

The goal of this PR is to allow users to set custom settings file paths.

![image](https://github.com/user-attachments/assets/0be7fe0c-aae8-471f-9bc7-b82c7ba950fd)

Fixes #1023

Patch:
diff --git a/src/common/Core/ContextBridge.ts b/src/common/Core/ContextBridge.ts
index 175a4d00..df9404e4 100644
--- a/src/common/Core/ContextBridge.ts
+++ b/src/common/Core/ContextBridge.ts
@@ -24,6 +24,8 @@ export type ContextBridge = {
         on: IpcRenderer["on"];
         off: IpcRenderer["off"];
         send: IpcRenderer["send"];
+        sendSync: IpcRenderer["sendSync"];
+        invoke: IpcRenderer["invoke"];
     };
 
     autostartIsEnabled: () => boolean;
@@ -58,6 +60,7 @@ export type ContextBridge = {
     removeExcludedSearchResultItem: (itemId: string) => Promise<void>;
     removeFavorite: (id: string) => Promise<void>;
     resetAllSettings: () => Promise<void>;
+    restartApp: () => void;
     searchIndexCacheFileExists: () => boolean;
     showOpenDialog: (options: OpenDialogOptions) => Promise<OpenDialogReturnValue>;
     showSaveDialog: (options: SaveDialogOptions) => Promise<SaveDialogReturnValue>;
diff --git a/src/main/Core/App/AppModule.ts b/src/main/Core/App/AppModule.ts
new file mode 100644
index 00000000..b1a87162
--- /dev/null
+++ b/src/main/Core/App/AppModule.ts
@@ -0,0 +1,13 @@
+import type { UeliModuleRegistry } from "@Core/ModuleRegistry";
+
+export class App {
+    public static bootstrap(moduleRegistry: UeliModuleRegistry) {
+        const app = moduleRegistry.get("App");
+        const ipcMain = moduleRegistry.get("IpcMain");
+
+        ipcMain.on("restartApp", () => {
+            app.relaunch();
+            app.exit();
+        });
+    }
+}
diff --git a/src/main/Core/App/index.ts b/src/main/Core/App/index.ts
new file mode 100644
index 00000000..e3e636e2
--- /dev/null
+++ b/src/main/Core/App/index.ts
@@ -0,0 +1,1 @@
+export * from "./AppModule";
diff --git a/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathResolver.ts b/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathResolver.ts
new file mode 100644
index 00000000..5e0b6f2f
--- /dev/null
+++ b/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathResolver.ts
@@ -0,0 +1,42 @@
+import type { FileSystemUtility } from "@Core/FileSystemUtility";
+import type { App } from "electron";
+import { join } from "path";
+
+type Config = { settingsFilePath?: string };
+
+export class CustomSettingsFilePathResolver {
+    private configFilePath: string;
+
+    public constructor(
+        private readonly app: App,
+        private readonly fileSystemUtility: FileSystemUtility,
+    ) {
+        this.configFilePath = join(this.app.getPath("home"), "ueli9.config.json");
+    }
+
+    public isEnabled(): boolean {
+        return this.resolve() !== undefined;
+    }
+
+    public resolve(): string | undefined {
+        return this.getConfig().settingsFilePath;
+    }
+
+    public async writeFilePathToConfigFile(filePath: string) {
+        const config = this.getConfig();
+        config.settingsFilePath = filePath;
+        await this.fileSystemUtility.writeJsonFile(config, this.configFilePath);
+    }
+
+    public async remove() {
+        const config = this.getConfig();
+        delete config.settingsFilePath;
+        this.fileSystemUtility.writeJsonFileSync(config, this.configFilePath);
+    }
+
+    private getConfig(): Config {
+        return this.fileSystemUtility.existsSync(this.configFilePath)
+            ? this.fileSystemUtility.readJsonFileSync<Config>(this.configFilePath)
+            : {};
+    }
+}
diff --git a/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathValidator.ts b/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathValidator.ts
new file mode 100644
index 00000000..be2dbcbc
--- /dev/null
+++ b/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathValidator.ts
@@ -0,0 +1,18 @@
+import type { FileSystemUtility } from "@Core/FileSystemUtility";
+
+export class CustomSettingsFilePathValidator {
+    public constructor(private readonly fileSystemUtility: FileSystemUtility) {}
+
+    public validate(filePath: string): boolean {
+        if (!this.fileSystemUtility.existsSync(filePath)) {
+            return false;
+        }
+
+        try {
+            this.fileSystemUtility.readJsonFileSync(filePath);
+            return true;
+        } catch (error) {
+            return false;
+        }
+    }
+}
diff --git a/src/main/Core/SettingsFile/Resolver/DefaultSettingsFilePathResolver.ts b/src/main/Core/SettingsFile/Resolver/DefaultSettingsFilePathResolver.ts
new file mode 100644
index 00000000..874efab6
--- /dev/null
+++ b/src/main/Core/SettingsFile/Resolver/DefaultSettingsFilePathResolver.ts
@@ -0,0 +1,10 @@
+import type { App } from "electron";
+import { join } from "path";
+
+export class DefaultSettingsFilePathResolver {
+    public constructor(private readonly app: App) {}
+
+    public resolve(): string {
+        return join(this.app.getPath("userData"), "ueli9.settings.json");
+    }
+}
diff --git a/src/main/Core/SettingsFile/Resolver/index.ts b/src/main/Core/SettingsFile/Resolver/index.ts
new file mode 100644
index 00000000..6170f1d8
--- /dev/null
+++ b/src/main/Core/SettingsFile/Resolver/index.ts
@@ -0,0 +1,2 @@
+export * from "./CustomSettingsFilePathResolver";
+export * from "./DefaultSettingsFilePathResolver";
diff --git a/src/main/Core/SettingsFile/SettingsFileModule.ts b/src/main/Core/SettingsFile/SettingsFileModule.ts
index 16093a15..f7e542b8 100644
--- a/src/main/Core/SettingsFile/SettingsFileModule.ts
+++ b/src/main/Core/SettingsFile/SettingsFileModule.ts
@@ -1,12 +1,46 @@
 import type { UeliModuleRegistry } from "@Core/ModuleRegistry";
-import { join } from "path";
+import { CustomSettingsFilePathResolver, DefaultSettingsFilePathResolver } from "./Resolver";
+import { CustomSettingsFilePathValidator } from "./Resolver/CustomSettingsFilePathValidator";
 
 export class SettingsFileModule {
     public static bootstrap(moduleRegistry: UeliModuleRegistry) {
-        const app = moduleRegistry.get("App");
+        const customSettingsFilePathResolver = new CustomSettingsFilePathResolver(
+            moduleRegistry.get("App"),
+            moduleRegistry.get("FileSystemUtility"),
+        );
+
+        const customSettingsFilePathValidator = new CustomSettingsFilePathValidator(
+            moduleRegistry.get("FileSystemUtility"),
+        );
+
+        const defaultSettingsFilePathResolver = new DefaultSettingsFilePathResolver(moduleRegistry.get("App"));
+
+        const resolveFilePath = () =>
+            customSettingsFilePathResolver.resolve() ?? defaultSettingsFilePathResolver.resolve();
+
+        const ipcMain = moduleRegistry.get("IpcMain");
+
+        ipcMain.on("removeCustomSettingsFilePath", () => {
+            customSettingsFilePathResolver.remove();
+        });
+
+        ipcMain.handle("setCustomSettingsFilePath", async (_, { filePath }: { filePath: string }) => {
+            await customSettingsFilePathResolver.writeFilePathToConfigFile(filePath);
+        });
+
+        ipcMain.on("getCustomSettingsFilePath", (event) => {
+            event.returnValue = customSettingsFilePathResolver.resolve();
+        });
+
+        ipcMain.on("isValidSettingsFile", (event, { filePath }: { filePath: string }) => {
+            event.returnValue = customSettingsFilePathValidator.validate(filePath);
+        });
+
+        const logger = moduleRegistry.get("Logger");
+        logger.info(`Reading settings from ${resolveFilePath()}`);
 
         moduleRegistry.register("SettingsFile", {
-            path: join(app.getPath("userData"), "ueli9.settings.json"),
+            path: resolveFilePath(),
         });
     }
 }
diff --git a/src/main/Core/index.ts b/src/main/Core/index.ts
index 3db80bf0..c7542a78 100644
--- a/src/main/Core/index.ts
+++ b/src/main/Core/index.ts
@@ -1,5 +1,6 @@
 export * from "./AboutUeli";
 export * from "./ActionHandler";
+export * from "./App";
 export * from "./AppleScriptUtility";
 export * from "./AssetPathResolver";
 export * from "./Autostart";
diff --git a/src/main/index.ts b/src/main/index.ts
index dcd21d23..e0ac25b7 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -44,6 +44,7 @@ if (!app.requestSingleInstanceLock()) {
     moduleRegistry.register("SystemPreferences", systemPreferences);
 
     // Core Modules
+    Core.App.bootstrap(moduleRegistry);
     Core.OperatingSystemModule.bootstrap(moduleRegistry);
     Core.CommandlineSwitchModule.bootstrap(moduleRegistry);
     Core.TaskSchedulerModule.bootstrap(moduleRegistry);
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 06dc9a01..8d6f5bb7 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -6,6 +6,8 @@ const contextBridgeImplementation: ContextBridge = {
         on: (channel, listener) => ipcRenderer.on(channel, listener),
         off: (channel, listener) => ipcRenderer.off(channel, listener),
         send: (channel, args) => ipcRenderer.send(channel, args),
+        sendSync: (channel, args) => ipcRenderer.sendSync(channel, args),
+        invoke: (channel, args) => ipcRenderer.invoke(channel, args),
     },
 
     autostartIsEnabled: () => ipcRenderer.sendSync("autostartIsEnabled"),
@@ -47,6 +49,7 @@ const contextBridgeImplementation: ContextBridge = {
         ipcRenderer.invoke("removeExcludedSearchResultItem", { itemId }),
     removeFavorite: (id) => ipcRenderer.invoke("removeFavorite", { id }),
     resetAllSettings: () => ipcRenderer.invoke("resetAllSettings"),
+    restartApp: () => ipcRenderer.send("restartApp"),
     showOpenDialog: (options) => ipcRenderer.invoke("showOpenDialog", { options }),
     showSaveDialog: (options) => ipcRenderer.invoke("showSaveDialog", { options }),
     triggerExtensionRescan: (extensionId) => ipcRenderer.invoke("triggerExtensionRescan", { extensionId }),
diff --git a/src/renderer/Core/I18n/getCoreResources.ts b/src/renderer/Core/I18n/getCoreResources.ts
index 9d4de4e2..f90b548b 100644
--- a/src/renderer/Core/I18n/getCoreResources.ts
+++ b/src/renderer/Core/I18n/getCoreResources.ts
@@ -82,6 +82,20 @@ export const getCoreResources = (): { namespace: string; resources: Resources<Tr
                     importExportDescription: "Import or export all settings from or to a JSON file",
                     importSettings: "Import",
                     exportSettings: "Export",
+                    customSettingsFilePath: "Custom settings file path",
+                    setCustomSettingsFilePath: "Set custom settings file path",
+                    selectFile: "Select file",
+                    customSettingsFilePathDialogTitle: "A custom settings file path has been set",
+                    customSettingsFilePathDialogDescription:
+                        "To make the custom settings file path take effect, you need to restart Ueli. Do you want to restart now?",
+                    restartNow: "Yes, restart now",
+                    no: "No",
+                    remove: "Remove",
+                    customSettingsFileValidationDialogTitle:
+                        "The selected file doesn't seem to be a valid settings file",
+                    customSettingsFileValidationDialogDescription:
+                        "Make sure that the selected file exists and contains valid JSON.",
+                    close: "Close",
                 },
                 "de-CH": {
                     title: "Allgemein",
@@ -119,6 +133,20 @@ export const getCoreResources = (): { namespace: string; resources: Resources<Tr
                         "Importiere oder exportiere alle Einstellungen von oder in eine JSON-Datei",
                     importSettings: "Import",
                     exportSettings: "Export",
+                    customSettingsFilePath: "Benutzerdefinierter Einstellungsdateipfad",
+                    setCustomSettingsFilePath: "Benutzerdefinierten Einstellungsdateipfad festlegen",
+                    selectFile: "Datei auswählen",
+                    customSettingsFilePathDialogTitle: "Ein benutzerdefinierter Einstellungsdateipfad wurde festgelegt",
+                    customSettingsFilePathDialogDescription:
+                        "Um den benutzerdefinierten Einstellungsdateipfad wirksam zu machen, musst du Ueli neu starten. Willst du jetzt neu starten?",
+                    restartNow: "Ja, jetzt neu starten",
+                    no: "Nein",
+                    remove: "Entfernen",
+                    customSettingsFileValidationDialogTitle:
+                        "Die ausgewählte Datei scheint keine gültige Einstellungsdatei zu sein",
+                    customSettingsFileValidationDialogDescription:
+                        "Stelle sicher, dass die ausgewählte Datei existiert und gültiges JSON enthält.",
+                    close: "Schliessen",
                 },
             },
         },
diff --git a/src/renderer/Core/Settings/Pages/General/General.tsx b/src/renderer/Core/Settings/Pages/General/General.tsx
index 02f3ef9d..095acb5f 100644
--- a/src/renderer/Core/Settings/Pages/General/General.tsx
+++ b/src/renderer/Core/Settings/Pages/General/General.tsx
@@ -8,6 +8,7 @@ import { HotkeySettings } from "./HotKey";
 import { ImportExport } from "./ImportExport";
 import { Language } from "./Language";
 import { SearchHistory } from "./SearchHistory";
+import { SetCustomSettingsFilePath } from "./SetCustomSettingsFilePath";
 import { UrlImageGenerator } from "./UrlImageGenerator";
 
 export const General = () => {
@@ -32,6 +33,9 @@ export const General = () => {
             <SettingGroup title={t("importExport")}>
                 <ImportExport />
             </SettingGroup>
+            <SettingGroup title={t("customSettingsFilePath")}>
+                <SetCustomSettingsFilePath />
+            </SettingGroup>
         </SettingGroupList>
     );
 };
diff --git a/src/renderer/Core/Settings/Pages/General/SetCustomSettingsFilePath.tsx b/src/renderer/Core/Settings/Pages/General/SetCustomSettingsFilePath.tsx
new file mode 100644
index 00000000..e4232f51
--- /dev/null
+++ b/src/renderer/Core/Settings/Pages/General/SetCustomSettingsFilePath.tsx
@@ -0,0 +1,115 @@
+import { Setting } from "@Core/Settings/Setting";
+import {
+    Button,
+    Dialog,
+    DialogActions,
+    DialogBody,
+    DialogContent,
+    DialogSurface,
+    DialogTitle,
+    DialogTrigger,
+    Tooltip,
+} from "@fluentui/react-components";
+import { DismissRegular, DocumentRegular } from "@fluentui/react-icons";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+export const SetCustomSettingsFilePath = () => {
+    const { t } = useTranslation("settingsGeneral");
+
+    const [dialogIsOpen, setDialogIsOpen] = useState(false);
+    const [validationDialogIsOpen, setValidationDialogIsOpen] = useState(false);
+
+    const [customSettingsFilePath, setCustomSettingsFilePath] = useState<string | undefined>(
+        window.ContextBridge.ipcRenderer.sendSync("getCustomSettingsFilePath"),
+    );
+
+    const removeCustomSettingsFilePath = () => {
+        window.ContextBridge.ipcRenderer.send("removeCustomSettingsFilePath");
+        setCustomSettingsFilePath(undefined);
+    };
+
+    const selectFile = async (): Promise<string | undefined> => {
+        const { canceled, filePaths } = await window.ContextBridge.showOpenDialog({
+            defaultPath: "ueli9.settings.json",
+            properties: ["openFile"],
+            filters: [{ name: "JSON", extensions: ["json"] }],
+        });
+
+        return !canceled && filePaths.length ? filePaths[0] : undefined;
+    };
+
+    const selectCustomSettingsFilePath = async (filePath: string) => {
+        const isValid: boolean = window.ContextBridge.ipcRenderer.sendSync("isValidSettingsFile", { filePath });
+
+        if (!isValid) {
+            setValidationDialogIsOpen(true);
+            return;
+        }
+
+        await window.ContextBridge.ipcRenderer.invoke("setCustomSettingsFilePath", { filePath });
+        setCustomSettingsFilePath(filePath);
+        setDialogIsOpen(true);
+    };
+
+    return (
+        <Setting
+            label={t("setCustomSettingsFilePath")}
+            description={customSettingsFilePath}
+            control={
+                <div style={{ display: "flex", flexDirection: "row", gap: 4 }}>
+                    <Tooltip withArrow relationship="label" content={t("selectFile")}>
+                        <Button
+                            onClick={async () => {
+                                const filePath = await selectFile();
+                                if (filePath) {
+                                    await selectCustomSettingsFilePath(filePath);
+                                }
+                            }}
+                            icon={<DocumentRegular />}
+                        />
+                    </Tooltip>
+                    <Dialog open={dialogIsOpen} onOpenChange={(_, { open }) => setDialogIsOpen(open)}>
+                        <DialogSurface>
+                            <DialogBody>
+                                <DialogTitle>{t("customSettingsFilePathDialogTitle")}</DialogTitle>
+                                <DialogContent>{t("customSettingsFilePathDialogDescription")}</DialogContent>
+                                <DialogActions>
+                                    <Button onClick={() => window.ContextBridge.restartApp()} appearance="primary">
+                                        {t("restartNow")}
+                                    </Button>
+                                    <DialogTrigger disableButtonEnhancement>
+                                        <Button appearance="secondary">{t("no")}</Button>
+                                    </DialogTrigger>
+                                </DialogActions>
+                            </DialogBody>
+                        </DialogSurface>
+                    </Dialog>
+                    <Dialog
+                        open={validationDialogIsOpen}
+                        onOpenChange={(_, { open }) => setValidationDialogIsOpen(open)}
+                    >
+                        <DialogSurface>
+                            <DialogBody>
+                                <DialogTitle>{t("customSettingsFileValidationDialogTitle")}</DialogTitle>
+                                <DialogContent>{t("customSettingsFileValidationDialogDescription")}</DialogContent>
+                                <DialogActions>
+                                    <DialogTrigger disableButtonEnhancement>
+                                        <Button appearance="secondary">{t("close")}</Button>
+                                    </DialogTrigger>
+                                </DialogActions>
+                            </DialogBody>
+                        </DialogSurface>
+                    </Dialog>
+                    <Tooltip withArrow content={t("remove")} relationship="label">
+                        <Button
+                            disabled={!customSettingsFilePath}
+                            onClick={() => removeCustomSettingsFilePath()}
+                            icon={<DismissRegular />}
+                        />
+                    </Tooltip>
+                </div>
+            }
+        />
+    );
+};


Test patch:
diff --git a/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathResolver.test.ts b/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathResolver.test.ts
new file mode 100644
index 00000000..cb43d382
--- /dev/null
+++ b/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathResolver.test.ts
@@ -0,0 +1,198 @@
+import type { FileSystemUtility } from "@Core/FileSystemUtility";
+import type { App } from "electron";
+import { join } from "path";
+import { describe, expect, it, vi } from "vitest";
+import { CustomSettingsFilePathResolver } from "./CustomSettingsFilePathResolver";
+
+describe(CustomSettingsFilePathResolver, () => {
+    const createCustomSettingsFilePathResolver = ({
+        homePath,
+        configFileExists,
+        config,
+    }: {
+        homePath: string;
+        configFileExists: boolean;
+        config: Record<string, string>;
+    }) => {
+        const getPathMock = vi.fn().mockReturnValue(homePath);
+        const existsSyncMock = vi.fn().mockReturnValue(configFileExists);
+        const readJsonFileSyncMock = vi.fn().mockReturnValue(config);
+        const writeJsonFileMock = vi.fn();
+        const writeJsonFileSyncMock = vi.fn();
+
+        const app = <App>{ getPath: (n) => getPathMock(n) };
+
+        const fileSystemUtility = <FileSystemUtility>{
+            existsSync: (f) => existsSyncMock(f),
+            readJsonFileSync: (f) => readJsonFileSyncMock(f),
+            writeJsonFile: (d, f) => writeJsonFileMock(d, f),
+            writeJsonFileSync: (d, f) => writeJsonFileSyncMock(d, f),
+        };
+
+        const customSettingsFilePathResolver = new CustomSettingsFilePathResolver(app, fileSystemUtility);
+
+        return {
+            customSettingsFilePathResolver,
+            getPathMock,
+            existsSyncMock,
+            readJsonFileSyncMock,
+            writeJsonFileMock,
+            writeJsonFileSyncMock,
+        };
+    };
+
+    describe(CustomSettingsFilePathResolver.prototype.isEnabled, () => {
+        it("should return false if config file does not exist", () => {
+            const { customSettingsFilePathResolver, getPathMock, existsSyncMock } =
+                createCustomSettingsFilePathResolver({
+                    homePath: "home",
+                    config: {},
+                    configFileExists: false,
+                });
+
+            expect(customSettingsFilePathResolver.isEnabled()).toBe(false);
+
+            expect(getPathMock).toHaveBeenCalledWith("home");
+            expect(existsSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+        });
+
+        it("should return false if config file exists but doesnt have a settingsFilePath property", () => {
+            const { customSettingsFilePathResolver, getPathMock, existsSyncMock } =
+                createCustomSettingsFilePathResolver({
+                    homePath: "home",
+                    config: {},
+                    configFileExists: true,
+                });
+
+            expect(customSettingsFilePathResolver.isEnabled()).toBe(false);
+
+            expect(getPathMock).toHaveBeenCalledWith("home");
+            expect(existsSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+        });
+
+        it("should return true if config file exists and has a settingsFilePath property", () => {
+            const { customSettingsFilePathResolver, getPathMock, existsSyncMock, readJsonFileSyncMock } =
+                createCustomSettingsFilePathResolver({
+                    homePath: "home",
+                    config: { settingsFilePath: "my-custon-settings-file.json" },
+                    configFileExists: true,
+                });
+
+            expect(customSettingsFilePathResolver.isEnabled()).toBe(true);
+
+            expect(getPathMock).toHaveBeenCalledWith("home");
+            expect(existsSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+            expect(readJsonFileSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+        });
+    });
+
+    describe(CustomSettingsFilePathResolver.prototype.remove, () => {
+        it("should write the new settings file path to the config file", async () => {
+            const { customSettingsFilePathResolver, readJsonFileSyncMock, writeJsonFileSyncMock } =
+                createCustomSettingsFilePathResolver({
+                    homePath: "home",
+                    config: { settingsFilePath: "old-file.json" },
+                    configFileExists: true,
+                });
+
+            await customSettingsFilePathResolver.remove();
+
+            expect(readJsonFileSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+            expect(writeJsonFileSyncMock).toHaveBeenCalledWith({}, join("home", "ueli9.config.json"));
+        });
+
+        it("should do nothing if the current settings dont have a settingsFilePath property", async () => {
+            const { customSettingsFilePathResolver, readJsonFileSyncMock, writeJsonFileSyncMock } =
+                createCustomSettingsFilePathResolver({
+                    homePath: "home",
+                    config: {},
+                    configFileExists: true,
+                });
+
+            await customSettingsFilePathResolver.remove();
+
+            expect(readJsonFileSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+            expect(writeJsonFileSyncMock).toHaveBeenCalledWith({}, join("home", "ueli9.config.json"));
+        });
+    });
+
+    describe(CustomSettingsFilePathResolver.prototype.resolve, () => {
+        it("should return undefined if config file does not exist", () => {
+            const { customSettingsFilePathResolver, getPathMock, existsSyncMock } =
+                createCustomSettingsFilePathResolver({
+                    homePath: "home",
+                    config: {},
+                    configFileExists: false,
+                });
+
+            expect(customSettingsFilePathResolver.resolve()).toBe(undefined);
+            expect(getPathMock).toHaveBeenCalledWith("home");
+            expect(existsSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+        });
+
+        it("should return undefined if config file exists but doesnt have a settingsFilePath property", () => {
+            const { customSettingsFilePathResolver, getPathMock, existsSyncMock } =
+                createCustomSettingsFilePathResolver({
+                    homePath: "home",
+                    config: {},
+                    configFileExists: true,
+                });
+
+            expect(customSettingsFilePathResolver.resolve()).toBe(undefined);
+            expect(getPathMock).toHaveBeenCalledWith("home");
+            expect(existsSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+        });
+
+        it("should return the settingsFilePath if config file exists and has a settingsFilePath property", () => {
+            const { customSettingsFilePathResolver, getPathMock, existsSyncMock, readJsonFileSyncMock } =
+                createCustomSettingsFilePathResolver({
+                    homePath: "home",
+                    config: { settingsFilePath: "myCustomConfigFile.json" },
+                    configFileExists: true,
+                });
+
+            expect(customSettingsFilePathResolver.resolve()).toBe("myCustomConfigFile.json");
+            expect(getPathMock).toHaveBeenCalledWith("home");
+            expect(existsSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+            expect(readJsonFileSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+        });
+    });
+
+    describe(CustomSettingsFilePathResolver.prototype.writeFilePathToConfigFile, () => {
+        it("should update the existing settingsFilePath in the config file", async () => {
+            const { customSettingsFilePathResolver, readJsonFileSyncMock, writeJsonFileMock } =
+                createCustomSettingsFilePathResolver({
+                    homePath: "home",
+                    config: { settingsFilePath: "old-file.json" },
+                    configFileExists: true,
+                });
+
+            await customSettingsFilePathResolver.writeFilePathToConfigFile("new-file.json");
+
+            expect(readJsonFileSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+
+            expect(writeJsonFileMock).toHaveBeenCalledWith(
+                { settingsFilePath: "new-file.json" },
+                join("home", "ueli9.config.json"),
+            );
+        });
+
+        it("should add the the new settingsFilePath to the config file if it the property is not there yet", async () => {
+            const { customSettingsFilePathResolver, readJsonFileSyncMock, writeJsonFileMock } =
+                createCustomSettingsFilePathResolver({
+                    homePath: "home",
+                    config: {},
+                    configFileExists: true,
+                });
+
+            await customSettingsFilePathResolver.writeFilePathToConfigFile("new-file.json");
+
+            expect(readJsonFileSyncMock).toHaveBeenCalledWith(join("home", "ueli9.config.json"));
+
+            expect(writeJsonFileMock).toHaveBeenCalledWith(
+                { settingsFilePath: "new-file.json" },
+                join("home", "ueli9.config.json"),
+            );
+        });
+    });
+});
diff --git a/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathValidator.test.ts b/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathValidator.test.ts
new file mode 100644
index 00000000..d94ff71a
--- /dev/null
+++ b/src/main/Core/SettingsFile/Resolver/CustomSettingsFilePathValidator.test.ts
@@ -0,0 +1,55 @@
+import type { FileSystemUtility } from "@Core/FileSystemUtility";
+import { describe, expect, it, vi } from "vitest";
+import { CustomSettingsFilePathValidator } from "./CustomSettingsFilePathValidator";
+
+describe(CustomSettingsFilePathValidator, () => {
+    describe(CustomSettingsFilePathValidator.prototype.validate, () => {
+        it("should return false if the file does not exist", () => {
+            const existsSyncMock = vi.fn().mockReturnValue(false);
+
+            const fileSystemUtility = <FileSystemUtility>{ existsSync: (f) => existsSyncMock(f) };
+
+            const customSettingsFilePathValidator = new CustomSettingsFilePathValidator(fileSystemUtility);
+
+            expect(customSettingsFilePathValidator.validate("non-existing-file.json")).toBe(false);
+            expect(existsSyncMock).toHaveBeenCalledWith("non-existing-file.json");
+        });
+
+        it("should return false if the file is not a valid JSON", () => {
+            const existsSyncMock = vi.fn().mockReturnValue(true);
+
+            const readJsonFileSyncMock = vi.fn().mockImplementation(() => {
+                throw new Error("Invalid JSON");
+            });
+
+            const fileSystemUtility = <FileSystemUtility>{
+                existsSync: (f) => existsSyncMock(f),
+                readJsonFileSync: (f) => readJsonFileSyncMock(f),
+            };
+
+            const customSettingsFilePathValidator = new CustomSettingsFilePathValidator(fileSystemUtility);
+
+            expect(customSettingsFilePathValidator.validate("invalid-json-file.json")).toBe(false);
+
+            expect(existsSyncMock).toHaveBeenCalledWith("invalid-json-file.json");
+            expect(readJsonFileSyncMock).toHaveBeenCalledWith("invalid-json-file.json");
+        });
+
+        it("should return true if the file exists and is a valid JSON", () => {
+            const existsSyncMock = vi.fn().mockReturnValue(true);
+            const readJsonFileSyncMock = vi.fn().mockReturnValue({});
+
+            const fileSystemUtility = <FileSystemUtility>{
+                existsSync: (f) => existsSyncMock(f),
+                readJsonFileSync: (f) => readJsonFileSyncMock(f),
+            };
+
+            const customSettingsFilePathValidator = new CustomSettingsFilePathValidator(fileSystemUtility);
+
+            expect(customSettingsFilePathValidator.validate("invalid-json-file.json")).toBe(true);
+
+            expect(existsSyncMock).toHaveBeenCalledWith("invalid-json-file.json");
+            expect(readJsonFileSyncMock).toHaveBeenCalledWith("invalid-json-file.json");
+        });
+    });
+});