Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions apps/desktop/docs/DEEP_LINKING.md
Original file line number Diff line number Diff line change
@@ -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
<!-- Simple link -->
<a href="superset://workspace/my-workspace-id">Open Workspace</a>

<!-- JavaScript -->
<button onclick="window.location.href='superset://workspace/my-workspace-id'">
Launch App
</button>
```

### 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 <div>My Component</div>;
}
```

### 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
5 changes: 5 additions & 0 deletions apps/desktop/electron-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export default {
notarize: false,
},

protocols: {
name: displayName,
schemes: ["superset"],
},

linux: {
artifactName,
category: "Utilities",
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/main/lib/deep-link-ipcs.ts
Original file line number Diff line number Diff line change
@@ -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");
}
45 changes: 45 additions & 0 deletions apps/desktop/src/main/lib/deep-link-manager.ts
Original file line number Diff line number Diff line change
@@ -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();
2 changes: 2 additions & 0 deletions apps/desktop/src/main/windows/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
61 changes: 61 additions & 0 deletions apps/desktop/src/renderer/hooks/useDeepLink.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading
Loading