diff --git a/electron-builder.json5 b/electron-builder.json5 index dd50b24..73e996b 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -9,6 +9,25 @@ }, files: ["dist", "dist-electron"], afterSign: "scripts/notarize.cjs", + + // 🔹 Enable deep linking across platforms + protocols: [ + { + name: "Outerbase Protocol", + schemes: [ + "outerbase", + "sqlite", + "mysql", + "postgres", + "turso", + "starbase", + "dolt", + "cloudflare", + ], + role: "Editor", + }, + ], + mac: { notarize: false, target: [ @@ -22,6 +41,25 @@ }, ], artifactName: "outerbase-mac-${version}.${ext}", + entitlements: "entitlements.mac.plist", + entitlementsInherit: "entitlements.mac.plist", + extendInfo: { + CFBundleURLTypes: [ + { + CFBundleURLName: "Outerbase", + CFBundleURLSchemes: [ + "outerbase", + "sqlite", + "mysql", + "postgres", + "turso", + "starbase", + "dolt", + "cloudflare", + ], + }, + ], + }, }, win: { target: [ diff --git a/electron/constants/index.ts b/electron/constants/index.ts index 7f64b10..de7244b 100644 --- a/electron/constants/index.ts +++ b/electron/constants/index.ts @@ -3,3 +3,14 @@ export const STUDIO_ENDPOINT = "https://studio.outerbase.com/embed"; export const OUTERBASE_WEBSITE = "https://outerbase.com"; export const OUTERBASE_GITHUB = "https://github.com/outerbase/studio-desktop/issues"; + +export const OuterbaseProtocols = [ + "outerbase", + "sqlite", + "mysql", + "postgres", + "turso", + "starbase", + "dolt", + "cloudflare", +]; diff --git a/electron/main.ts b/electron/main.ts index fe8eff1..16e8a1f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -16,6 +16,7 @@ import { type ConnectionStoreItem } from "@/lib/conn-manager-store"; import { createDatabaseWindow } from "./window/create-database"; import { bindMenuIpc, bindDockerIpc, bindSavedDocIpc } from "./ipc"; import { bindAnalyticIpc } from "./ipc/analytics"; +import { OuterbaseProtocols } from "./constants"; export function getAutoUpdater(): AppUpdater { // Using destructuring to access autoUpdater due to the CommonJS module of 'electron-updater'. @@ -55,6 +56,17 @@ settings.load(); const mainWindow = new MainWindow(); +OuterbaseProtocols.forEach((protocol) => { + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(protocol, process.execPath, [ + path.resolve(process.argv[1]), + ]); + } + } else { + app.setAsDefaultProtocolClient(protocol); + } +}); // Quit when all windows are closed, except on macOS. There, it's common // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. @@ -138,6 +150,58 @@ ipcMain.handle("set-setting", (_, key, value) => { ipcMain.on("navigate", (event, route: string) => { event.sender.send("navigate-to", route); }); +// Handle deep links +const gotTheLock = app.requestSingleInstanceLock(); + +if (!gotTheLock) { + app.quit(); +} else { + app.on("second-instance", (_, commandLine) => { + // Process deep link when app is already running + const url = commandLine.find((arg) => + OuterbaseProtocols.some((protocol) => arg.startsWith(`${protocol}://`)), + ); + + if (url) { + handleDeepLink(url); + } + }); + app.on("open-url", (event, url) => { + event.preventDefault(); + handleDeepLink(url); + }); +} + +function handleDeepLink(url: string) { + const win = mainWindow.getWindow(); + // Someone tried to run a second instance, we should focus our window. + if (win) { + if (win.isMinimized()) { + win.restore(); + } else { + win.focus(); + } + try { + const urlObj = new URL(url); + const protocol = urlObj.protocol.replace(":", ""); + const host = urlObj.hostname; + const port = urlObj.port || (protocol === "mysql" ? 3306 : 5432); + const database = urlObj.pathname.replace("/", ""); + + // Send deep link data to the React frontend + win.webContents.send("deep-link", { + protocol, + host, + port, + database, + }); + } catch (error) { + console.error("Invalid deep link:", url); + } + } else { + mainWindow.init(); + } +} bindSavedDocIpc(); bindAnalyticIpc(); diff --git a/src/database/index.tsx b/src/database/index.tsx index 80d5da3..86dd9f4 100644 --- a/src/database/index.tsx +++ b/src/database/index.tsx @@ -5,9 +5,11 @@ import { useMemo, useState } from "react"; import ImportConnectionStringRoute from "./import-connection-string"; import useNavigateToRoute from "@/hooks/useNavigateToRoute"; import ConnectionList from "@/components/database/connection-list"; +import useDeeplink from "@/hooks/useDeeplink"; import Header from "./header"; function ConnectionListRoute() { + useDeeplink(); useNavigateToRoute(); const [search, setSearch] = useState(""); diff --git a/src/hooks/useDeeplink.ts b/src/hooks/useDeeplink.ts new file mode 100644 index 0000000..b3b5326 --- /dev/null +++ b/src/hooks/useDeeplink.ts @@ -0,0 +1,29 @@ +import { OuterbaseProtocols } from "../../electron/constants"; +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +interface Args { + protocol: string; + host: string; + port: string; + database: string; +} +export default function useDeeplink() { + const navigate = useNavigate(); + + useEffect(() => { + const handleDeepLink = (_event: unknown, { database }: Args) => { + const matchRoute = + OuterbaseProtocols.findIndex((protocol) => protocol === database) > -1; + // currently handle only create connection route + if (matchRoute) { + navigate(`/connection/create/${database}`); + } + }; + + window.outerbaseIpc.on("deep-link", handleDeepLink); + return () => { + window.outerbaseIpc.off("deep-link", handleDeepLink); + }; + }, [navigate]); +}