diff --git a/packages/app/src/desktop.ts b/packages/app/src/desktop.ts index 406b721701a..180e075bc19 100644 --- a/packages/app/src/desktop.ts +++ b/packages/app/src/desktop.ts @@ -1,6 +1,7 @@ export { AppBaseProviders, AppInterface, preloadRoute } from "./app" export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker" export { useCommand } from "./shell/commands/command" +export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout" export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language" export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform" export { ServerConnection, useServers } from "./runtime/server/registry" diff --git a/packages/app/src/shell/tabs/tabs.tsx b/packages/app/src/shell/tabs/tabs.tsx index 7767ce17efd..39bf5372c54 100644 --- a/packages/app/src/shell/tabs/tabs.tsx +++ b/packages/app/src/shell/tabs/tabs.tsx @@ -65,7 +65,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ createStore([]), ) const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore({})) - const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore>({})) + const [info, setInfo, , infoReady] = persisted( + Persist.window("tabs.info"), + createStore>({}), + ) const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore([])) const params = useParams() @@ -378,6 +381,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, } - return { ...actions, store, info, ready, recentReady } + return { ...actions, store, info, ready, infoReady, recentReady } }, }) diff --git a/packages/desktop/src/main/ipc-handlers/window.ts b/packages/desktop/src/main/ipc-handlers/window.ts index 4cb48630c15..6508b9fc977 100644 --- a/packages/desktop/src/main/ipc-handlers/window.ts +++ b/packages/desktop/src/main/ipc-handlers/window.ts @@ -2,28 +2,13 @@ import { BrowserWindow } from "electron" import { Effect } from "effect" import { WindowRpcs } from "../../shared/ipc-rpc" import { IpcPortHandoff } from "../ipc-transport" -import { - getPinchZoomEnabled, - getWindowID, - setPinchZoomEnabled, - setTitlebar, - setWindowThemeReady, - updateTitlebar, -} from "../windows" +import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, setWindowThemeReady, updateTitlebar } from "../windows" import { sender } from "./context" export const windowHandlers = WindowRpcs.toLayer( Effect.gen(function* () { const handoff = yield* IpcPortHandoff return WindowRpcs.of({ - WindowGetId: (_args, context) => - Effect.sync(() => { - const win = BrowserWindow.fromWebContents(sender(handoff, context)) - if (!win) throw new Error("Window not found") - const id = getWindowID(win) - if (!id) throw new Error("Window ID not found") - return id - }), WindowThemeReady: (_args, context) => Effect.sync(() => { const win = BrowserWindow.fromWebContents(sender(handoff, context)) diff --git a/packages/desktop/src/main/windows/index.ts b/packages/desktop/src/main/windows/index.ts index bbff6eaa269..d5d20f211c6 100644 --- a/packages/desktop/src/main/windows/index.ts +++ b/packages/desktop/src/main/windows/index.ts @@ -7,6 +7,7 @@ import { scoped } from "../native/logging" import { DesktopPaths } from "../paths" import { forgetStore, getStore } from "../storage/store" import { WINDOW_IDS_KEY } from "../storage/keys" +import { windowIDArgument } from "../../shared/window-bootstrap" import { getBackgroundColor, getPinchZoomEnabled, @@ -24,7 +25,6 @@ import { createWindowRegistry } from "./registry" import { makeWindowRecovery } from "./recovery" import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security" -const windowIDs = new WeakMap() const themeReady = new WeakMap void>() const registry = createWindowRegistry({ read: () => getStore().get(WINDOW_IDS_KEY), @@ -59,10 +59,6 @@ export function setAppQuitting(quitting = true) { registry.setQuitting(quitting) } -export function getWindowID(win: BrowserWindow) { - return windowIDs.get(win) -} - export function getLastFocusedWindow() { const focused = BrowserWindow.getFocusedWindow() if (focused) return focused @@ -89,6 +85,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () { const create = (id: string = randomUUID()) => { const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 }) + const appearance = windowAppearance(path, paths) const win = new BrowserWindow({ x: state.x, y: state.y, @@ -96,7 +93,11 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () { height: state.height, show: false, autoHideMenuBar: true, - ...windowAppearance(path, paths), + ...appearance, + webPreferences: { + ...appearance.webPreferences, + additionalArguments: [windowIDArgument(id)], + }, }) allowRendererPermissions(win) @@ -132,7 +133,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () { } const register = (win: BrowserWindow, id: string) => { - windowIDs.set(win, id) registry.register(id, win) win.on("focus", () => registry.focused(id)) // Windows emits session-end, but not before-quit, during shutdown and logoff. diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index ecf427caf9b..fec4009668e 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer, webUtils } from "electron" import { IpcTransportPort } from "../shared/ipc-transport" +import { windowIDFromArguments } from "../shared/window-bootstrap" ipcRenderer.on(IpcTransportPort, (event) => { const port = event.ports[0] @@ -7,5 +8,6 @@ ipcRenderer.on(IpcTransportPort, (event) => { }) contextBridge.exposeInMainWorld("electron", { + windowID: windowIDFromArguments(process.argv), getPathForFile: (file: File) => webUtils.getPathForFile(file), }) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 9fb67648c39..500c564de36 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -1,3 +1,4 @@ export type ElectronNative = { + windowID: string getPathForFile(file: File): string } diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts index 53c0f21c25f..876ff95267c 100644 --- a/packages/desktop/src/renderer/api-types.ts +++ b/packages/desktop/src/renderer/api-types.ts @@ -42,7 +42,7 @@ export type ElectronAPI = { draftDelete(key: string): Promise draftBlobPut(data: ArrayBuffer): Promise draftBlobGet(id: string): Promise - getWindowID(): Promise + getWindowID(): string themeReady(): Promise onMenuCommand(cb: (id: string) => void): () => void onDeepLink(cb: (urls: string[]) => void): () => void diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts index be52c29dc80..96173a5ea26 100644 --- a/packages/desktop/src/renderer/api.ts +++ b/packages/desktop/src/renderer/api.ts @@ -86,7 +86,7 @@ export const api: ElectronAPI = { draftBlobPut: (data) => invoke("DraftsPutBlob", { data: new Uint8Array(data) }), draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)), - getWindowID: () => invoke("WindowGetId"), + getWindowID: () => window.electron.windowID, themeReady: () => invoke("WindowThemeReady"), onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)), onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))), diff --git a/packages/desktop/src/renderer/desktop-app.tsx b/packages/desktop/src/renderer/desktop-app.tsx index ff7b0cd4e7c..339d748cfd8 100644 --- a/packages/desktop/src/renderer/desktop-app.tsx +++ b/packages/desktop/src/renderer/desktop-app.tsx @@ -4,16 +4,22 @@ import { AppBaseProviders, AppInterface, + currentRoute, PlatformProvider, + preloadRoute, ServerConnection, useCommand, + useCurrentRoute, useLanguage, + useTabs, useWslServers, + type LayoutRoute, type UpdaterPlatform, } from "@opencode-ai/app/desktop" import { useTheme } from "@opencode-ai/ui/theme/context" import type { BaseRouterProps } from "@solidjs/router" import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js" +import { createStore } from "solid-js/store" import type { ElectronAPI } from "./api-types" import { DesktopFirstLaunchOnboarding } from "./onboarding" import { createDesktopPlatform, type DesktopWindowState } from "./platform" @@ -28,19 +34,51 @@ import { availableStartupServer, readyWslConnections } from "./wsl/connections" const MigrationStatus = lazy(() => import("./migration-status").then((module) => ({ default: module.MigrationStatus }))) export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; version: string }) { - const [windowState] = createResource(() => props.api.getWindowID().then((id) => ({ id, version: props.version }))) + const windowState = { id: props.api.getWindowID(), version: props.version } + const url = new URL(getLastActiveUrl(windowState.id), "http://localhost") + const route = currentRoute(url.pathname, url.search) + const [startup, setStartup] = createStore<{ ready: boolean; visible: boolean; route: LayoutRoute }>({ + ready: false, + visible: true, + route, + }) return ( - } keyed> - {(state) => } - + <> + setStartup("ready", true)} + onRoute={(route) => setStartup("route", route)} + /> + +
{ + if (event.target !== event.currentTarget || !startup.ready) return + setStartup("visible", false) + }} + > + +
+
+ ) } -function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; windowState: DesktopWindowState }) { +function DesktopWindow(props: { + api: ElectronAPI + updater: UpdaterPlatform + windowState: DesktopWindowState + onReady: () => void + onRoute: (route: LayoutRoute) => void +}) { 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 [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id))) const router = (routerProps: BaseRouterProps) => ( ) @@ -74,10 +112,15 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind ) return ( - }> + {(key) => ( + !initialRoute.loading} + onReady={props.onReady} + onRoute={props.onRoute} + /> boolean + onReady: () => void + onRoute: (route: LayoutRoute) => void +}) { + const tabs = useTabs() + const route = useCurrentRoute() + createEffect(() => props.onRoute(route())) + createEffect(() => { + if (!props.routeReady() || !tabs.ready() || !tabs.infoReady()) return + props.onReady() + }) + return null +} + function DesktopEffects(props: { api: ElectronAPI }) { const command = useCommand() bindDesktopMenu((id) => command.trigger(id)) diff --git a/packages/desktop/src/renderer/startup/splash.tsx b/packages/desktop/src/renderer/startup/splash.tsx index 73850f227f8..26dd78c0f4b 100644 --- a/packages/desktop/src/renderer/startup/splash.tsx +++ b/packages/desktop/src/renderer/startup/splash.tsx @@ -1,8 +1,14 @@ import { Splash } from "@opencode-ai/ui/logo" -export function LoadingSplash() { +export function LoadingSplash(props: { deep: boolean }) { return ( -
+
) diff --git a/packages/desktop/src/shared/ipc-rpc/window.ts b/packages/desktop/src/shared/ipc-rpc/window.ts index 7971d0b62d6..bb659b61af7 100644 --- a/packages/desktop/src/shared/ipc-rpc/window.ts +++ b/packages/desktop/src/shared/ipc-rpc/window.ts @@ -1,7 +1,6 @@ import { Schema } from "effect" import { Rpc, RpcGroup } from "effect/unstable/rpc" -export const WindowGetId = Rpc.make("WindowGetId", { success: Schema.String }) export const WindowThemeReady = Rpc.make("WindowThemeReady") export const WindowGetFocused = Rpc.make("WindowGetFocused", { success: Schema.Boolean }) export const WindowGetFullscreen = Rpc.make("WindowGetFullscreen", { success: Schema.Boolean }) @@ -26,7 +25,6 @@ export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", { }, }) export const WindowRpcs = RpcGroup.make( - WindowGetId, WindowThemeReady, WindowGetFocused, WindowGetFullscreen, diff --git a/packages/desktop/src/shared/window-bootstrap.test.ts b/packages/desktop/src/shared/window-bootstrap.test.ts new file mode 100644 index 00000000000..bfea76422aa --- /dev/null +++ b/packages/desktop/src/shared/window-bootstrap.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { windowIDArgument, windowIDFromArguments } from "./window-bootstrap" + +describe("window bootstrap", () => { + test("round-trips the window ID through renderer arguments", () => { + const id = "window/id with spaces" + expect(windowIDFromArguments(["electron", windowIDArgument(id)])).toBe(id) + }) + + test("requires a window ID argument", () => { + expect(() => windowIDFromArguments(["electron"])).toThrow("Window ID argument not found") + }) +}) diff --git a/packages/desktop/src/shared/window-bootstrap.ts b/packages/desktop/src/shared/window-bootstrap.ts new file mode 100644 index 00000000000..79c4c47b6b4 --- /dev/null +++ b/packages/desktop/src/shared/window-bootstrap.ts @@ -0,0 +1,11 @@ +const windowIDPrefix = "--opencode-window-id=" + +export function windowIDArgument(id: string) { + return windowIDPrefix + encodeURIComponent(id) +} + +export function windowIDFromArguments(args: readonly string[]) { + const value = args.find((arg) => arg.startsWith(windowIDPrefix))?.slice(windowIDPrefix.length) + if (!value) throw new Error("Window ID argument not found") + return decodeURIComponent(value) +}