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