mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
refactor(desktop): extract renderer platform (#43164)
This commit is contained in:
committed by
GitHub
Unverified
parent
b6a8d99da5
commit
93cdfafa78
@@ -0,0 +1,121 @@
|
||||
/// <reference path="./env.d.ts" />
|
||||
|
||||
// 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 (
|
||||
<Show when={windowState.latest} fallback={<LoadingSplash />} keyed>
|
||||
{(state) => <DesktopWindow api={props.api} updater={props.updater} windowState={state} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
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) => (
|
||||
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
|
||||
)
|
||||
|
||||
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 (
|
||||
<Show when={ready()} fallback={<LoadingSplash />}>
|
||||
<Show when={effectiveDefaultServer()} keyed>
|
||||
{(key) => (
|
||||
<AppInterface defaultServer={key} servers={servers()} router={router}>
|
||||
<DesktopFirstLaunchOnboarding
|
||||
api={props.api}
|
||||
initialUrl={getLastActiveUrl(props.windowState.id)}
|
||||
serverKey={key}
|
||||
/>
|
||||
<DesktopEffects api={props.api} />
|
||||
<Show when={initializationData(sidecar)} keyed>
|
||||
{(server) => <MigrationStatus server={server} />}
|
||||
</Show>
|
||||
</AppInterface>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders
|
||||
locale={locale.latest}
|
||||
onNativeTranslations={(bundle) => void props.api.setNativeTranslations(bundle).catch(() => undefined)}
|
||||
>
|
||||
<Show when={true}>{(_) => <ReadyApp />}</Show>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<UpdaterState>({ 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 <MemoryRouter {...props} history={history} />
|
||||
}
|
||||
|
||||
const createPlatform = (windowState: DesktopWindowState): Platform => {
|
||||
const attachmentPaths = new WeakMap<File, string>()
|
||||
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<string, AsyncStorage>()
|
||||
|
||||
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 (
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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) => (
|
||||
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
|
||||
)
|
||||
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 (
|
||||
<Show when={ready()} fallback={<LoadingSplash />}>
|
||||
<Show when={effectiveDefaultServer()} keyed>
|
||||
{(key) => (
|
||||
<AppInterface defaultServer={key} servers={servers()} router={router}>
|
||||
<DesktopFirstLaunchOnboarding initialUrl={getLastActiveUrl(props.windowState.id)} serverKey={key} />
|
||||
<DesktopEffects />
|
||||
<Show when={initializationData(sidecar)} keyed>
|
||||
{(server) => <MigrationStatus server={server} />}
|
||||
</Show>
|
||||
</AppInterface>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders
|
||||
locale={locale.latest}
|
||||
onNativeTranslations={(bundle) => void window.api.setNativeTranslations(bundle).catch(() => undefined)}
|
||||
>
|
||||
<Show when={true}>{(_) => <App />}</Show>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}
|
||||
|
||||
render(() => {
|
||||
const [windowState] = createResource(() => window.api.getWindowID().then((id) => ({ id })))
|
||||
|
||||
return (
|
||||
<Show when={windowState.latest} fallback={<LoadingSplash />} keyed>
|
||||
{(state) => <DesktopRoot windowState={state} />}
|
||||
</Show>
|
||||
)
|
||||
}, root!)
|
||||
render(() => <DesktopApp api={window.api} updater={updater} version={version} />, root)
|
||||
|
||||
+8
-3
@@ -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 })
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Platform } from "@opencode-ai/app"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
|
||||
type DesktopOS = Extract<Platform, { platform: "desktop" }>["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<File, string>()
|
||||
const openDirectoryPickerDialog: Extract<Platform, { platform: "desktop" }>["openDirectoryPickerDialog"] = async (
|
||||
options,
|
||||
) => {
|
||||
return api.openDirectoryPicker({
|
||||
multiple: options?.multiple ?? false,
|
||||
title: options?.title,
|
||||
})
|
||||
}
|
||||
const openAttachmentPickerDialog: NonNullable<Platform["openAttachmentPickerDialog"]> = 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",
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Platform["runDesktopMenuAction"]> {
|
||||
return (action) => {
|
||||
switch (action) {
|
||||
case "view.resetZoom":
|
||||
resetZoom()
|
||||
return
|
||||
case "view.zoomIn":
|
||||
zoomIn()
|
||||
return
|
||||
case "view.zoomOut":
|
||||
zoomOut()
|
||||
return
|
||||
}
|
||||
return api.runDesktopMenuAction(action)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, AsyncStorage>()
|
||||
const storage: NonNullable<Platform["storage"]> = (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])),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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<UpdaterState>({ status: "disabled" })
|
||||
void api.updater.subscribe(setState)
|
||||
return {
|
||||
state,
|
||||
check: () => api.updater.check(),
|
||||
install: () => api.updater.install(),
|
||||
}
|
||||
}
|
||||
@@ -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 } }))
|
||||
}
|
||||
+1
-1
@@ -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", () => {
|
||||
+1
-1
@@ -20,4 +20,4 @@ export function initializationReady<A>(state: (() => A | undefined) & { error: u
|
||||
initializationData(state)
|
||||
return true
|
||||
}
|
||||
import { Ipc } from "../shared/ipc-contract"
|
||||
import { Ipc } from "../../shared/ipc-contract"
|
||||
@@ -0,0 +1,3 @@
|
||||
export function storedLocaleValue(raw: string | null | undefined) {
|
||||
return raw?.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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!
|
||||
}
|
||||
@@ -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")
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
|
||||
export function LoadingSplash() {
|
||||
return (
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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("/")
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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 <MemoryRouter {...props} history={history} />
|
||||
}
|
||||
Reference in New Issue
Block a user