fix(desktop): open external links in system browser (#39820)
This commit is contained in:
parent
da59457ca4
commit
2039c90c06
38 changed files with 242 additions and 258 deletions
29
packages/desktop/src/main/external-url.test.ts
Normal file
29
packages/desktop/src/main/external-url.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
20
packages/desktop/src/main/external-url.ts
Normal file
20
packages/desktop/src/main/external-url.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue