diff --git a/packages/desktop/src/renderer/desktop-app.tsx b/packages/desktop/src/renderer/desktop-app.tsx
new file mode 100644
index 00000000000..ccc2807a3aa
--- /dev/null
+++ b/packages/desktop/src/renderer/desktop-app.tsx
@@ -0,0 +1,121 @@
+///
+
+// Load the complete preload contract before App's optional browser bridge declaration.
+import {
+ AppBaseProviders,
+ AppInterface,
+ PlatformProvider,
+ ServerConnection,
+ useCommand,
+ useLanguage,
+ useWslServers,
+ type UpdaterPlatform,
+} from "@opencode-ai/app"
+import { useTheme } from "@opencode-ai/ui/theme/context"
+import type { BaseRouterProps } from "@solidjs/router"
+import { createEffect, createMemo, createResource, Show } from "solid-js"
+import type { ElectronAPI } from "../preload/types"
+import { MigrationStatus } from "./migration-status"
+import { DesktopFirstLaunchOnboarding } from "./onboarding"
+import { createDesktopPlatform, type DesktopWindowState } from "./platform"
+import { bindDesktopMenu } from "./platform/menu"
+import { initializationData } from "./startup/initialization"
+import { preloadStoredLocale } from "./startup/locale"
+import { LoadingSplash } from "./startup/splash"
+import { getLastActiveUrl } from "./window/route-storage"
+import { DesktopMemoryRouter } from "./window/router"
+import { availableStartupServer, readyWslConnections } from "./wsl/connections"
+
+export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; version: string }) {
+ const [windowState] = createResource(() => props.api.getWindowID().then((id) => ({ id, version: props.version })))
+ return (
+ } keyed>
+ {(state) => }
+
+ )
+}
+
+function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; windowState: DesktopWindowState }) {
+ const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
+ const [sidecar] = createResource(() => props.api.awaitInitialization())
+ const [defaultServer] = createResource(() => platform.getDefaultServer?.())
+ const [locale] = createResource(() => preloadStoredLocale(platform))
+ const router = (routerProps: BaseRouterProps) => (
+
+ )
+
+ function ReadyApp() {
+ const wslServers = useWslServers()
+ const language = useLanguage()
+ const ready = createMemo(
+ () => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading,
+ )
+ const servers = createMemo(() => {
+ const data = initializationData(sidecar)
+ const list: ServerConnection.Any[] = []
+ if (data) {
+ list.push({
+ displayName: language.t("desktop.server.local"),
+ type: "sidecar",
+ variant: "base",
+ http: {
+ url: data.url,
+ username: data.username ?? undefined,
+ password: data.password ?? undefined,
+ },
+ })
+ }
+ list.push(...readyWslConnections(wslServers.data, language.t("wsl.server.label")))
+ return list
+ })
+ const effectiveDefaultServer = createMemo(() =>
+ ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
+ )
+
+ return (
+ }>
+
+ {(key) => (
+
+
+
+
+ {(server) => }
+
+
+ )}
+
+
+ )
+ }
+
+ return (
+
+ void props.api.setNativeTranslations(bundle).catch(() => undefined)}
+ >
+ {(_) => }
+
+
+ )
+}
+
+function DesktopEffects(props: { api: ElectronAPI }) {
+ const command = useCommand()
+ bindDesktopMenu((id) => command.trigger(id))
+ const theme = useTheme()
+
+ createEffect(() => {
+ theme.themeId()
+ theme.mode()
+ const background = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
+ if (background) void props.api.setBackgroundColor(background)
+ })
+
+ return null
+}
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index c34ed1a2167..cd519a00f92 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -1,432 +1,21 @@
// @refresh reload
import "./diagnostics"
-import {
- ACCEPTED_FILE_EXTENSIONS,
- AppBaseProviders,
- AppInterface,
- loadLocaleDict,
- normalizeLocale,
- type Locale,
- type Platform,
- PlatformProvider,
- createDraftStore,
- ServerConnection,
- useCommand,
- useWslServers,
- useLanguage,
-} from "@opencode-ai/app"
-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, Show } from "solid-js"
-import { render } from "solid-js/web"
-import pkg from "../../package.json"
-import { t } from "./i18n"
-import { initializationData } from "./initialization"
-import { DesktopFirstLaunchOnboarding } from "./onboarding"
-import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
-import { windowFullscreen } from "./window-fullscreen"
-import { availableStartupServer, readyWslConnections } from "./wsl/connections"
-import { MigrationStatus } from "./migration-status"
import "./styles.css"
-import { Splash } from "@opencode-ai/ui/logo"
-import { useTheme } from "@opencode-ai/ui/theme/context"
+import { render } from "solid-js/web"
+import { DesktopApp } from "./desktop-app"
+import { startDesktopMenu } from "./platform/menu"
+import { startDesktopUpdater } from "./platform/updater"
+import { startDeepLinks } from "./startup/deep-links"
+import { requireRendererRoot } from "./startup/root"
+import { desktopVersion, initializeSentry } from "./startup/sentry"
-const root = document.getElementById("root")
-const version = import.meta.env.OPENCODE_VERSION ?? pkg.version
-if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
- throw new Error(t("desktop.error.dev.rootNotFound"))
-}
+const root = requireRendererRoot()
+const version = desktopVersion()
+initializeSentry(version)
-if (import.meta.env.VITE_SENTRY_DSN) {
- Sentry.init({
- dsn: import.meta.env.VITE_SENTRY_DSN,
- environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
- release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${version}`,
- initialScope: {
- tags: {
- platform: "desktop",
- },
- },
- integrations: (integrations) => {
- return integrations.filter(
- (i) =>
- i.name !== "Breadcrumbs" &&
- !(
- import.meta.env.OPENCODE_CHANNEL === "prod" &&
- (i.name === "GlobalHandlers" || i.name === "BrowserApiErrors")
- ),
- )
- },
- })
-}
+const updater = startDesktopUpdater(window.api)
+startDesktopMenu(window.api)
+startDeepLinks(window.api)
-const [updaterState, setUpdaterState] = createSignal({ status: "disabled" })
-void window.api.updater.subscribe(setUpdaterState)
-
-const deepLinkEvent = "opencode:deep-link"
-
-type DesktopWindowState = {
- id: string
-}
-
-const emitDeepLinks = (urls: string[]) => {
- if (urls.length === 0) return
- window.__OPENCODE__ ??= {}
- const pending = window.__OPENCODE__.deepLinks ?? []
- window.__OPENCODE__.deepLinks = [...pending, ...urls]
- window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
-}
-
-const listenForDeepLinks = () => {
- void window.api.consumeInitialDeepLinks().then((urls) => emitDeepLinks(urls))
- return window.api.onDeepLink((urls) => emitDeepLinks(urls))
-}
-
-function windowLastActiveUrlKey(windowID: string) {
- return `opencode.desktop.window.${windowID}.last-active-url`
-}
-
-function getLastActiveUrl(windowID: string) {
- if (typeof localStorage !== "object") return "/"
- try {
- const value = localStorage.getItem(windowLastActiveUrlKey(windowID))
- if (value === "/") return value
- const path = value?.split(/[?#]/, 1)[0]
- if (path === "/new-session") return value ?? "/"
- if (/^\/server\/[^/]+\/session\/[^/]+$/.test(path ?? "")) return value ?? "/"
- } catch {}
- return "/"
-}
-
-function setLastActiveUrl(windowID: string, value: string) {
- if (typeof localStorage !== "object") return
- try {
- localStorage.setItem(windowLastActiveUrlKey(windowID), value)
- } catch {}
-}
-
-function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) {
- const history = createMemoryHistory()
- const initialUrl = getLastActiveUrl(props.windowID)
- if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false })
- onCleanup(history.listen((value) => setLastActiveUrl(props.windowID, value)))
- return
-}
-
-const createPlatform = (windowState: DesktopWindowState): Platform => {
- const attachmentPaths = new WeakMap()
- const os = (() => {
- const ua = navigator.userAgent
- if (ua.includes("Mac")) return "macos"
- if (ua.includes("Windows")) return "windows"
- if (ua.includes("Linux")) return "linux"
- return undefined
- })()
-
- const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
- switch (action) {
- case "view.resetZoom":
- resetZoom()
- return
- case "view.zoomIn":
- zoomIn()
- return
- case "view.zoomOut":
- zoomOut()
- return
- }
-
- return window.api.runDesktopMenuAction(action)
- }
-
- const storage = (() => {
- const cache = new Map()
-
- const createStorage = (name: string) => {
- const api: AsyncStorage = {
- getItem: (key: string) => window.api.storeGet(name, key),
- setItem: (key: string, value: string) => window.api.storeSet(name, key, value),
- removeItem: (key: string) => window.api.storeDelete(name, key),
- clear: () => window.api.storeClear(name),
- key: async (index: number) => (await window.api.storeKeys(name))[index],
- getLength: () => window.api.storeLength(name),
- get length() {
- return api.getLength()
- },
- }
- return api
- }
-
- return (name = "default.dat") => {
- const cached = cache.get(name)
- if (cached) return cached
- const api = createStorage(name)
- cache.set(name, api)
- return api
- }
- })()
-
- const wslServersApi = os === "windows" ? window.api.wslServers : undefined
-
- return {
- platform: "desktop",
- os,
- version,
- windowID: windowState.id,
-
- async openDirectoryPickerDialog(opts) {
- return window.api.openDirectoryPicker({
- multiple: opts?.multiple ?? false,
- title: opts?.title,
- })
- },
-
- async openAttachmentPickerDialog(opts, onFile) {
- const result = await window.api.openFilePicker({
- multiple: opts?.multiple ?? false,
- title: opts?.title,
- defaultPath: opts?.defaultPath,
- extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
- })
- if (!result) return
- try {
- for (const file of result.files) {
- const selected = new File([await window.api.readPickedFile(result.token, file.path)], file.name)
- attachmentPaths.set(selected, file.path)
- await onFile(selected)
- }
- } finally {
- await window.api.releasePickedFiles(result.token)
- }
- },
-
- getPathForFile(file) {
- return attachmentPaths.get(file) ?? window.api.getPathForFile(file)
- },
-
- async saveFilePickerDialog(opts) {
- return window.api.saveFilePicker({
- title: opts?.title,
- defaultPath: opts?.defaultPath,
- })
- },
-
- openExternal(url: string) {
- window.api.openExternal(url)
- },
- openLocalFile(url: string) {
- window.api.openLocalFile(url)
- },
- async openPath(path: string, app?: string) {
- if (os === "windows") {
- const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
- await window.api.openPath(path, resolvedApp ?? undefined)
- return
- }
- await window.api.openPath(path, app)
- },
- async revealPath(path: string) {
- return window.api.revealPath(path)
- },
-
- storage,
- draftStore: createDraftStore({
- get: window.api.draftGet,
- set: window.api.draftSet,
- remove: window.api.draftDelete,
- putBlob: (blob) => blob.arrayBuffer().then(window.api.draftBlobPut),
- getBlob: (id) => window.api.draftBlobGet(id).then((data) => data && new Blob([data])),
- }),
-
- updater: {
- state: updaterState,
- check: () => window.api.updater.check(),
- install: () => window.api.updater.install(),
- },
-
- exportDebugLogs: () => window.api.exportDebugLogs(),
-
- setForceFocus: (enabled) => window.api.setForceFocus(enabled),
-
- recordFatalRendererError: (error) => window.api.recordFatalRendererError(error),
-
- restart: async () => window.api.relaunch(),
-
- notify: async (title, description, onClick) => {
- const focused = await window.api.getWindowFocused().catch(() => document.hasFocus())
- if (focused) return
-
- const notification = new Notification(title, {
- body: description ?? "",
- icon: "https://opencode.ai/favicon-96x96-v3.png",
- })
- notification.onclick = () => {
- void window.api.showWindow()
- void window.api.setWindowFocus()
- onClick?.()
- notification.close()
- }
- },
-
- fetch: (input, init) => {
- if (input instanceof Request) return fetch(input)
- return fetch(input, init)
- },
-
- getDefaultServer: async () => {
- const url = await window.api.getDefaultServerUrl().catch(() => null)
- if (!url) return null
- return ServerConnection.Key.make(url)
- },
-
- setDefaultServer: async (url: string | null) => {
- await window.api.setDefaultServerUrl(url)
- },
-
- wslServers: wslServersApi,
-
- webviewZoom,
-
- windowFullscreen,
-
- getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(),
-
- setPinchZoomEnabled,
-
- runDesktopMenuAction,
-
- checkAppExists: async (appName: string) => {
- return window.api.checkAppExists(appName)
- },
-
- async readClipboardImage() {
- const image = await window.api.readClipboardImage().catch(() => null)
- if (!image) return null
- const blob = new Blob([image.buffer], { type: "image/png" })
- return new File([blob], `pasted-image-${Date.now()}.png`, {
- type: "image/png",
- })
- },
- }
-}
-
-let menuTrigger = null as null | ((id: string) => void)
-window.api.onMenuCommand((id) => {
- menuTrigger?.(id)
-})
-listenForDeepLinks()
-
-function LoadingSplash() {
- return (
-
-
-
- )
-}
-
-function DesktopRoot(props: { windowState: DesktopWindowState }) {
- const platform = createPlatform(props.windowState)
- const loadLocale = async () => {
- const raw = await platform.storage?.("opencode.global.dat").getItem("language")
- if (!raw) return
- const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
- if (!locale) return
- const next = normalizeLocale(locale)
- if (next !== "en") await loadLocaleDict(next)
- return next satisfies Locale
- }
-
- // Fetch sidecar credentials (available immediately, before health check)
- const [sidecar] = createResource(() => window.api.awaitInitialization())
-
- const [defaultServer] = createResource(() => platform.getDefaultServer?.())
- const [locale] = createResource(loadLocale)
- const router = (routerProps: BaseRouterProps) => (
-
- )
- function DesktopEffects() {
- const cmd = useCommand()
- menuTrigger = (id) => cmd.trigger(id)
-
- const theme = useTheme()
-
- createEffect(() => {
- theme.themeId()
- theme.mode()
- const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
- if (bg) {
- void window.api.setBackgroundColor(bg)
- }
- })
-
- return null
- }
-
- function App() {
- const wslServers = useWslServers()
- const language = useLanguage()
- const ready = createMemo(
- () => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading,
- )
- const servers = createMemo(() => {
- const data = initializationData(sidecar)
- const list: ServerConnection.Any[] = []
- if (data) {
- list.push({
- displayName: language.t("desktop.server.local"),
- type: "sidecar",
- variant: "base",
- http: {
- url: data.url,
- username: data.username ?? undefined,
- password: data.password ?? undefined,
- },
- })
- }
- list.push(...readyWslConnections(wslServers.data, language.t("wsl.server.label")))
- return list
- })
- const effectiveDefaultServer = createMemo(() =>
- ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
- )
- return (
- }>
-
- {(key) => (
-
-
-
-
- {(server) => }
-
-
- )}
-
-
- )
- }
-
- return (
-
- void window.api.setNativeTranslations(bundle).catch(() => undefined)}
- >
- {(_) => }
-
-
- )
-}
-
-render(() => {
- const [windowState] = createResource(() => window.api.getWindowID().then((id) => ({ id })))
-
- return (
- } keyed>
- {(state) => }
-
- )
-}, root!)
+render(() => , root)
diff --git a/packages/desktop/src/renderer/onboarding.tsx b/packages/desktop/src/renderer/onboarding/index.tsx
similarity index 78%
rename from packages/desktop/src/renderer/onboarding.tsx
rename to packages/desktop/src/renderer/onboarding/index.tsx
index 0638c19d21a..4802b3a9242 100644
--- a/packages/desktop/src/renderer/onboarding.tsx
+++ b/packages/desktop/src/renderer/onboarding/index.tsx
@@ -1,7 +1,12 @@
import { ServerConnection, useServers, useTabs } from "@opencode-ai/app"
import { onMount } from "solid-js"
+import type { ElectronAPI } from "../../preload/types"
-export function DesktopFirstLaunchOnboarding(props: { serverKey: ServerConnection.Key; initialUrl: string }) {
+export function DesktopFirstLaunchOnboarding(props: {
+ api: ElectronAPI
+ serverKey: ServerConnection.Key
+ initialUrl: string
+}) {
const server = useServers()
const tabs = useTabs()
@@ -12,7 +17,7 @@ export function DesktopFirstLaunchOnboarding(props: { serverKey: ServerConnectio
async function runFirstLaunchOnboarding() {
try {
await Promise.all([tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()))
- const pending = await window.api.isFirstLaunchOnboardingPending()
+ const pending = await props.api.isFirstLaunchOnboardingPending()
if (!pending) return
const shouldTrigger =
@@ -26,7 +31,7 @@ export function DesktopFirstLaunchOnboarding(props: { serverKey: ServerConnectio
servers: server.list.map(ServerConnection.key),
})
- const directory = await window.api.finishFirstLaunchOnboarding(shouldTrigger)
+ const directory = await props.api.finishFirstLaunchOnboarding(shouldTrigger)
if (!shouldTrigger || !directory) return
console.info("[desktop-onboarding] starting first launch draft", { directory })
diff --git a/packages/desktop/src/renderer/platform/files.test.ts b/packages/desktop/src/renderer/platform/files.test.ts
new file mode 100644
index 00000000000..84dd8c5de97
--- /dev/null
+++ b/packages/desktop/src/renderer/platform/files.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test"
+import { createDesktopFiles } from "./files"
+
+function fileApi(events: string[]) {
+ return {
+ openDirectoryPicker: async () => null,
+ openFilePicker: async () => ({
+ token: "selection",
+ files: [
+ { path: "C:\\first.txt", name: "first.txt", size: 5 },
+ { path: "C:\\second.txt", name: "second.txt", size: 6 },
+ ],
+ }),
+ readPickedFile: async (_token: string, path: string) => {
+ events.push(`read:${path}`)
+ return new TextEncoder().encode(path).buffer
+ },
+ releasePickedFiles: async (token: string) => {
+ events.push(`release:${token}`)
+ },
+ getPathForFile: () => "fallback",
+ saveFilePicker: async () => null,
+ openExternal: () => {},
+ openLocalFile: () => {},
+ resolveAppPath: async () => null,
+ openPath: async () => undefined,
+ revealPath: async () => false,
+ readClipboardImage: async () => null,
+ }
+}
+
+describe("desktop attachment files", () => {
+ test("reads selected files sequentially and releases the token", async () => {
+ const events: string[] = []
+ const files = createDesktopFiles(fileApi(events), "windows", ["txt"])
+
+ await files.openAttachmentPickerDialog({}, async (file) => {
+ events.push(`file:${file.name}`)
+ })
+
+ expect(events).toEqual([
+ "read:C:\\first.txt",
+ "file:first.txt",
+ "read:C:\\second.txt",
+ "file:second.txt",
+ "release:selection",
+ ])
+ })
+
+ test("releases the token when a selected file callback fails", async () => {
+ const events: string[] = []
+ const files = createDesktopFiles(fileApi(events), "windows", ["txt"])
+
+ await expect(
+ files.openAttachmentPickerDialog({}, async () => {
+ throw new Error("attachment rejected")
+ }),
+ ).rejects.toThrow("attachment rejected")
+ expect(events.at(-1)).toBe("release:selection")
+ })
+})
diff --git a/packages/desktop/src/renderer/platform/files.ts b/packages/desktop/src/renderer/platform/files.ts
new file mode 100644
index 00000000000..7b1962d5091
--- /dev/null
+++ b/packages/desktop/src/renderer/platform/files.ts
@@ -0,0 +1,78 @@
+import type { Platform } from "@opencode-ai/app"
+import type { ElectronAPI } from "../../preload/types"
+
+type DesktopOS = Extract["os"]
+type DesktopFileAPI = Pick<
+ ElectronAPI,
+ | "openDirectoryPicker"
+ | "openFilePicker"
+ | "readPickedFile"
+ | "releasePickedFiles"
+ | "getPathForFile"
+ | "saveFilePicker"
+ | "openExternal"
+ | "openLocalFile"
+ | "resolveAppPath"
+ | "openPath"
+ | "revealPath"
+ | "readClipboardImage"
+>
+
+export function createDesktopFiles(api: DesktopFileAPI, os: DesktopOS, acceptedExtensions: string[]) {
+ const attachmentPaths = new WeakMap()
+ const openDirectoryPickerDialog: Extract["openDirectoryPickerDialog"] = async (
+ options,
+ ) => {
+ return api.openDirectoryPicker({
+ multiple: options?.multiple ?? false,
+ title: options?.title,
+ })
+ }
+ const openAttachmentPickerDialog: NonNullable = async (options, onFile) => {
+ const result = await api.openFilePicker({
+ multiple: options?.multiple ?? false,
+ title: options?.title,
+ defaultPath: options?.defaultPath,
+ extensions: options?.extensions ?? acceptedExtensions,
+ })
+ if (!result) return
+ try {
+ for (const file of result.files) {
+ const selected = new File([await api.readPickedFile(result.token, file.path)], file.name)
+ attachmentPaths.set(selected, file.path)
+ await onFile(selected)
+ }
+ } finally {
+ await api.releasePickedFiles(result.token)
+ }
+ }
+
+ return {
+ openDirectoryPickerDialog,
+ openAttachmentPickerDialog,
+ getPathForFile: (file: File) => attachmentPaths.get(file) ?? api.getPathForFile(file),
+ async saveFilePickerDialog(options?: { title?: string; defaultPath?: string }) {
+ return api.saveFilePicker({ title: options?.title, defaultPath: options?.defaultPath })
+ },
+ openExternal: (url: string) => api.openExternal(url),
+ openLocalFile: (url: string) => api.openLocalFile(url),
+ async openPath(path: string, app?: string) {
+ if (os !== "windows") {
+ await api.openPath(path, app)
+ return
+ }
+ const resolvedApp = app ? await api.resolveAppPath(app).catch(() => null) : null
+ await api.openPath(path, resolvedApp ?? undefined)
+ },
+ async revealPath(path: string) {
+ return api.revealPath(path)
+ },
+ async readClipboardImage() {
+ const image = await api.readClipboardImage().catch(() => null)
+ if (!image) return null
+ return new File([new Blob([image.buffer], { type: "image/png" })], `pasted-image-${Date.now()}.png`, {
+ type: "image/png",
+ })
+ },
+ }
+}
diff --git a/packages/desktop/src/renderer/platform/index.ts b/packages/desktop/src/renderer/platform/index.ts
new file mode 100644
index 00000000000..3abc6c9f124
--- /dev/null
+++ b/packages/desktop/src/renderer/platform/index.ts
@@ -0,0 +1,63 @@
+import { ACCEPTED_FILE_EXTENSIONS, ServerConnection, type Platform, type UpdaterPlatform } from "@opencode-ai/app"
+import type { ElectronAPI } from "../../preload/types"
+import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
+import { windowFullscreen } from "../window/fullscreen"
+import { createDesktopFiles } from "./files"
+import { createDesktopMenuAction } from "./menu"
+import { createDesktopNotify } from "./notifications"
+import { createDesktopStorage } from "./storage"
+
+export type DesktopWindowState = {
+ id: string
+ version: string
+}
+
+export function createDesktopPlatform(
+ api: ElectronAPI,
+ windowState: DesktopWindowState,
+ updater: UpdaterPlatform,
+): Platform {
+ const os = desktopOS()
+ return {
+ platform: "desktop",
+ os,
+ version: windowState.version,
+ windowID: windowState.id,
+ ...createDesktopFiles(api, os, ACCEPTED_FILE_EXTENSIONS),
+ ...createDesktopStorage(api),
+ updater,
+ exportDebugLogs: () => api.exportDebugLogs(),
+ setForceFocus: (enabled) => api.setForceFocus(enabled),
+ recordFatalRendererError: (error) => api.recordFatalRendererError(error),
+ restart: async () => api.relaunch(),
+ notify: createDesktopNotify(api),
+ fetch: (input, init) => {
+ if (input instanceof Request) return fetch(input)
+ return fetch(input, init)
+ },
+ getDefaultServer: async () => {
+ const url = await api.getDefaultServerUrl().catch(() => null)
+ if (!url) return null
+ return ServerConnection.Key.make(url)
+ },
+ setDefaultServer: async (url) => {
+ await api.setDefaultServerUrl(url)
+ },
+ wslServers: os === "windows" ? api.wslServers : undefined,
+ webviewZoom,
+ windowFullscreen,
+ getPinchZoomEnabled: () => api.getPinchZoomEnabled(),
+ setPinchZoomEnabled,
+ runDesktopMenuAction: createDesktopMenuAction(api),
+ checkAppExists: async (appName) => {
+ return api.checkAppExists(appName)
+ },
+ }
+}
+
+function desktopOS() {
+ if (navigator.userAgent.includes("Mac")) return "macos"
+ if (navigator.userAgent.includes("Windows")) return "windows"
+ if (navigator.userAgent.includes("Linux")) return "linux"
+ return undefined
+}
diff --git a/packages/desktop/src/renderer/platform/menu.ts b/packages/desktop/src/renderer/platform/menu.ts
new file mode 100644
index 00000000000..78698c64020
--- /dev/null
+++ b/packages/desktop/src/renderer/platform/menu.ts
@@ -0,0 +1,30 @@
+import type { Platform } from "@opencode-ai/app"
+import type { ElectronAPI } from "../../preload/types"
+import { resetZoom, zoomIn, zoomOut } from "../window/zoom"
+
+let trigger: ((id: string) => void) | null = null
+
+export function startDesktopMenu(api: ElectronAPI) {
+ api.onMenuCommand((id) => trigger?.(id))
+}
+
+export function bindDesktopMenu(next: (id: string) => void) {
+ trigger = next
+}
+
+export function createDesktopMenuAction(api: ElectronAPI): NonNullable {
+ return (action) => {
+ switch (action) {
+ case "view.resetZoom":
+ resetZoom()
+ return
+ case "view.zoomIn":
+ zoomIn()
+ return
+ case "view.zoomOut":
+ zoomOut()
+ return
+ }
+ return api.runDesktopMenuAction(action)
+ }
+}
diff --git a/packages/desktop/src/renderer/platform/notifications.ts b/packages/desktop/src/renderer/platform/notifications.ts
new file mode 100644
index 00000000000..b293de15db6
--- /dev/null
+++ b/packages/desktop/src/renderer/platform/notifications.ts
@@ -0,0 +1,20 @@
+import type { Platform } from "@opencode-ai/app"
+import type { ElectronAPI } from "../../preload/types"
+
+export function createDesktopNotify(api: ElectronAPI): Platform["notify"] {
+ return async (title, description, onClick) => {
+ const focused = await api.getWindowFocused().catch(() => document.hasFocus())
+ if (focused) return
+
+ const notification = new Notification(title, {
+ body: description ?? "",
+ icon: "https://opencode.ai/favicon-96x96-v3.png",
+ })
+ notification.onclick = () => {
+ void api.showWindow()
+ void api.setWindowFocus()
+ onClick?.()
+ notification.close()
+ }
+ }
+}
diff --git a/packages/desktop/src/renderer/platform/storage.ts b/packages/desktop/src/renderer/platform/storage.ts
new file mode 100644
index 00000000000..b3845eb018c
--- /dev/null
+++ b/packages/desktop/src/renderer/platform/storage.ts
@@ -0,0 +1,35 @@
+import { createDraftStore, type Platform } from "@opencode-ai/app"
+import type { AsyncStorage } from "@solid-primitives/storage"
+import type { ElectronAPI } from "../../preload/types"
+
+export function createDesktopStorage(api: ElectronAPI) {
+ const cache = new Map()
+ const storage: NonNullable = (name = "default.dat") => {
+ const cached = cache.get(name)
+ if (cached) return cached
+ const next: AsyncStorage = {
+ getItem: (key) => api.storeGet(name, key),
+ setItem: (key, value) => api.storeSet(name, key, value),
+ removeItem: (key) => api.storeDelete(name, key),
+ clear: () => api.storeClear(name),
+ key: async (index: number) => (await api.storeKeys(name))[index],
+ getLength: () => api.storeLength(name),
+ get length() {
+ return next.getLength()
+ },
+ }
+ cache.set(name, next)
+ return next
+ }
+
+ return {
+ storage,
+ draftStore: createDraftStore({
+ get: api.draftGet,
+ set: api.draftSet,
+ remove: api.draftDelete,
+ putBlob: (blob) => blob.arrayBuffer().then(api.draftBlobPut),
+ getBlob: (id) => api.draftBlobGet(id).then((data) => data && new Blob([data])),
+ }),
+ }
+}
diff --git a/packages/desktop/src/renderer/platform/updater.ts b/packages/desktop/src/renderer/platform/updater.ts
new file mode 100644
index 00000000000..6dfb9f4a870
--- /dev/null
+++ b/packages/desktop/src/renderer/platform/updater.ts
@@ -0,0 +1,13 @@
+import type { UpdaterPlatform, UpdaterState } from "@opencode-ai/app"
+import { createSignal } from "solid-js"
+import type { ElectronAPI } from "../../preload/types"
+
+export function startDesktopUpdater(api: ElectronAPI): UpdaterPlatform {
+ const [state, setState] = createSignal({ status: "disabled" })
+ void api.updater.subscribe(setState)
+ return {
+ state,
+ check: () => api.updater.check(),
+ install: () => api.updater.install(),
+ }
+}
diff --git a/packages/desktop/src/renderer/startup/deep-links.ts b/packages/desktop/src/renderer/startup/deep-links.ts
new file mode 100644
index 00000000000..3d33f356cdd
--- /dev/null
+++ b/packages/desktop/src/renderer/startup/deep-links.ts
@@ -0,0 +1,15 @@
+import type { ElectronAPI } from "../../preload/types"
+
+const deepLinkEvent = "opencode:deep-link"
+
+export function startDeepLinks(api: ElectronAPI) {
+ void api.consumeInitialDeepLinks().then(emitDeepLinks)
+ api.onDeepLink(emitDeepLinks)
+}
+
+function emitDeepLinks(urls: string[]) {
+ if (urls.length === 0) return
+ window.__OPENCODE__ ??= {}
+ window.__OPENCODE__.deepLinks = [...(window.__OPENCODE__.deepLinks ?? []), ...urls]
+ window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
+}
diff --git a/packages/desktop/src/renderer/initialization.test.ts b/packages/desktop/src/renderer/startup/initialization.test.ts
similarity index 98%
rename from packages/desktop/src/renderer/initialization.test.ts
rename to packages/desktop/src/renderer/startup/initialization.test.ts
index c008799db1d..fc4bd197e1d 100644
--- a/packages/desktop/src/renderer/initialization.test.ts
+++ b/packages/desktop/src/renderer/startup/initialization.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { Ipc } from "../shared/ipc-contract"
+import { Ipc } from "../../shared/ipc-contract"
import { initializationData, initializationReady } from "./initialization"
describe("desktop renderer initialization", () => {
diff --git a/packages/desktop/src/renderer/initialization.ts b/packages/desktop/src/renderer/startup/initialization.ts
similarity index 95%
rename from packages/desktop/src/renderer/initialization.ts
rename to packages/desktop/src/renderer/startup/initialization.ts
index f2035226306..16a0af1255e 100644
--- a/packages/desktop/src/renderer/initialization.ts
+++ b/packages/desktop/src/renderer/startup/initialization.ts
@@ -20,4 +20,4 @@ export function initializationReady(state: (() => A | undefined) & { error: u
initializationData(state)
return true
}
-import { Ipc } from "../shared/ipc-contract"
+import { Ipc } from "../../shared/ipc-contract"
diff --git a/packages/desktop/src/renderer/startup/locale-value.ts b/packages/desktop/src/renderer/startup/locale-value.ts
new file mode 100644
index 00000000000..08c6008a2d0
--- /dev/null
+++ b/packages/desktop/src/renderer/startup/locale-value.ts
@@ -0,0 +1,3 @@
+export function storedLocaleValue(raw: string | null | undefined) {
+ return raw?.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
+}
diff --git a/packages/desktop/src/renderer/startup/locale.test.ts b/packages/desktop/src/renderer/startup/locale.test.ts
new file mode 100644
index 00000000000..d22fb53cfb6
--- /dev/null
+++ b/packages/desktop/src/renderer/startup/locale.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, test } from "bun:test"
+import { storedLocaleValue } from "./locale-value"
+
+describe("desktop stored locale", () => {
+ test("extracts the current locale field", () => {
+ expect(storedLocaleValue('{"locale":"fr"}')).toBe("fr")
+ expect(storedLocaleValue('{"other":true,"locale" : "zht"}')).toBe("zht")
+ })
+
+ test("ignores missing locale data", () => {
+ expect(storedLocaleValue(null)).toBeUndefined()
+ expect(storedLocaleValue("{}")).toBeUndefined()
+ })
+})
diff --git a/packages/desktop/src/renderer/startup/locale.ts b/packages/desktop/src/renderer/startup/locale.ts
new file mode 100644
index 00000000000..1e9b355459e
--- /dev/null
+++ b/packages/desktop/src/renderer/startup/locale.ts
@@ -0,0 +1,16 @@
+import { loadLocaleDict, normalizeLocale, type Locale, type Platform } from "@opencode-ai/app"
+import { storedLocaleValue } from "./locale-value"
+
+export async function preloadStoredLocale(platform: Platform) {
+ const raw = await platform.storage?.("opencode.global.dat").getItem("language")
+ const locale = storedLocale(raw)
+ if (!locale) return
+ if (locale !== "en") await loadLocaleDict(locale)
+ return locale
+}
+
+export function storedLocale(raw: string | null | undefined): Locale | undefined {
+ const locale = storedLocaleValue(raw)
+ if (!locale) return
+ return normalizeLocale(locale)
+}
diff --git a/packages/desktop/src/renderer/startup/root.ts b/packages/desktop/src/renderer/startup/root.ts
new file mode 100644
index 00000000000..02d7fef7acc
--- /dev/null
+++ b/packages/desktop/src/renderer/startup/root.ts
@@ -0,0 +1,8 @@
+import { t } from "../i18n"
+
+export function requireRendererRoot() {
+ const root = document.getElementById("root")
+ if (root instanceof HTMLElement) return root
+ if (import.meta.env.DEV) throw new Error(t("desktop.error.dev.rootNotFound"))
+ return root!
+}
diff --git a/packages/desktop/src/renderer/startup/sentry.ts b/packages/desktop/src/renderer/startup/sentry.ts
new file mode 100644
index 00000000000..c4e2d5700f7
--- /dev/null
+++ b/packages/desktop/src/renderer/startup/sentry.ts
@@ -0,0 +1,29 @@
+import * as Sentry from "@sentry/solid"
+import pkg from "../../../package.json"
+
+export function desktopVersion() {
+ return import.meta.env.OPENCODE_VERSION ?? pkg.version
+}
+
+export function initializeSentry(version: string) {
+ if (!import.meta.env.VITE_SENTRY_DSN) return
+ Sentry.init({
+ dsn: import.meta.env.VITE_SENTRY_DSN,
+ environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
+ release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${version}`,
+ initialScope: {
+ tags: {
+ platform: "desktop",
+ },
+ },
+ integrations: (integrations) =>
+ integrations.filter(
+ (integration) =>
+ integration.name !== "Breadcrumbs" &&
+ !(
+ import.meta.env.OPENCODE_CHANNEL === "prod" &&
+ (integration.name === "GlobalHandlers" || integration.name === "BrowserApiErrors")
+ ),
+ ),
+ })
+}
diff --git a/packages/desktop/src/renderer/startup/splash.tsx b/packages/desktop/src/renderer/startup/splash.tsx
new file mode 100644
index 00000000000..73850f227f8
--- /dev/null
+++ b/packages/desktop/src/renderer/startup/splash.tsx
@@ -0,0 +1,9 @@
+import { Splash } from "@opencode-ai/ui/logo"
+
+export function LoadingSplash() {
+ return (
+
+
+
+ )
+}
diff --git a/packages/desktop/src/renderer/window-fullscreen.ts b/packages/desktop/src/renderer/window/fullscreen.ts
similarity index 100%
rename from packages/desktop/src/renderer/window-fullscreen.ts
rename to packages/desktop/src/renderer/window/fullscreen.ts
diff --git a/packages/desktop/src/renderer/window/route-storage.test.ts b/packages/desktop/src/renderer/window/route-storage.test.ts
new file mode 100644
index 00000000000..29d08c54973
--- /dev/null
+++ b/packages/desktop/src/renderer/window/route-storage.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, test } from "bun:test"
+import { acceptedLastActiveUrl } from "./route-storage"
+
+describe("desktop last active route", () => {
+ test("restores current desktop routes", () => {
+ expect(acceptedLastActiveUrl("/")).toBe("/")
+ expect(acceptedLastActiveUrl("/new-session?directory=C%3A%5Cwork#draft")).toBe(
+ "/new-session?directory=C%3A%5Cwork#draft",
+ )
+ expect(acceptedLastActiveUrl("/server/wsl%3ADebian/session/abc?view=review#file")).toBe(
+ "/server/wsl%3ADebian/session/abc?view=review#file",
+ )
+ })
+
+ test("falls back for invalid saved routes", () => {
+ expect(acceptedLastActiveUrl(undefined)).toBe("/")
+ expect(acceptedLastActiveUrl("/settings")).toBe("/")
+ expect(acceptedLastActiveUrl("/new-session/extra")).toBe("/")
+ expect(acceptedLastActiveUrl("/server/sidecar/session/abc/extra")).toBe("/")
+ })
+})
diff --git a/packages/desktop/src/renderer/window/route-storage.ts b/packages/desktop/src/renderer/window/route-storage.ts
new file mode 100644
index 00000000000..63595c8576e
--- /dev/null
+++ b/packages/desktop/src/renderer/window/route-storage.ts
@@ -0,0 +1,27 @@
+export function getLastActiveUrl(windowID: string) {
+ if (typeof localStorage !== "object") return "/"
+ try {
+ return acceptedLastActiveUrl(localStorage.getItem(windowLastActiveUrlKey(windowID)))
+ } catch {
+ return "/"
+ }
+}
+
+export function setLastActiveUrl(windowID: string, value: string) {
+ if (typeof localStorage !== "object") return
+ try {
+ localStorage.setItem(windowLastActiveUrlKey(windowID), value)
+ } catch {}
+}
+
+export function acceptedLastActiveUrl(value: string | null | undefined) {
+ if (value === "/") return value
+ const path = value?.split(/[?#]/, 1)[0]
+ if (path === "/new-session") return value ?? "/"
+ if (/^\/server\/[^/]+\/session\/[^/]+$/.test(path ?? "")) return value ?? "/"
+ return "/"
+}
+
+function windowLastActiveUrlKey(windowID: string) {
+ return `opencode.desktop.window.${windowID}.last-active-url`
+}
diff --git a/packages/desktop/src/renderer/window/router.tsx b/packages/desktop/src/renderer/window/router.tsx
new file mode 100644
index 00000000000..62d8ebdc95a
--- /dev/null
+++ b/packages/desktop/src/renderer/window/router.tsx
@@ -0,0 +1,11 @@
+import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router"
+import { onCleanup } from "solid-js"
+import { getLastActiveUrl, setLastActiveUrl } from "./route-storage"
+
+export function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) {
+ const history = createMemoryHistory()
+ const initialUrl = getLastActiveUrl(props.windowID)
+ if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false })
+ onCleanup(history.listen((value) => setLastActiveUrl(props.windowID, value)))
+ return
+}
diff --git a/packages/desktop/src/renderer/webview-zoom.ts b/packages/desktop/src/renderer/window/zoom.ts
similarity index 100%
rename from packages/desktop/src/renderer/webview-zoom.ts
rename to packages/desktop/src/renderer/window/zoom.ts