From 7dd55438a83288c2c249446e82da44637216e5ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:21:21 +0000 Subject: [PATCH] Open internet links in the default system browser Clicking an http/https/mailto link in rendered markdown now opens it in the default system browser instead of navigating the app window. Right-clicking such a link shows a context menu with options to copy the link or open it in the browser. URL protocols are validated in the main process before handing off to shell.openExternal. Closes #16 https://claude.ai/code/session_01A83A3GHjE9aQz4C6zhwar9 --- src/main/ipc/handlers/ShellHandler.ts | 29 ++++++++ src/preload/preload.ts | 4 ++ src/renderer/components/MarkdownViewer.ts | 67 +++++++++++++++++++ src/shared/types/api.ts | 2 + .../main/ipc/handlers/ShellHandler.test.ts | 61 ++++++++++++++++- 5 files changed, 161 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/handlers/ShellHandler.ts b/src/main/ipc/handlers/ShellHandler.ts index 40def81..1ffa59a 100644 --- a/src/main/ipc/handlers/ShellHandler.ts +++ b/src/main/ipc/handlers/ShellHandler.ts @@ -21,6 +21,15 @@ const EDITOR_COMMANDS: Record, stri zed: 'zed', }; +/** + * Protocols allowed to be opened in the default system browser/handler + */ +const ALLOWED_EXTERNAL_PROTOCOLS = new Set([ + 'http:', + 'https:', + 'mailto:', +]); + /** * Register shell IPC handlers */ @@ -68,6 +77,25 @@ export function registerShellHandlers(): void { } } ); + + // Open an external URL in the default system browser/handler + ipcMain.handle( + IPC_CHANNELS.SHELL.OPEN_EXTERNAL, + async (_event, url: string): Promise => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return; + } + + if (!ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol)) { + return; + } + + await shell.openExternal(parsed.toString()); + } + ); } /** @@ -76,4 +104,5 @@ export function registerShellHandlers(): void { export function unregisterShellHandlers(): void { ipcMain.removeHandler(IPC_CHANNELS.SHELL.REVEAL_IN_FILE_MANAGER); ipcMain.removeHandler(IPC_CHANNELS.SHELL.OPEN_IN_EDITOR); + ipcMain.removeHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL); } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 89aca08..c9905bf 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -328,6 +328,10 @@ const electronAPI: ElectronAPI = { openInEditor: (filePath: string): Promise => { return ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_IN_EDITOR, filePath); }, + + openExternal: (url: string): Promise => { + return ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_EXTERNAL, url); + }, }, }; diff --git a/src/renderer/components/MarkdownViewer.ts b/src/renderer/components/MarkdownViewer.ts index 8f35b0e..c4daf03 100644 --- a/src/renderer/components/MarkdownViewer.ts +++ b/src/renderer/components/MarkdownViewer.ts @@ -91,6 +91,9 @@ export class MarkdownViewer { // Setup context menu handling this.setupContextMenu(); + // Setup external link handling + this.setupLinkHandling(); + this.initialized = true; } @@ -303,12 +306,53 @@ export class MarkdownViewer { }); } + /** + * Setup click handling so links to the internet open in the system browser + */ + private setupLinkHandling(): void { + this.container.addEventListener('click', (e) => { + if (e.button !== 0) return; + + const target = e.target as HTMLElement; + const anchor = target.closest('a[href]'); + if (!(anchor instanceof HTMLAnchorElement)) return; + + const href = anchor.getAttribute('href'); + if (!href || !this.isExternalUrl(href)) return; + + e.preventDefault(); + void window.electronAPI.shell.openExternal(href); + }); + } + + /** + * Determine whether a link points to the internet (vs. an in-document anchor) + */ + private isExternalUrl(href: string): boolean { + try { + const protocol = new URL(href).protocol; + return protocol === 'http:' || protocol === 'https:' || protocol === 'mailto:'; + } catch { + return false; + } + } + /** * Handle context menu event */ private async handleContextMenu(e: MouseEvent): Promise { const target = e.target as HTMLElement; + // External links: offer copy/open actions + const anchor = target.closest('a[href]'); + if (anchor instanceof HTMLAnchorElement) { + const href = anchor.getAttribute('href'); + if (href && this.isExternalUrl(href)) { + await this.handleLinkContextMenu(e, href); + return; + } + } + // Find plugin-rendered element const pluginElement = target.closest('[data-plugin-id]'); if (!pluginElement || !(pluginElement instanceof HTMLElement)) { @@ -356,6 +400,29 @@ export class MarkdownViewer { } } + /** + * Show a context menu for an external link + */ + private async handleLinkContextMenu(e: MouseEvent, href: string): Promise { + e.preventDefault(); + + const selectedId = await window.electronAPI.contextMenu.show({ + items: [ + { id: 'copy-link', label: 'Copy Link to Clipboard', enabled: true }, + { id: 'open-link', label: 'Open in Default Browser', enabled: true }, + ], + x: e.screenX, + y: e.screenY, + }); + + if (selectedId === 'copy-link') { + await window.electronAPI.clipboard.writeText(href); + this.toast.success('Link copied to clipboard'); + } else if (selectedId === 'open-link') { + await window.electronAPI.shell.openExternal(href); + } + } + /** * Highlight an element being targeted by context menu */ diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 763d708..1f8e777 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -78,6 +78,7 @@ export const IPC_CHANNELS = { SHELL: { REVEAL_IN_FILE_MANAGER: 'shell:reveal-in-file-manager', OPEN_IN_EDITOR: 'shell:open-in-editor', + OPEN_EXTERNAL: 'shell:open-external', }, } as const; @@ -254,6 +255,7 @@ export interface OpenInEditorResult { export interface ShellAPI { revealInFileManager: (filePath: string) => Promise; openInEditor: (filePath: string) => Promise; + openExternal: (url: string) => Promise; } /** diff --git a/tests/unit/main/ipc/handlers/ShellHandler.test.ts b/tests/unit/main/ipc/handlers/ShellHandler.test.ts index d12cb0a..773f12e 100644 --- a/tests/unit/main/ipc/handlers/ShellHandler.test.ts +++ b/tests/unit/main/ipc/handlers/ShellHandler.test.ts @@ -31,6 +31,7 @@ vi.mock('electron', () => { }, shell: { showItemInFolder: vi.fn(), + openExternal: vi.fn(() => Promise.resolve()), }, }; }); @@ -89,7 +90,7 @@ describe('ShellHandler', () => { }); describe('registerShellHandlers', () => { - it('should register both shell IPC handlers', () => { + it('should register all shell IPC handlers', () => { registerShellHandlers(); expect(ipcMain.handle).toHaveBeenCalledWith( @@ -100,11 +101,15 @@ describe('ShellHandler', () => { IPC_CHANNELS.SHELL.OPEN_IN_EDITOR, expect.any(Function) ); + expect(ipcMain.handle).toHaveBeenCalledWith( + IPC_CHANNELS.SHELL.OPEN_EXTERNAL, + expect.any(Function) + ); }); }); describe('unregisterShellHandlers', () => { - it('should remove both shell IPC handlers', () => { + it('should remove all shell IPC handlers', () => { registerShellHandlers(); unregisterShellHandlers(); @@ -114,6 +119,58 @@ describe('ShellHandler', () => { expect(ipcMain.removeHandler).toHaveBeenCalledWith( IPC_CHANNELS.SHELL.OPEN_IN_EDITOR ); + expect(ipcMain.removeHandler).toHaveBeenCalledWith( + IPC_CHANNELS.SHELL.OPEN_EXTERNAL + ); + }); + }); + + describe('OPEN_EXTERNAL handler', () => { + it('should open http URLs in the default browser', async () => { + registerShellHandlers(); + + const handler = mockIpcMain._getHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL); + await handler?.({}, 'http://example.com/'); + + expect(shell.openExternal).toHaveBeenCalledWith('http://example.com/'); + }); + + it('should open https URLs in the default browser', async () => { + registerShellHandlers(); + + const handler = mockIpcMain._getHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL); + await handler?.({}, 'https://example.com/page'); + + expect(shell.openExternal).toHaveBeenCalledWith('https://example.com/page'); + }); + + it('should open mailto links', async () => { + registerShellHandlers(); + + const handler = mockIpcMain._getHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL); + await handler?.({}, 'mailto:someone@example.com'); + + expect(shell.openExternal).toHaveBeenCalledWith('mailto:someone@example.com'); + }); + + it('should ignore disallowed protocols', async () => { + registerShellHandlers(); + + const handler = mockIpcMain._getHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL); + await handler?.({}, 'file:///etc/passwd'); + await handler?.({}, 'javascript:alert(1)'); + + expect(shell.openExternal).not.toHaveBeenCalled(); + }); + + it('should ignore invalid URLs', async () => { + registerShellHandlers(); + + const handler = mockIpcMain._getHandler(IPC_CHANNELS.SHELL.OPEN_EXTERNAL); + await handler?.({}, 'not a url'); + await handler?.({}, '#section'); + + expect(shell.openExternal).not.toHaveBeenCalled(); }); });