Repository: oliverschwendener/ueli (ts)

Issue:
[Feature Request] Use an alternate to default browser
My Windows default browser is Microsoft Edge (for work) but I use Ueli mainly for personal (Chrome). Is there a way to have Ueli use a browser other than the default one configured by the OS? I couldn't find a way to do this. Thanks.

Pull request description:
feat: add option to define custom browser
The goal of this PR is to add the option to open URLs in a web browser that is not the system's default.

Fixes #930

To do:
- [x] add macOS implementation
- [x] add Windows implementation
- [x] ~~add Linux implementation~~ => not implemented for now

Patch:
diff --git a/src/main/Core/Shell/ActionHandler/CustomWebBrowser/CustomWebBrowserActionHandler.ts b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/CustomWebBrowserActionHandler.ts
new file mode 100644
index 00000000..9c987ef1
--- /dev/null
+++ b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/CustomWebBrowserActionHandler.ts
@@ -0,0 +1,4 @@
+export interface CustomWebBrowserActionHandler {
+    openUrl(url: string): Promise<void>;
+    isEnabled(): boolean;
+}
diff --git a/src/main/Core/Shell/ActionHandler/CustomWebBrowser/LinuxCustomWebBrowserActionHandler.ts b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/LinuxCustomWebBrowserActionHandler.ts
new file mode 100644
index 00000000..5ab93f8d
--- /dev/null
+++ b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/LinuxCustomWebBrowserActionHandler.ts
@@ -0,0 +1,11 @@
+import type { CustomWebBrowserActionHandler } from "./CustomWebBrowserActionHandler";
+
+export class LinuxCustomWebBrowserActionHandler implements CustomWebBrowserActionHandler {
+    public isEnabled(): boolean {
+        return false;
+    }
+
+    public async openUrl(): Promise<void> {
+        throw new Error("Not implemented");
+    }
+}
diff --git a/src/main/Core/Shell/ActionHandler/CustomWebBrowser/MacOsCustomWebBrowserActionHandler.ts b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/MacOsCustomWebBrowserActionHandler.ts
new file mode 100644
index 00000000..71092639
--- /dev/null
+++ b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/MacOsCustomWebBrowserActionHandler.ts
@@ -0,0 +1,26 @@
+import type { CommandlineUtility } from "@Core/CommandlineUtility";
+import type { SettingsManager } from "@Core/SettingsManager";
+import type { CustomWebBrowserActionHandler } from "./CustomWebBrowserActionHandler";
+
+export class MacOsCustomWebBrowserActionHandler implements CustomWebBrowserActionHandler {
+    public constructor(
+        private readonly settingsManager: SettingsManager,
+        private readonly commandlineUtility: CommandlineUtility,
+    ) {}
+
+    public isEnabled(): boolean {
+        if (this.settingsManager.getValue<boolean>("general.browser.useDefaultWebBrowser", true)) {
+            return false;
+        }
+
+        return this.getCustomWebBrowserName().length > 0;
+    }
+
+    public async openUrl(url: string): Promise<void> {
+        await this.commandlineUtility.executeCommand(`open -a "${this.getCustomWebBrowserName()}" "${url}"`);
+    }
+
+    private getCustomWebBrowserName(): string {
+        return this.settingsManager.getValue<string>("general.browser.customWebBrowserName", "");
+    }
+}
diff --git a/src/main/Core/Shell/ActionHandler/CustomWebBrowser/WindowsCustomWebBrowserActionHandler.ts b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/WindowsCustomWebBrowserActionHandler.ts
new file mode 100644
index 00000000..9d6f5e5b
--- /dev/null
+++ b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/WindowsCustomWebBrowserActionHandler.ts
@@ -0,0 +1,35 @@
+import type { PowershellUtility } from "@Core/PowershellUtility";
+import type { SettingsManager } from "@Core/SettingsManager";
+import type { CustomWebBrowserActionHandler } from "./CustomWebBrowserActionHandler";
+
+export class WindowsCustomWebBrowserActionHandler implements CustomWebBrowserActionHandler {
+    public constructor(
+        private readonly powershellUtility: PowershellUtility,
+        private readonly settingsManager: SettingsManager,
+    ) {}
+
+    public isEnabled(): boolean {
+        if (this.settingsManager.getValue<boolean>("general.browser.useDefaultWebBrowser", true)) {
+            return false;
+        }
+
+        return this.getExecutableFilePath().length > 0 && this.getCommandlineArguments().includes("{{url}}");
+    }
+
+    public async openUrl(url: string): Promise<void> {
+        await this.powershellUtility.executeCommand(
+            `Start-Process -FilePath '${this.getExecutableFilePath()}' -ArgumentList '${this.getCommandlineArguments().replace("{{url}}", url)}'`,
+        );
+    }
+
+    private getExecutableFilePath(): string {
+        return this.settingsManager.getValue<string>("general.browser.customWebBrowser.executableFilePath", "");
+    }
+
+    private getCommandlineArguments(): string {
+        return this.settingsManager.getValue<string>(
+            "general.browser.customWebBrowser.commandlineArguments",
+            "{{url}}",
+        );
+    }
+}
diff --git a/src/main/Core/Shell/ActionHandler/CustomWebBrowser/index.ts b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/index.ts
new file mode 100644
index 00000000..49b7d7b4
--- /dev/null
+++ b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/index.ts
@@ -0,0 +1,4 @@
+export * from "./CustomWebBrowserActionHandler";
+export * from "./LinuxCustomWebBrowserActionHandler";
+export * from "./MacOsCustomWebBrowserActionHandler";
+export * from "./WindowsCustomWebBrowserActionHandler";
diff --git a/src/main/Core/Shell/ActionHandler/UrlActionHandler.ts b/src/main/Core/Shell/ActionHandler/UrlActionHandler.ts
index f4b91f61..ff927614 100644
--- a/src/main/Core/Shell/ActionHandler/UrlActionHandler.ts
+++ b/src/main/Core/Shell/ActionHandler/UrlActionHandler.ts
@@ -1,6 +1,7 @@
 import type { ActionHandler } from "@Core/ActionHandler";
 import type { SearchResultItemAction } from "@common/Core";
 import type { Shell } from "electron";
+import type { CustomWebBrowserActionHandler } from "./CustomWebBrowser";
 
 /**
  * Action handler for opening a URL.
@@ -8,13 +9,18 @@ import type { Shell } from "electron";
 export class UrlActionHandler implements ActionHandler {
     public readonly id = "Url";
 
-    public constructor(private readonly shell: Shell) {}
+    public constructor(
+        private readonly shell: Shell,
+        private readonly customWebBrowserActionHandler: CustomWebBrowserActionHandler,
+    ) {}
 
     /**
      * Opens the given URL with the system's default web browser. Expects the given action's argument to be a valid URL,
      * e.g. `"https://example.com"`.
      */
     public async invokeAction(action: SearchResultItemAction): Promise<void> {
-        await this.shell.openExternal(action.argument);
+        return this.customWebBrowserActionHandler.isEnabled()
+            ? await this.customWebBrowserActionHandler.openUrl(action.argument)
+            : await this.shell.openExternal(action.argument);
     }
 }
diff --git a/src/main/Core/Shell/ShellModule.ts b/src/main/Core/Shell/ShellModule.ts
index 3f593f30..75578d57 100644
--- a/src/main/Core/Shell/ShellModule.ts
+++ b/src/main/Core/Shell/ShellModule.ts
@@ -1,18 +1,41 @@
+import type { OperatingSystem } from "@common/Core";
 import type { Dependencies } from "@Core/Dependencies";
 import type { DependencyRegistry } from "@Core/DependencyRegistry";
 import type { OpenExternalOptions } from "electron";
 import { OpenFilePathActionHandler, ShowItemInFileExplorerActionHandler, UrlActionHandler } from "./ActionHandler";
+import {
+    type CustomWebBrowserActionHandler,
+    LinuxCustomWebBrowserActionHandler,
+    MacOsCustomWebBrowserActionHandler,
+    WindowsCustomWebBrowserActionHandler,
+} from "./ActionHandler/CustomWebBrowser";
 
 export class ShellModule {
     public static bootstrap(dependencyRegistry: DependencyRegistry<Dependencies>) {
         const shell = dependencyRegistry.get("Shell");
         const ipcMain = dependencyRegistry.get("IpcMain");
-
         const actionHandlerRegistry = dependencyRegistry.get("ActionHandlerRegistry");
 
         actionHandlerRegistry.register(new OpenFilePathActionHandler(shell));
         actionHandlerRegistry.register(new ShowItemInFileExplorerActionHandler(shell));
-        actionHandlerRegistry.register(new UrlActionHandler(shell));
+
+        const customWebBrowserActionHandlers: Record<OperatingSystem, () => CustomWebBrowserActionHandler> = {
+            macOS: () =>
+                new MacOsCustomWebBrowserActionHandler(
+                    dependencyRegistry.get("SettingsManager"),
+                    dependencyRegistry.get("CommandlineUtility"),
+                ),
+            Linux: () => new LinuxCustomWebBrowserActionHandler(),
+            Windows: () =>
+                new WindowsCustomWebBrowserActionHandler(
+                    dependencyRegistry.get("PowershellUtility"),
+                    dependencyRegistry.get("SettingsManager"),
+                ),
+        };
+
+        actionHandlerRegistry.register(
+            new UrlActionHandler(shell, customWebBrowserActionHandlers[dependencyRegistry.get("OperatingSystem")]()),
+        );
 
         ipcMain.handle("openExternal", (_, { url, options }: { url: string; options?: OpenExternalOptions }) =>
             shell.openExternal(url, options),
diff --git a/src/main/index.ts b/src/main/index.ts
index 071a3470..4a8d418c 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -60,7 +60,6 @@ if (!app.requestSingleInstanceLock()) {
     Core.RandomStringProviderModule.bootstrap(dependencyRegistry);
     Core.SafeStorageEncryptionModule.bootstrap(dependencyRegistry);
     Core.AssetPathResolverModule.bootstrap(dependencyRegistry);
-    Core.ShellModule.bootstrap(dependencyRegistry);
     Core.ClipboardModule.bootstrap(dependencyRegistry);
     Core.AboutUeliModule.bootstrap(dependencyRegistry);
     Core.CommandlineUtilityModule.bootstrap(dependencyRegistry);
@@ -71,6 +70,7 @@ if (!app.requestSingleInstanceLock()) {
     Core.SettingsReaderModule.bootstrap(dependencyRegistry);
     Core.SettingsWriterModule.bootstrap(dependencyRegistry);
     Core.SettingsManagerModule.bootstrap(dependencyRegistry);
+    Core.ShellModule.bootstrap(dependencyRegistry);
     Core.DockModule.bootstrap(dependencyRegistry);
     await Core.ImageGeneratorModule.bootstrap(dependencyRegistry);
     Core.TranslatorModule.bootstrap(dependencyRegistry);
diff --git a/src/renderer/Core/I18n/getCoreResources.ts b/src/renderer/Core/I18n/getCoreResources.ts
index 6bfc83b8..b5c46082 100644
--- a/src/renderer/Core/I18n/getCoreResources.ts
+++ b/src/renderer/Core/I18n/getCoreResources.ts
@@ -56,6 +56,12 @@ export const getCoreResources = (): { namespace: string; resources: Resources<Tr
                     searchHistoryLimit: "Search History Limit",
                     searchHistoryLimitHint: "Limits the maximum number of search history items",
                     showAppIconInDock: "Show app icon in dock",
+                    webBrowser: "Web Browser",
+                    useDefaultWebBrowser: "Use system's default web browser",
+                    customWebBrowserExecutableFilePath: "Executable file path",
+                    customWebBrowserExecutableFilePathSelectFile: "Select file",
+                    customWebBrowserCommandlineArguments: "Commandline arguments",
+                    customWebBrowserName: "Browser Name",
                 },
                 "de-CH": {
                     title: "Allgemein",
@@ -72,6 +78,12 @@ export const getCoreResources = (): { namespace: string; resources: Resources<Tr
                     searchHistoryLimit: "Suchverlauf Limit",
                     searchHistoryLimitHint: "Begrenzt die maximale Anzahl an Suchverlaufseinträgen",
                     showAppIconInDock: "App-Symbol im Dock anzeigen",
+                    webBrowser: "Web Browser",
+                    useDefaultWebBrowser: "Systemstandard Webbrowser verwenden",
+                    customWebBrowserExecutableFilePath: "Ausführbare Datei",
+                    customWebBrowserExecutableFilePathSelectFile: "Datei auswählen",
+                    customWebBrowserCommandlineArguments: "Kommandozeilen-Argumente",
+                    customWebBrowserName: "Browser-Name",
                 },
             },
         },
diff --git a/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowser.tsx b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowser.tsx
new file mode 100644
index 00000000..3887d690
--- /dev/null
+++ b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowser.tsx
@@ -0,0 +1,34 @@
+import { useSetting } from "@Core/Hooks";
+import { SettingGroup } from "@Core/Settings/SettingGroup";
+import { useTranslation } from "react-i18next";
+import { CustomWebBrowserCommandlineArguments } from "./CustomWebBrowserCommandlineArguments";
+import { CustomWebBrowserExecutable } from "./CustomWebBrowserExecutable";
+import { CustomWebBrowserName } from "./CustomWebBrowserName";
+import { UseDefaultBrowser } from "./UseDefaultBrowser";
+
+export const CustomWebBrowser = () => {
+    const operatingSystem = window.ContextBridge.getOperatingSystem();
+
+    const { t } = useTranslation("settingsGeneral");
+
+    const { value: useDefaultWebBrowser, updateValue: setUseDefaultWebBrowser } = useSetting({
+        defaultValue: true,
+        key: "general.browser.useDefaultWebBrowser",
+    });
+
+    return (
+        <SettingGroup title={t("webBrowser")}>
+            <UseDefaultBrowser
+                useDefaultWebBrowser={useDefaultWebBrowser}
+                setUseDefaultBrowser={setUseDefaultWebBrowser}
+            />
+            {operatingSystem === "Windows" && (
+                <CustomWebBrowserExecutable useDefaultWebBrowser={useDefaultWebBrowser} />
+            )}
+            {operatingSystem === "Windows" && (
+                <CustomWebBrowserCommandlineArguments useDefaultWebBrowser={useDefaultWebBrowser} />
+            )}
+            {operatingSystem === "macOS" && <CustomWebBrowserName useDefaultWebBrowser={useDefaultWebBrowser} />}
+        </SettingGroup>
+    );
+};
diff --git a/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowserCommandlineArguments.tsx b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowserCommandlineArguments.tsx
new file mode 100644
index 00000000..5080564a
--- /dev/null
+++ b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowserCommandlineArguments.tsx
@@ -0,0 +1,33 @@
+import { useSetting } from "@Core/Hooks";
+import { Setting } from "@Core/Settings/Setting";
+import { Input } from "@fluentui/react-components";
+import { useTranslation } from "react-i18next";
+
+type CustomWebBrowserArgumentListProps = {
+    useDefaultWebBrowser: boolean;
+};
+
+export const CustomWebBrowserCommandlineArguments = ({ useDefaultWebBrowser }: CustomWebBrowserArgumentListProps) => {
+    const { t } = useTranslation("settingsGeneral");
+
+    const { value: argumentList, updateValue: setArgumentList } = useSetting<string>({
+        key: "general.browser.customWebBrowser.commandlineArguments",
+        defaultValue: "{{url}}",
+    });
+
+    return (
+        <Setting
+            label={t("customWebBrowserCommandlineArguments")}
+            control={
+                <div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
+                    <Input
+                        disabled={useDefaultWebBrowser}
+                        value={argumentList}
+                        onChange={(_, { value }) => setArgumentList(value)}
+                        placeholder="-private-window {{url}}"
+                    />
+                </div>
+            }
+        />
+    );
+};
diff --git a/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowserExecutable.tsx b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowserExecutable.tsx
new file mode 100644
index 00000000..dd1247e8
--- /dev/null
+++ b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowserExecutable.tsx
@@ -0,0 +1,53 @@
+import { useSetting } from "@Core/Hooks";
+import { Setting } from "@Core/Settings/Setting";
+import { Button, Input, Tooltip } from "@fluentui/react-components";
+import { FolderRegular } from "@fluentui/react-icons";
+import { useTranslation } from "react-i18next";
+
+type CustomWebBrowserProps = {
+    useDefaultWebBrowser: boolean;
+};
+
+export const CustomWebBrowserExecutable = ({ useDefaultWebBrowser }: CustomWebBrowserProps) => {
+    const { t } = useTranslation("settingsGeneral");
+
+    const { value, updateValue } = useSetting({
+        defaultValue: "",
+        key: "general.browser.customWebBrowser.executableFilePath",
+    });
+
+    const chooseFile = async () => {
+        const { canceled, filePaths } = await window.ContextBridge.showOpenDialog({ properties: ["openFile"] });
+
+        if (!canceled && filePaths.length) {
+            updateValue(filePaths[0]);
+        }
+    };
+
+    return (
+        <Setting
+            label={t("customWebBrowserExecutableFilePath")}
+            control={
+                <div style={{ width: "100%", display: "flex", flexDirection: "column" }}>
+                    <Input
+                        disabled={useDefaultWebBrowser}
+                        value={value}
+                        onChange={(_, { value }) => updateValue(value)}
+                        placeholder="C:\Program Files\Mozilla Firefox\firefox.exe"
+                        contentAfter={
+                            <Tooltip content={t("customWebBrowserExecutableFilePathSelectFile")} relationship="label">
+                                <Button
+                                    disabled={useDefaultWebBrowser}
+                                    size="small"
+                                    appearance="subtle"
+                                    icon={<FolderRegular />}
+                                    onClick={chooseFile}
+                                />
+                            </Tooltip>
+                        }
+                    />
+                </div>
+            }
+        />
+    );
+};
diff --git a/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowserName.tsx b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowserName.tsx
new file mode 100644
index 00000000..47d73f9c
--- /dev/null
+++ b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/CustomWebBrowserName.tsx
@@ -0,0 +1,28 @@
+import { useSetting } from "@Core/Hooks";
+import { Setting } from "@Core/Settings/Setting";
+import { Input } from "@fluentui/react-components";
+import { useTranslation } from "react-i18next";
+
+type CustomWebBrowserNameProps = {
+    useDefaultWebBrowser: boolean;
+};
+
+export const CustomWebBrowserName = ({ useDefaultWebBrowser }: CustomWebBrowserNameProps) => {
+    const { t } = useTranslation("settingsGeneral");
+
+    const { value, updateValue } = useSetting({ key: "general.browser.customWebBrowserName", defaultValue: "" });
+
+    return (
+        <Setting
+            label={t("customWebBrowserName")}
+            control={
+                <Input
+                    placeholder="Google Chrome, Firefox, Brave"
+                    value={value}
+                    onChange={(_, { value }) => updateValue(value)}
+                    disabled={useDefaultWebBrowser}
+                />
+            }
+        />
+    );
+};
diff --git a/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/UseDefaultBrowser.tsx b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/UseDefaultBrowser.tsx
new file mode 100644
index 00000000..a91da6e6
--- /dev/null
+++ b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/UseDefaultBrowser.tsx
@@ -0,0 +1,21 @@
+import { Setting } from "@Core/Settings/Setting";
+import { Switch } from "@fluentui/react-components";
+import { useTranslation } from "react-i18next";
+
+type UseDefaultBrowserProps = {
+    useDefaultWebBrowser: boolean;
+    setUseDefaultBrowser: (value: boolean) => void;
+};
+
+export const UseDefaultBrowser = ({ useDefaultWebBrowser, setUseDefaultBrowser }: UseDefaultBrowserProps) => {
+    const { t } = useTranslation("settingsGeneral");
+
+    return (
+        <Setting
+            label={t("useDefaultWebBrowser")}
+            control={
+                <Switch checked={useDefaultWebBrowser} onChange={(_, { checked }) => setUseDefaultBrowser(checked)} />
+            }
+        />
+    );
+};
diff --git a/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/index.ts b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/index.ts
new file mode 100644
index 00000000..fb6c5388
--- /dev/null
+++ b/src/renderer/Core/Settings/Pages/General/CustomWebBrowser/index.ts
@@ -0,0 +1,1 @@
+export * from "./CustomWebBrowser";
diff --git a/src/renderer/Core/Settings/Pages/General/General.tsx b/src/renderer/Core/Settings/Pages/General/General.tsx
index 9954926a..459c0b38 100644
--- a/src/renderer/Core/Settings/Pages/General/General.tsx
+++ b/src/renderer/Core/Settings/Pages/General/General.tsx
@@ -1,7 +1,7 @@
-import { useContextBridge } from "@Core/Hooks";
 import { SettingGroup } from "@Core/Settings/SettingGroup";
 import { SettingGroupList } from "../../SettingGroupList";
 import { Autostart } from "./Autostart";
+import { CustomWebBrowser } from "./CustomWebBrowser";
 import { DockSettings } from "./DockSettings";
 import { HotKey } from "./HotKey";
 import { Language } from "./Language";
@@ -9,9 +9,7 @@ import { SearchHistory } from "./SearchHistory";
 import { UrlImageGenerator } from "./UrlImageGenerator";
 
 export const General = () => {
-    const { contextBridge } = useContextBridge();
-
-    const operatingSystem = contextBridge.getOperatingSystem();
+    const operatingSystem = window.ContextBridge.getOperatingSystem();
 
     return (
         <SettingGroupList>
@@ -27,6 +25,7 @@ export const General = () => {
             <SettingGroup title="Icons">
                 <UrlImageGenerator />
             </SettingGroup>
+            {operatingSystem !== "Linux" && <CustomWebBrowser />}
         </SettingGroupList>
     );
 };


Test patch:
diff --git a/src/main/Core/Shell/ActionHandler/CustomWebBrowser/LinuxCustomWebBrowserActionHandler.test.ts b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/LinuxCustomWebBrowserActionHandler.test.ts
new file mode 100644
index 00000000..3e7b51fe
--- /dev/null
+++ b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/LinuxCustomWebBrowserActionHandler.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import { LinuxCustomWebBrowserActionHandler } from "./LinuxCustomWebBrowserActionHandler";
+
+describe(LinuxCustomWebBrowserActionHandler, () => {
+    describe(LinuxCustomWebBrowserActionHandler.prototype.isEnabled, () => {
+        it("should return false as it's not supported yet", () => {
+            expect(new LinuxCustomWebBrowserActionHandler().isEnabled()).toBe(false);
+        });
+    });
+
+    describe(LinuxCustomWebBrowserActionHandler.prototype.openUrl, () => {
+        it("should throw an error as it's not implemented yet", async () => {
+            const actionHandler = new LinuxCustomWebBrowserActionHandler();
+            await expect(actionHandler.openUrl()).rejects.toThrow("Not implemented");
+        });
+    });
+});
diff --git a/src/main/Core/Shell/ActionHandler/CustomWebBrowser/MacOsCustomWebBrowserActionHandler.test.ts b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/MacOsCustomWebBrowserActionHandler.test.ts
new file mode 100644
index 00000000..7f6d6d99
--- /dev/null
+++ b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/MacOsCustomWebBrowserActionHandler.test.ts
@@ -0,0 +1,75 @@
+import type { CommandlineUtility } from "@Core/CommandlineUtility";
+import type { SettingsManager } from "@Core/SettingsManager";
+import { describe, expect, it, vi } from "vitest";
+import { MacOsCustomWebBrowserActionHandler } from "./MacOsCustomWebBrowserActionHandler";
+
+describe(MacOsCustomWebBrowserActionHandler, () => {
+    describe(MacOsCustomWebBrowserActionHandler.prototype.isEnabled, () => {
+        it("should return false if the setting 'general.browser.useDefaultWebBrowser' is true", async () => {
+            const getValueMock = vi.fn().mockReturnValue(false);
+            const settingsManager = <SettingsManager>{ getValue: (k, d) => getValueMock(k, d) };
+            const commandlineUtility = <CommandlineUtility>{};
+
+            expect(new MacOsCustomWebBrowserActionHandler(settingsManager, commandlineUtility).isEnabled()).toBe(false);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.useDefaultWebBrowser", true);
+        });
+
+        it("should return false if the setting 'general.browser.useDefaultWebBrowser' is false but the custom browser name is not set", async () => {
+            const getValueMock = vi.fn().mockImplementation((k) => {
+                if (k === "general.browser.useDefaultWebBrowser") {
+                    return false;
+                }
+
+                if (k === "general.browser.customWebBrowserName") {
+                    return "";
+                }
+
+                throw new Error("Unexpected call to getValue");
+            });
+
+            const settingsManager = <SettingsManager>{ getValue: (k, d) => getValueMock(k, d) };
+            const commandlineUtility = <CommandlineUtility>{};
+
+            expect(new MacOsCustomWebBrowserActionHandler(settingsManager, commandlineUtility).isEnabled()).toBe(false);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.useDefaultWebBrowser", true);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.customWebBrowserName", "");
+        });
+
+        it("should return true if the setting 'general.browser.useDefaultWebBrowser' is false and the custom browser name is set", async () => {
+            const getValueMock = vi.fn().mockImplementation((k) => {
+                if (k === "general.browser.useDefaultWebBrowser") {
+                    return false;
+                }
+
+                if (k === "general.browser.customWebBrowserName") {
+                    return "Google Chrome";
+                }
+
+                throw new Error("Unexpected call to getValue");
+            });
+
+            const settingsManager = <SettingsManager>{ getValue: (k, d) => getValueMock(k, d) };
+            const commandlineUtility = <CommandlineUtility>{};
+
+            expect(new MacOsCustomWebBrowserActionHandler(settingsManager, commandlineUtility).isEnabled()).toBe(true);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.useDefaultWebBrowser", true);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.customWebBrowserName", "");
+        });
+    });
+
+    describe(MacOsCustomWebBrowserActionHandler.prototype.openUrl, () => {
+        it("should open the URL with the custom web browser", async () => {
+            const executeCommandMock = vi.fn().mockReturnValue(Promise.resolve());
+            const getValueMock = vi.fn().mockReturnValue("My Custom Browser");
+            const settingsManager = <SettingsManager>{ getValue: (k, d) => getValueMock(k, d) };
+            const commandlineUtility = <CommandlineUtility>{ executeCommand: (c) => executeCommandMock(c) };
+
+            await new MacOsCustomWebBrowserActionHandler(settingsManager, commandlineUtility).openUrl(
+                "https://example.com",
+            );
+
+            expect(executeCommandMock).toHaveBeenCalledWith('open -a "My Custom Browser" "https://example.com"');
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.customWebBrowserName", "");
+        });
+    });
+});
diff --git a/src/main/Core/Shell/ActionHandler/CustomWebBrowser/WindowsCustomWebBrowserActionHandler.test.ts b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/WindowsCustomWebBrowserActionHandler.test.ts
new file mode 100644
index 00000000..d75742ee
--- /dev/null
+++ b/src/main/Core/Shell/ActionHandler/CustomWebBrowser/WindowsCustomWebBrowserActionHandler.test.ts
@@ -0,0 +1,126 @@
+import type { PowershellUtility } from "@Core/PowershellUtility";
+import type { SettingsManager } from "@Core/SettingsManager";
+import { describe, expect, it, vi } from "vitest";
+import { WindowsCustomWebBrowserActionHandler } from "./WindowsCustomWebBrowserActionHandler";
+
+describe(WindowsCustomWebBrowserActionHandler, () => {
+    describe(WindowsCustomWebBrowserActionHandler.prototype.isEnabled, () => {
+        it("should return false if system default browser should be used", () => {
+            const getValueMock = vi.fn().mockReturnValue(true);
+            const settingsManager = <SettingsManager>{ getValue: (k, d) => getValueMock(k, d) };
+
+            const actionHandler = new WindowsCustomWebBrowserActionHandler(<PowershellUtility>{}, settingsManager);
+
+            expect(actionHandler.isEnabled()).toBe(false);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.useDefaultWebBrowser", true);
+        });
+
+        it("should return false if custom browser should be used but executable file path is not set", () => {
+            const getValueMock = vi.fn().mockImplementation((k) => {
+                if (k === "general.browser.useDefaultWebBrowser") {
+                    return false;
+                }
+                if (k === "general.browser.customWebBrowser.executableFilePath") {
+                    return "";
+                }
+                throw new Error("Unexpected call to getValue");
+            });
+
+            const settingsManager = <SettingsManager>{ getValue: (k, d) => getValueMock(k, d) };
+
+            const actionHandler = new WindowsCustomWebBrowserActionHandler(<PowershellUtility>{}, settingsManager);
+
+            expect(actionHandler.isEnabled()).toBe(false);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.useDefaultWebBrowser", true);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.customWebBrowser.executableFilePath", "");
+        });
+
+        it("should return false if custom browser should be used, executable file path is set, but commandline arguments do not contain url placeholder", () => {
+            const getValueMock = vi.fn().mockImplementation((k) => {
+                if (k === "general.browser.useDefaultWebBrowser") {
+                    return false;
+                }
+                if (k === "general.browser.customWebBrowser.executableFilePath") {
+                    return "path";
+                }
+                if (k === "general.browser.customWebBrowser.commandlineArguments") {
+                    return "-some -argument -list";
+                }
+                throw new Error("Unexpected call to getValue");
+            });
+
+            const settingsManager = <SettingsManager>{ getValue: (k, d) => getValueMock(k, d) };
+
+            const actionHandler = new WindowsCustomWebBrowserActionHandler(<PowershellUtility>{}, settingsManager);
+
+            expect(actionHandler.isEnabled()).toBe(false);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.useDefaultWebBrowser", true);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.customWebBrowser.executableFilePath", "");
+            expect(getValueMock).toHaveBeenCalledWith(
+                "general.browser.customWebBrowser.commandlineArguments",
+                "{{url}}",
+            );
+        });
+
+        it("should return true if custom browser should be used, executable file path is set and commandline arguments contain url placeholder", () => {
+            const getValueMock = vi.fn().mockImplementation((k) => {
+                if (k === "general.browser.useDefaultWebBrowser") {
+                    return false;
+                }
+                if (k === "general.browser.customWebBrowser.executableFilePath") {
+                    return "path";
+                }
+                if (k === "general.browser.customWebBrowser.commandlineArguments") {
+                    return "-some -argument -list {{url}}";
+                }
+                throw new Error("Unexpected call to getValue");
+            });
+
+            const settingsManager = <SettingsManager>{ getValue: (k, d) => getValueMock(k, d) };
+
+            const actionHandler = new WindowsCustomWebBrowserActionHandler(<PowershellUtility>{}, settingsManager);
+
+            expect(actionHandler.isEnabled()).toBe(true);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.useDefaultWebBrowser", true);
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.customWebBrowser.executableFilePath", "");
+            expect(getValueMock).toHaveBeenCalledWith(
+                "general.browser.customWebBrowser.commandlineArguments",
+                "{{url}}",
+            );
+        });
+    });
+
+    describe(WindowsCustomWebBrowserActionHandler.prototype.openUrl, () => {
+        it("should open the browser with powershell and pass the commandline arguments", async () => {
+            const getValueMock = vi.fn().mockImplementation((k) => {
+                if (k === "general.browser.customWebBrowser.executableFilePath") {
+                    return "path";
+                }
+                if (k === "general.browser.customWebBrowser.commandlineArguments") {
+                    return "-some -argument -list {{url}}";
+                }
+                throw new Error("Unexpected call to getValue");
+            });
+
+            const executeCommandMock = vi.fn().mockReturnValue(Promise.resolve());
+
+            const settingsManager = <SettingsManager>{ getValue: (k, d) => getValueMock(k, d) };
+            const powershellUtility = <PowershellUtility>{ executeCommand: (c) => executeCommandMock(c) };
+
+            const actionHandler = new WindowsCustomWebBrowserActionHandler(powershellUtility, settingsManager);
+
+            await actionHandler.openUrl("https://example.com");
+
+            expect(getValueMock).toHaveBeenCalledWith("general.browser.customWebBrowser.executableFilePath", "");
+
+            expect(getValueMock).toHaveBeenCalledWith(
+                "general.browser.customWebBrowser.commandlineArguments",
+                "{{url}}",
+            );
+
+            expect(executeCommandMock).toHaveBeenCalledWith(
+                `Start-Process -FilePath 'path' -ArgumentList '-some -argument -list https://example.com'`,
+            );
+        });
+    });
+});
diff --git a/src/main/Core/Shell/ActionHandler/UrlActionHandler.test.ts b/src/main/Core/Shell/ActionHandler/UrlActionHandler.test.ts
index d2b838ec..9c4cd1e9 100644
--- a/src/main/Core/Shell/ActionHandler/UrlActionHandler.test.ts
+++ b/src/main/Core/Shell/ActionHandler/UrlActionHandler.test.ts
@@ -1,16 +1,36 @@
 import type { SearchResultItemAction } from "@common/Core";
 import type { Shell } from "electron";
 import { describe, expect, it, vi } from "vitest";
+import type { CustomWebBrowserActionHandler } from "./CustomWebBrowser";
 import { UrlActionHandler } from "./UrlActionHandler";
 
 describe(UrlActionHandler, () => {
-    it("should call shell's openExternal function", async () => {
+    it("should open the URL with the system's default web browser if the custom web browser action handler is disabled", async () => {
         const openExternalMock = vi.fn().mockReturnValue(Promise.resolve());
         const shell = <Shell>{ openExternal: (url) => openExternalMock(url) };
 
-        const actionHandler = new UrlActionHandler(shell);
+        const customWebBrowserActionHandler = <CustomWebBrowserActionHandler>{
+            isEnabled: () => false,
+            openUrl: () => Promise.resolve(),
+        };
+
+        const actionHandler = new UrlActionHandler(shell, customWebBrowserActionHandler);
         await actionHandler.invokeAction(<SearchResultItemAction>{ argument: "this is a url" });
 
         expect(openExternalMock).toHaveBeenCalledWith("this is a url");
     });
+
+    it("should open the URL with the custom web browser aciton handler if it's enabled", async () => {
+        const openUrlMock = vi.fn().mockReturnValue(Promise.resolve());
+
+        const customWebBrowserActionHandler = <CustomWebBrowserActionHandler>{
+            isEnabled: () => true,
+            openUrl: (url) => openUrlMock(url),
+        };
+
+        const actionHandler = new UrlActionHandler(<Shell>{}, customWebBrowserActionHandler);
+        await actionHandler.invokeAction(<SearchResultItemAction>{ argument: "this is a url" });
+
+        expect(openUrlMock).toHaveBeenCalledWith("this is a url");
+    });
 });