fix(desktop): open external links in system browser (#39820)

This commit is contained in:
Luke Parker 2026-07-31 18:02:11 +10:00 committed by GitHub
commit 2039c90c06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 242 additions and 258 deletions

View file

@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { resolve } from "node:path"
import { pathToFileURL } from "node:url"
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
describe("external URLs", () => {
test("opens web URLs externally", () => {
expect(resolveExternalURL("https://example.com/a?b=c")).toBe("https://example.com/a?b=c")
expect(resolveExternalURL("http://example.com")).toBe("http://example.com/")
})
test("opens mail links externally", () => {
expect(resolveExternalURL("mailto:hello@opencode.ai")).toBe("mailto:hello@opencode.ai")
})
test("rejects file URLs and unsupported protocols", () => {
expect(resolveExternalURL("file:///tmp/index.html")).toBeUndefined()
expect(resolveExternalURL("javascript:alert(1)")).toBeUndefined()
expect(resolveExternalURL("data:text/html,hello")).toBeUndefined()
expect(resolveExternalURL("not a url")).toBeUndefined()
})
test("resolves only local file URLs", () => {
const path = resolve("example.html")
expect(resolveLocalFilePath(pathToFileURL(path).href)).toBe(path)
expect(resolveLocalFilePath("file://example.com/share/index.html")).toBeUndefined()
expect(resolveLocalFilePath("https://example.com/index.html")).toBeUndefined()
})
})

View file

@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url"
export function resolveExternalURL(value: string) {
if (!URL.canParse(value)) return undefined
const url = new URL(value)
if (url.protocol === "http:" || url.protocol === "https:" || url.protocol === "mailto:")
return url.href
return undefined
}
export function resolveLocalFilePath(value: string) {
if (!URL.canParse(value)) return undefined
const url = new URL(value)
if (url.protocol !== "file:" || url.hostname) return undefined
try {
return fileURLToPath(url)
} catch {
return undefined
}
}

View file

@ -17,7 +17,6 @@ import { CHANNEL } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import {
finishFirstLaunchOnboarding,
@ -292,7 +291,6 @@ const main = Effect.gen(function* () {
isOldLayoutEligible,
getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown),
checkAppExists: (appName) => checkAppExists(appName),
resolveAppPath: async (appName) => resolveAppPath(appName),
updater,

View file

@ -1,7 +1,7 @@
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import { basename } from "node:path"
import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
@ -10,7 +10,15 @@ import { runDesktopMenuAction } from "./desktop-menu-actions"
import { setForceFocus } from "./debug"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import {
getPinchZoomEnabled,
getWindowID,
openExternalURL,
openLocalFileURL,
setPinchZoomEnabled,
setTitlebar,
updateTitlebar,
} from "./windows"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
@ -33,7 +41,6 @@ type Deps = {
isOldLayoutEligible: () => Promise<boolean> | boolean
getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | string
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
updater: UpdaterController
@ -63,7 +70,6 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend),
)
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.handle("updater-subscribe", (event) => {
@ -177,8 +183,12 @@ export function registerIpcHandlers(deps: Deps) {
},
)
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
void shell.openExternal(url)
ipcMain.on("open-external", (_event: IpcMainEvent, url: string) => {
openExternalURL(url)
})
ipcMain.on("open-local-file", (_event: IpcMainEvent, url: string) => {
openLocalFileURL(url)
})
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
@ -208,12 +218,6 @@ export function registerIpcHandlers(deps: Deps) {
return { buffer, width: size.width, height: size.height }
})
ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
new Notification({ title, body }).show()
})
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error("Window not found")

View file

@ -1,16 +0,0 @@
import { marked, type Tokens } from "marked"
const renderer = new marked.Renderer()
renderer.link = ({ href, title, text }: Tokens.Link) => {
const titleAttr = title ? ` title="${title}"` : ""
return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
}
export function parseMarkdown(input: string) {
return marked(input, {
renderer,
breaks: false,
gfm: true,
})
}

View file

@ -1,4 +1,4 @@
import { BrowserWindow, Menu, shell } from "electron"
import { BrowserWindow, Menu } from "electron"
import type { MenuItemConstructorOptions } from "electron"
import {
DESKTOP_MENU,
@ -9,6 +9,7 @@ import {
import { UPDATER_ENABLED } from "./constants"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { openExternalURL } from "./windows"
type Deps = {
trigger: (id: string) => void
@ -56,7 +57,7 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
}
if (entry.href) {
const href = entry.href
item.click = () => shell.openExternal(href)
item.click = () => openExternalURL(href)
}
return item

View file

@ -4,7 +4,7 @@ import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
import { randomUUID } from "node:crypto"
import { rmSync } from "node:fs"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol, shell } from "electron"
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
@ -14,6 +14,7 @@ import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
import { createWindowRegistry } from "./window-registry"
import { safeWindowURL } from "./window-state"
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
const root = dirname(fileURLToPath(import.meta.url))
const rendererRoot = join(root, "../renderer")
@ -204,6 +205,7 @@ export function createMainWindow(id: string = randomUUID()) {
allowRendererPermissions(win)
wireWindowRecovery(win, id)
wireNavigationPolicy(win)
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details
@ -230,6 +232,40 @@ export function createMainWindow(id: string = randomUUID()) {
return win
}
export function openExternalURL(value: string) {
const url = resolveExternalURL(value)
if (!url) {
writeLog("window", "blocked external target", { url: value }, "warn")
return
}
void shell.openExternal(url)
}
export function openLocalFileURL(value: string) {
const path = resolveLocalFilePath(value)
if (!path) {
writeLog("window", "blocked local file target", { url: value }, "warn")
return
}
void shell.openPath(path).then((error) => {
if (error) writeLog("window", "failed to open local file", { path, error }, "error")
})
}
function wireNavigationPolicy(win: BrowserWindow) {
win.webContents.setWindowOpenHandler(({ url }) => {
if (!isRendererUrl(url)) openExternalURL(url)
return { action: "deny" }
})
// Renderer reloads (window.location.reload) navigate to the app's own URL
// and must stay in-window; everything else leaves through the OS.
win.webContents.on("will-navigate", (event, url) => {
if (isRendererUrl(url)) return
event.preventDefault()
openExternalURL(url)
})
}
function registerWindow(win: BrowserWindow, id: string) {
windowIDs.set(win, id)
registry.register(id, win)

View file

@ -65,7 +65,6 @@ const api: ElectronAPI = {
isOldLayoutEligible: () => ipcRenderer.invoke("is-old-layout-eligible"),
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
@ -75,7 +74,6 @@ const api: ElectronAPI = {
storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
storeLength: (name) => ipcRenderer.invoke("store-length", name),
getWindowCount: () => ipcRenderer.invoke("get-window-count"),
getWindowID: () => ipcRenderer.invoke("get-window-id"),
onMenuCommand: (cb) => {
const handler = (_: unknown, id: string) => cb(id)
@ -94,11 +92,11 @@ const api: ElectronAPI = {
releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token),
getPathForFile: (file) => webUtils.getPathForFile(file),
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
openLink: (url) => ipcRenderer.send("open-link", url),
openExternal: (url) => ipcRenderer.send("open-external", url),
openLocalFile: (url) => ipcRenderer.send("open-local-file", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
revealPath: (path) => ipcRenderer.invoke("reveal-path", path),
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
getWindowFullscreen: () => ipcRenderer.invoke("get-window-fullscreen"),
onWindowFullscreenChanged: (cb) => {

View file

@ -55,7 +55,6 @@ export type ElectronAPI = {
isOldLayoutEligible: () => Promise<boolean>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
parseMarkdownCommand: (markdown: string) => Promise<string>
checkAppExists: (appName: string) => Promise<boolean>
resolveAppPath: (appName: string) => Promise<string | null>
storeGet: (name: string, key: string) => Promise<string | null>
@ -65,7 +64,6 @@ export type ElectronAPI = {
storeKeys: (name: string) => Promise<string[]>
storeLength: (name: string) => Promise<number>
getWindowCount: () => Promise<number>
getWindowID: () => Promise<string>
onMenuCommand: (cb: (id: string) => void) => () => void
onDeepLink: (cb: (urls: string[]) => void) => () => void
@ -85,11 +83,11 @@ export type ElectronAPI = {
releasePickedFiles: (token: string) => Promise<void>
getPathForFile: (file: File) => string
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void
openExternal: (url: string) => void
openLocalFile: (url: string) => void
openPath: (path: string, app?: string) => Promise<void>
revealPath: (path: string) => Promise<boolean>
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
showNotification: (title: string, body?: string) => void
getWindowFocused: () => Promise<boolean>
getWindowFullscreen: () => Promise<boolean>
onWindowFullscreenChanged: (cb: (fullscreen: boolean) => void) => () => void

View file

@ -4,7 +4,6 @@ import {
ACCEPTED_FILE_EXTENSIONS,
AppBaseProviders,
AppInterface,
handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
@ -18,11 +17,11 @@ import type { UpdaterState } from "@opencode-ai/app/updater"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { initializationData, initializationReady } from "./initialization"
import { initializationData } from "./initialization"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import { windowFullscreen } from "./window-fullscreen"
@ -209,8 +208,11 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
})
},
openLink(url: string) {
window.api.openLink(url)
openExternal(url: string) {
window.api.openExternal(url)
},
openLocalFile(url: string) {
window.api.openLocalFile(url)
},
async openPath(path: string, app?: string) {
if (os === "windows") {
@ -223,14 +225,6 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
return window.api.revealPath(path)
},
back() {
window.history.back()
},
forward() {
window.history.forward()
},
storage,
updater: {
@ -250,7 +244,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
window.api.relaunch()
},
notify: async (title, description, href) => {
notify: async (title, description, onClick) => {
const focused = await window.api.getWindowFocused().catch(() => document.hasFocus())
if (focused) return
@ -261,7 +255,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
notification.onclick = () => {
void window.api.showWindow()
void window.api.setWindowFocus()
handleNotificationClick(href)
onClick?.()
notification.close()
}
},
@ -291,8 +285,6 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
await window.api.setDisplayBackend(backend)
},
parseMarkdown: (markdown: string) => window.api.parseMarkdownCommand(markdown),
webviewZoom,
windowFullscreen,
@ -346,8 +338,6 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
return next satisfies Locale
}
const [windowCount] = createResource(() => window.api.getWindowCount())
// Fetch sidecar credentials (available immediately, before health check)
const [sidecar] = createResource(() => window.api.awaitInitialization())
@ -358,14 +348,6 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
)
const onboarding = Promise.withResolvers<void>()
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
e.preventDefault()
platform.openLink(link.href)
}
}
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
@ -388,7 +370,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
const wslServers = useWslServers()
const ready = createMemo(
() =>
!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading && !wslServers.isLoading,
!defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading,
)
const servers = createMemo(() => {
const data = initializationData(sidecar)
@ -435,13 +417,6 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
)
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
document.removeEventListener("click", handleClick)
})
})
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale={locale.latest}>