From 801d95b3af258bad5f2b38d8d72a773fc34ec600 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 9 Nov 2025 11:53:00 -0800 Subject: [PATCH] Ship deep links --- apps/desktop/docs/DEEP_LINKING.md | 190 ++++++++++++++++++ apps/desktop/electron-builder.ts | 5 + apps/desktop/src/main/index.ts | 26 +++ apps/desktop/src/main/lib/deep-link-ipcs.ts | 14 ++ .../desktop/src/main/lib/deep-link-manager.ts | 45 +++++ apps/desktop/src/main/windows/main.ts | 2 + .../desktop/src/renderer/hooks/useDeepLink.ts | 61 ++++++ apps/desktop/src/shared/ipc-channels.ts | 7 + 8 files changed, 350 insertions(+) create mode 100644 apps/desktop/docs/DEEP_LINKING.md create mode 100644 apps/desktop/src/main/lib/deep-link-ipcs.ts create mode 100644 apps/desktop/src/main/lib/deep-link-manager.ts create mode 100644 apps/desktop/src/renderer/hooks/useDeepLink.ts diff --git a/apps/desktop/docs/DEEP_LINKING.md b/apps/desktop/docs/DEEP_LINKING.md new file mode 100644 index 00000000000..f67b886accf --- /dev/null +++ b/apps/desktop/docs/DEEP_LINKING.md @@ -0,0 +1,190 @@ +# Deep Linking in Superset Desktop + +This guide explains how deep linking works in the Superset desktop app and how to use it. + +## Overview + +Deep linking allows you to open the Superset desktop app from a website or external application using custom URLs with the `superset://` protocol scheme. + +## How It Works + +### Protocol Registration + +The app registers the `superset://` protocol scheme during startup: + +- **macOS**: Uses `app.setAsDefaultProtocolClient()` to register the protocol handler +- **Windows/Linux**: Same registration mechanism, OS handles the protocol + +### Development vs Production + +In **development mode**, the protocol handler includes the executable path and arguments: + +```typescript +app.setAsDefaultProtocolClient('superset', process.execPath, [path.resolve(process.argv[1])]); +``` + +In **production mode**, it's simpler: + +```typescript +app.setAsDefaultProtocolClient('superset'); +``` + +### URL Handling Flow + +1. **Website/external app** triggers a deep link: `superset://action/something` +2. **OS** routes the URL to the Superset desktop app +3. **Main process** receives the URL via: + - `open-url` event (macOS) - app already running + - Command line args (Windows/Linux) - app launch +4. **Deep link manager** stores the URL +5. **Renderer process** polls for the URL via IPC +6. **Your handler** receives and processes the URL + +## Using Deep Links + +### From a Website + +```html + +Open Workspace + + + +``` + +### In the Renderer Process + +Use the `useDeepLink` hook to handle deep links in your React components: + +```tsx +import { useDeepLink } from '@/renderer/hooks/useDeepLink'; + +function MyComponent() { + useDeepLink((url) => { + console.log('Deep link received:', url); + + // Parse the URL + const urlObj = new URL(url); + + // Handle different deep link types + if (urlObj.hostname === 'workspace') { + const workspaceId = urlObj.pathname.slice(1); + // Load the workspace... + } else if (urlObj.hostname === 'worktree') { + // Handle worktree deep link... + } + }); + + return
My Component
; +} +``` + +### URL Format Examples + +``` +superset://workspace/abc123 # Open workspace by ID +superset://worktree/abc123/def456 # Open workspace + worktree +superset://action/create-workspace # Trigger an action +superset://import?url=https://... # Import with query params +``` + +## Implementation Details + +### Files + +- **Main process**: `apps/desktop/src/main/index.ts` - Protocol registration +- **Deep link manager**: `apps/desktop/src/main/lib/deep-link-manager.ts` - URL storage +- **IPC handlers**: `apps/desktop/src/main/lib/deep-link-ipcs.ts` - IPC communication +- **IPC types**: `apps/desktop/src/shared/ipc-channels.ts` - Type definitions +- **React hook**: `apps/desktop/src/renderer/hooks/useDeepLink.ts` - Renderer API + +### IPC Channel + +The deep link system uses a single IPC channel: + +```typescript +"deep-link-get-url": { + request: void; + response: string | null; +} +``` + +Calling this channel returns and clears the current deep link URL (one-time retrieval). + +### Development Workflow + +1. Start the dev server: `bun dev` +2. In your browser/terminal, trigger a deep link: `open superset://test/hello` +3. The app should receive and log the URL + +### Fallback Handling + +When using deep links from a website, consider that users might not have the app installed: + +```javascript +function openApp() { + const deepLink = 'superset://workspace/abc123'; + const timeout = setTimeout(() => { + // App didn't open, redirect to download page + window.location.href = '/download'; + }, 2000); + + window.addEventListener('blur', () => { + // App likely opened + clearTimeout(timeout); + }); + + window.location.href = deepLink; +} +``` + +## Testing + +### macOS + +```bash +# Open a deep link from terminal +open superset://test/hello + +# Or use a direct protocol handler +open -a Superset superset://workspace/abc123 +``` + +### Windows + +```powershell +# Run from PowerShell +start superset://test/hello +``` + +### Linux + +```bash +# Run from terminal +xdg-open superset://test/hello +``` + +## Security Considerations + +- Always validate and sanitize deep link URLs before processing +- Never execute arbitrary code from deep link parameters +- Treat deep link data as untrusted user input +- Validate workspace/worktree IDs exist before navigation + +## Troubleshooting + +**Deep links not working in development:** +- Make sure the app is running (`bun dev`) +- Check console logs for protocol registration messages +- On macOS, try `open superset://test` to verify protocol is registered + +**URL not being received in renderer:** +- Check the polling interval in `useDeepLink` hook +- Verify IPC handler is registered in `main/windows/main.ts` +- Check browser console for errors + +**Multiple instances opening:** +- This is expected behavior - the app allows multiple instances +- Each instance will independently handle deep links diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 940f070ebec..ea108d93390 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -42,6 +42,11 @@ export default { notarize: false, }, + protocols: { + name: displayName, + schemes: ["superset"], + }, + linux: { artifactName, category: "Utilities", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e7248f42731..0c8710e928d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -5,11 +5,37 @@ import { config } from "dotenv"; // Use override: true to ensure .env values take precedence over inherited env vars config({ path: resolve(__dirname, "../../../../.env"), override: true }); +import path from "node:path"; import { app } from "electron"; import { makeAppSetup } from "lib/electron-app/factories/app/setup"; +import { deepLinkManager } from "main/lib/deep-link-manager"; import { getPort } from "main/lib/port-manager"; import { MainWindow } from "./windows/main"; +// Protocol scheme for deep linking +const PROTOCOL_SCHEME = "superset"; + +// Register protocol handler for deep linking +// In development, we need to provide the execPath and args +if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient( + PROTOCOL_SCHEME, + process.execPath, + [path.resolve(process.argv[1])], + ); + } +} else { + app.setAsDefaultProtocolClient(PROTOCOL_SCHEME); +} + +// macOS: Handle deep link when app is already running +app.on("open-url", (event, url) => { + event.preventDefault(); + console.log("Deep link URL (open-url):", url); + deepLinkManager.setUrl(url); +}); + // Allow multiple instances - removed single instance lock // Each instance will use the same default user data directory // To use separate data directories, launch with: --user-data-dir=/path/to/custom/dir diff --git a/apps/desktop/src/main/lib/deep-link-ipcs.ts b/apps/desktop/src/main/lib/deep-link-ipcs.ts new file mode 100644 index 00000000000..a6a8c0686ae --- /dev/null +++ b/apps/desktop/src/main/lib/deep-link-ipcs.ts @@ -0,0 +1,14 @@ +import { ipcMain } from "electron"; +import { deepLinkManager } from "./deep-link-manager"; + +/** + * Register IPC handlers for deep linking + */ +export function registerDeepLinkIpcs(): void { + // Get the current deep link URL + ipcMain.handle("deep-link-get-url", async () => { + return deepLinkManager.getAndClearUrl(); + }); + + console.log("[DeepLinkIpcs] Registered deep link IPC handlers"); +} diff --git a/apps/desktop/src/main/lib/deep-link-manager.ts b/apps/desktop/src/main/lib/deep-link-manager.ts new file mode 100644 index 00000000000..073a4fd92c7 --- /dev/null +++ b/apps/desktop/src/main/lib/deep-link-manager.ts @@ -0,0 +1,45 @@ +/** + * Deep Link Manager + * + * Manages deep link URLs for the application. + * Handles both app launch and runtime deep links. + */ +class DeepLinkManager { + private currentUrl: string | null = null; + + /** + * Set the deep link URL + */ + setUrl(url: string): void { + console.log("[DeepLinkManager] Setting deep link URL:", url); + this.currentUrl = url; + } + + /** + * Get and clear the deep link URL + * @returns The current deep link URL, or null if none + */ + getAndClearUrl(): string | null { + const url = this.currentUrl; + this.currentUrl = null; + return url; + } + + /** + * Get the deep link URL without clearing it + * @returns The current deep link URL, or null if none + */ + getUrl(): string | null { + return this.currentUrl; + } + + /** + * Clear the deep link URL + */ + clearUrl(): void { + this.currentUrl = null; + } +} + +// Export singleton instance +export const deepLinkManager = new DeepLinkManager(); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 0d8f41055bb..3abb01267f9 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -5,6 +5,7 @@ import { createWindow } from "lib/electron-app/factories/windows/create"; import { ENVIRONMENT } from "shared/constants"; import { displayName } from "~/package.json"; import { createApplicationMenu } from "../lib/menu"; +import { registerDeepLinkIpcs } from "../lib/deep-link-ipcs"; import { portDetector } from "../lib/port-detector"; import { registerPortIpcs } from "../lib/port-ipcs"; import { registerTerminalIPCs } from "../lib/terminal-ipcs"; @@ -43,6 +44,7 @@ export async function MainWindow() { const cleanupTerminal = registerTerminalIPCs(window); registerWorkspaceIPCs(); registerPortIpcs(); + registerDeepLinkIpcs(); // Set up port detection listeners portDetector.on("port-detected", async (event: any) => { diff --git a/apps/desktop/src/renderer/hooks/useDeepLink.ts b/apps/desktop/src/renderer/hooks/useDeepLink.ts new file mode 100644 index 00000000000..540c17860db --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useDeepLink.ts @@ -0,0 +1,61 @@ +import { useEffect } from "react"; + +/** + * Hook to handle deep link URLs + * + * @param handler - Callback function to handle the deep link URL + * @param pollInterval - Interval in milliseconds to poll for deep link URLs (default: 1000ms) + * + * @example + * ```tsx + * useDeepLink((url) => { + * console.log('Deep link received:', url); + * // Parse and handle the URL + * const urlObj = new URL(url); + * if (urlObj.hostname === 'workspace') { + * // Handle workspace deep link + * const workspaceId = urlObj.pathname.slice(1); + * // Load workspace... + * } + * }); + * ``` + */ +export function useDeepLink( + handler: (url: string) => void, + pollInterval = 1000, +): void { + useEffect(() => { + let mounted = true; + let timeoutId: NodeJS.Timeout; + + const checkForDeepLink = async () => { + if (!mounted) return; + + try { + const url = await window.ipcRenderer.invoke("deep-link-get-url"); + if (url && mounted) { + console.log("[useDeepLink] Deep link received:", url); + handler(url); + } + } catch (error) { + console.error("[useDeepLink] Error checking for deep link:", error); + } + + // Schedule next check + if (mounted) { + timeoutId = setTimeout(checkForDeepLink, pollInterval); + } + }; + + // Start polling + checkForDeepLink(); + + // Cleanup + return () => { + mounted = false; + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [handler, pollInterval]); +} diff --git a/apps/desktop/src/shared/ipc-channels.ts b/apps/desktop/src/shared/ipc-channels.ts index f1f3403f825..024d8d3f2e0 100644 --- a/apps/desktop/src/shared/ipc-channels.ts +++ b/apps/desktop/src/shared/ipc-channels.ts @@ -332,6 +332,12 @@ export interface IpcChannels { active: boolean; }>; }; + + // Deep linking + "deep-link-get-url": { + request: void; + response: string | null; + }; } /** @@ -395,6 +401,7 @@ export function isValidChannel(channel: string): channel is IpcChannelName { "workspace-set-ports", "workspace-get-detected-ports", "proxy-get-status", + "deep-link-get-url", ]; return validChannels.includes(channel as IpcChannelName); }