diff --git a/CHANGELOG.md b/CHANGELOG.md index d21973803e..409b0cb660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -293,6 +293,21 @@ already in use` and no way forward short of finding the file in a temporary visible way out; Escape still works everywhere it did. The one exception is the provider setup screen the app shows when no provider is configured, which has never been dismissable. +- **You can get a new API key without reinstalling** — the app fetched an API + key once, when you first signed in, and had no way to ever fetch another. Keys + expire on a schedule CoDev does not set, and when yours did, every message + failed while the app still considered itself signed in — the sign-in screen + only ever appeared when no provider was connected at all, and an expired key + still counts as connected. Two things change. The app now checks the key when + it starts: if it has been rejected, it quietly gets a new one using your + existing session, and you never see it happen. Only when that is not possible + — no key at all, or a session too old to renew — does it ask you to sign in. + And there is now a **Get a new API Key...** item to do it yourself at any + time: in the **CoDev** menu on macOS, under **File** on Windows and Linux, + both next to Settings, and in the command palette on every platform. A + gateway that cannot be reached is never treated as a bad key, so a flaky + network will not sign you out. + ([#296](https://github.com/quickbeard/codev-code/pull/296)) - **The Work tabs stay out of the way until you use sandboxes** — every desktop install showed a **Code** / **Work** switch above the projects list, even though Work was empty for anyone who had never made a sandbox: switching to it diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 8bcc132c54..2f81d62080 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -288,6 +288,8 @@ from the squash message first, or use a plain merge. | Dialogs close on a button, never on an outside click: `packages/ui/src/components/dialog.tsx`, `packages/ui/src/components/dialog.css`, `packages/ui/src/v2/components/dialog-v2.tsx`, `packages/ui/src/v2/components/dialog-v2.css`, `packages/ui/src/components/image-preview.tsx`, plus the `*.stories.tsx` beside the two dialogs | All three `Kobalte.Content`s take `onInteractOutside={(e) => e.preventDefault()}`, which is what actually blocks Kobalte's dismissable layer (the context row above only removes the explicit overlay click). Both `Dialog`s gained `closeLabel?`/`hideClose?` and now render the `Kobalte.CloseButton` themselves, as the last child of their content, absolutely positioned at the content's top-right — v1 at 16px/20px (12px/12px when `data-no-header`), v2 at 14px/14px, both at `z-index: 30` so they clear the settings tab header's sticky `z-index: 10`. The button was removed from v2's `DialogHeader` (which loses its `hideClose`/`closeLabel` props and its `data-hide-close` attribute) and from v1's header `Switch`, whose `action` branch used to *replace* it; `action` now simply renders. Headers reserve the corner in `padding-right` (v1 52px, v2 42px) and v1's title takes `flex: 1`; both containers take a `min-height` (v1 52px, v2 48px) so a short `fit` dialog cannot clip the button. `aria-label` defaults to `ui.common.close` through `useI18n` — the first v2 component to use the UI i18n context, which has an English fallback. | Every dialog needed a visible way out once the backdrop stopped being one, including the dozen whose call sites passed `hideClose`. Pinning the button to the content corner rather than laying it out in the header is what lets header-less dialogs (settings, release notes, the command palettes) have one at all; keeping it inside `Kobalte.Content` keeps it inside the focus trap and the modal's pointer-events layer. | Keep the corner button and the `onInteractOutside` guards. Upstream owns the header/body/footer layout — take its changes and re-apply the `padding-right` reserve and the close button on top. If upstream adds its own close button to `DialogHeader`, drop ours from `Dialog` rather than shipping two. | | Dialog close buttons at the call sites: `packages/app/src/sandbox/{dialog-add,dialog-add-default,dialog-delete,dialog-mounts}.tsx`, `packages/app/src/wsl/{dialog-add-server,dialog-delete}.tsx`, `packages/app/src/components/{dialog-manage-models,dialog-connect-provider,dialog-select-model-unpaid-v2,session-actions-menu,settings-v2/dialog-server-v2}.tsx`, `packages/app/src/components/gateway/dialog-configure.tsx`, `packages/app/src/components/dialog-select-file.tsx`, `packages/app/src/components/dialog-command-palette-v2.css` | Dropped every `hideClose`/`closeLabel` prop from `DialogHeader` (the button moved to `Dialog`, and hiding it is no longer wanted anywhere it was hidden). `dialog-configure` passes `hideClose` on its `Dialog` instead — it is the configure gate, which the dialog stack also marks `persistent`, so a close button there would be inert. The two search-first dialogs reserve the corner the button now occupies: `dialog-select-file` via `[&_[data-slot=list-search-wrapper]]:pr-10`, the v2 command palette via `padding-right` on `.command-palette-v2-search`. | Fork-only sandbox/WSL/gateway dialogs aside, these are upstream files whose only change is a removed prop. | Take upstream's version of these call sites and re-drop `hideClose`. The `hideClose` on `dialog-configure` is fork-only and must stay. | +| Gateway key recovery: `packages/app/src/desktop-menu.ts`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | A "Get a new API Key..." item dispatching the fork-only `gateway.apiKey.new` command — in the macOS app menu under Settings, and in the Windows/Linux **File** menu (`platforms: ["windows"]`) under its Settings, the same split Settings itself uses since those builds render no app menu. i18n gains `command.gateway.apiKey.new`. Preload gains the `gateway-sso-ensure-key` channel on the existing `gatewaySso` namespace. The logic is fork-only: `packages/desktop/src/main/gateway-sso/controller.ts` gains `ensureKey()`, the desktop twin of codev-hub's `ensureFreshGatewayKey` — validate the stored key against the gateway's `/key/info` (`@codev/gateway`'s already-exported `validateApiKey`, previously with no production caller), bounded by a 2.5s race so a slow gateway cannot delay startup; a definite 401/403 tries `silentSso` + `fetchApiKey` and re-saves the record with every other field intact, returning the credential for the renderer to merge into the local server's auth registry (only the renderer writes to the server); anything else is left alone. `packages/app/src/components/gateway/gateway-configure-gate.tsx` (fork-only) registers the command behind its existing driver election and runs the pre-flight once per window after sync settles, opening `DialogConfigure` only on `needs-login`. `DialogConfigure` gains a `dismissable` prop driving both `setPersistent` and `hideClose`: the gate's copy stays trapped (the provider-setup screen the dialog rework deliberately exempts), while the two new entry points — the menu command and the pre-flight — are escapable, since a provider is configured in both cases and the dialog otherwise has no close button, no backdrop dismissal and no Escape. | Gateway keys expire on a schedule CoDev does not control, and nothing in the desktop app noticed: `DialogConfigure` had exactly one caller, the gate, which fires only when no provider is connected — and a dead key is still a connected provider, so every message 401'd with the sign-in flow unreachable. The silent re-mint mirrors what the hub already does for CLI launches, so the common case costs the user nothing. | Keep the menu item on both platforms (dropping the `platforms: ["windows"]` twin loses it entirely on Windows/Linux, which render no app menu) and keep the pre-flight's "only a definite rejection acts" rule — treating an unreachable gateway as a dead key would lock people out on a flaky network. Take upstream's surrounding menu/i18n/preload changes. | + ## Known rebrand gaps - **UI branding**: TUI banner, default-theme accent, desktop/web app names, favicon/PWA diff --git a/packages/app/src/components/gateway/dialog-configure.tsx b/packages/app/src/components/gateway/dialog-configure.tsx index ee89ffc180..a5ee62fa66 100644 --- a/packages/app/src/components/gateway/dialog-configure.tsx +++ b/packages/app/src/components/gateway/dialog-configure.tsx @@ -27,7 +27,13 @@ type Step = // configure gate keeps open while no provider is configured. Unlike the TUI // (whose dialog system swaps element factories per step), the whole flow lives // in this one component as internal steps, so persistence is armed once. -export function DialogConfigure() { +// +// `dismissable` is for the entry points the user chose to open — the "Get a new +// API Key" command. The gate's copy must stay trapped (there is no configured +// provider to go back to), but a dialog opened on purpose needs a way out, and +// since this one hides its close button and a backdrop click no longer closes +// anything, leaving it persistent would strand whoever changed their mind. +export function DialogConfigure(props: { dismissable?: boolean } = {}) { const dialog = useDialog() const language = useLanguage() const platform = usePlatform() @@ -43,7 +49,7 @@ export function DialogConfigure() { const [copied, setCopied] = createSignal(false) onMount(() => { - dialog.setPersistent(true) + dialog.setPersistent(!props.dismissable) void sso.getState().then(setSsoState) onCleanup(sso.subscribe(setSsoState)) }) @@ -173,7 +179,7 @@ export function DialogConfigure() { } return ( - +
diff --git a/packages/app/src/components/gateway/gateway-configure-gate.tsx b/packages/app/src/components/gateway/gateway-configure-gate.tsx index c40f79cca0..7e30d2fda1 100644 --- a/packages/app/src/components/gateway/gateway-configure-gate.tsx +++ b/packages/app/src/components/gateway/gateway-configure-gate.tsx @@ -1,7 +1,10 @@ import { createEffect, createSignal, onCleanup } from "solid-js" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { shouldOpenConfigureGate } from "@/gateway/configure-gate" import { DialogConfigure } from "./dialog-configure" @@ -23,11 +26,16 @@ export function GatewayConfigureGate() { const server = useServer() const serverSync = useServerSync() + const serverSDK = useServerSDK() const dialog = useDialog() + const command = useCommand() + const language = useLanguage() // Plain flag, deliberately non-reactive: both effects are (re)triggered by // their reactive reads; this only records whether the active dialog is ours. let gateDialogOpen = false + // One-shot guard for the key pre-flight below. + let checked = false // Stateless invariant (twin of the TUI gate in packages/tui/src/app.tsx): // whenever the dialog stack empties while unconfigured, reopen the gate — @@ -60,5 +68,75 @@ export function GatewayConfigureGate() { dialog.close() }) + // The only manual way into the sign-in flow: the gate below opens + // DialogConfigure on its own only while no provider is connected, and a key + // the gateway has rejected still counts as connected. Registered as a command + // so the desktop menus (desktop-menu.ts) can dispatch it and the command + // palette lists it on every platform. + // + // Options are empty for a non-driver instance: both shells can mount this gate + // on /:dir routes, and a second registration of the same id is a duplicate the + // command registry warns about. + // Dismissable, unlike the gate's copy below: this one was opened on purpose, + // so changing your mind has to be possible. + const openConfigure = () => void dialog.show(() => ) + command.register("gateway-api-key", () => + instances()[0] !== self + ? [] + : [ + { + id: "gateway.apiKey.new", + title: language.t("command.gateway.apiKey.new"), + category: language.t("command.category.settings"), + onSelect: openConfigure, + }, + ], + ) + + // Startup key pre-flight. The gate above only fires when no provider is + // connected, so a stored key that the gateway has since rejected left the app + // configured on paper and 401ing on every message, with no way back to the + // sign-in flow. ensureKey re-mints silently where it can; the dialog is for + // the case it cannot. + // + // Deliberately once per window and after sync settles: it reads the same + // local-server auth registry the refresh writes to, and re-running it on + // every provider change would re-check a key nothing has touched. + createEffect(() => { + if (instances()[0] !== self) return + if (!server.isLocal()) return + if (!serverSync().data.ready) return + if (checked) return + checked = true + void ensureKey() + }) + + async function ensureKey() { + const result = await platform.gatewaySso?.ensureKey().catch(() => undefined) + if (!result || result.status === "ok") return + if (result.status === "refreshed") { + // Only the credential changed — the keyless config block still points at + // the same gateway and model — so merge it into the auth registry and + // reload the server, skipping the config patch the full flow does. + const client = serverSDK().client + await client.auth + .set({ providerID: result.providerID, auth: { type: "api", key: result.apiKey } }) + .then(() => client.global.dispose()) + .catch(() => undefined) + return + } + if (dialog.active !== undefined) return + gateDialogOpen = true + // Dismissable: unlike the gate above, a provider *is* configured here — its + // key is just dead — so this is not the setup screen the app traps you on. + // The menu item is still there for whoever waves it away. + void dialog.show( + () => , + () => { + gateDialogOpen = false + }, + ) + } + return null } diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index 49e50482be..ee60811f95 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -81,6 +81,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [ { type: "item", role: "about" }, { type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" }, { type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, + { type: "item", label: "Get a new API Key...", command: "gateway.apiKey.new" }, { type: "item", label: "Reload Webview", action: "view.reload" }, { type: "item", label: "Restart", action: "app.relaunch" }, { type: "item", label: "Export Logs...", command: "logs.export" }, @@ -110,6 +111,14 @@ export const DESKTOP_MENU: DesktopMenu[] = [ accelerator: { windows: "Ctrl+," }, platforms: ["windows"], }, + // macOS carries this in the app menu beside its own Settings, which the + // Windows/Linux menu does not render — same split Settings itself uses. + { + type: "item", + label: "Get a new API Key...", + command: "gateway.apiKey.new", + platforms: ["windows"], + }, { type: "item", label: "New Window", diff --git a/packages/app/src/gateway/types.ts b/packages/app/src/gateway/types.ts index f214d666e3..c340783b0d 100644 --- a/packages/app/src/gateway/types.ts +++ b/packages/app/src/gateway/types.ts @@ -42,6 +42,16 @@ export type GatewayCompleteResult = { config: Record } +// Outcome of the startup key pre-flight (controller.ensureKey). +export type GatewayKeyCheck = + // Key validated, or the gateway could not be reached to say otherwise. + | { status: "ok" } + // A rejected key was re-minted silently; the renderer merges the credential + // into the local server's auth registry, since only it writes to the server. + | { status: "refreshed"; providerID: string; apiKey: string } + // No key, or one that cannot be replaced without a browser sign-in. + | { status: "needs-login" } + export type GatewaySsoPlatform = { getState(): Promise subscribe(cb: (state: GatewaySsoState) => void): () => void @@ -59,4 +69,7 @@ export type GatewaySsoPlatform = { // Persists credentials to the shared auth file and returns the provider // config fragment for the chosen default model. complete(model: string): Promise + // Validates the stored key, refreshing it silently when it is rejected and + // the SSO session still allows it. Never prompts. + ensureKey(): Promise } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7553a329aa..d33ed9dd0d 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -28,6 +28,7 @@ export const dict = { "command.project.index": "Switch to project {{index}}", "command.provider.connect": "Connect provider", "command.server.switch": "Switch server", + "command.gateway.apiKey.new": "Get a new API key", "command.settings.open": "Open settings", "command.session.previous": "Previous session", "command.session.next": "Next session", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 86856713d7..5631fcba99 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -28,6 +28,7 @@ export const dict = { "command.project.index": "Cambiar al proyecto {{index}}", "command.provider.connect": "Conectar proveedor", "command.server.switch": "Cambiar servidor", + "command.gateway.apiKey.new": "Obtener una nueva clave de API", "command.settings.open": "Abrir ajustes", "command.session.previous": "Sesión anterior", "command.session.next": "Siguiente sesión", diff --git a/packages/desktop/src/main/gateway-sso/controller.test.ts b/packages/desktop/src/main/gateway-sso/controller.test.ts index bac6aa4a79..3c318c35de 100644 --- a/packages/desktop/src/main/gateway-sso/controller.test.ts +++ b/packages/desktop/src/main/gateway-sso/controller.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { AuthData } from "@codev/gateway" +import { GATEWAY_PROVIDER, type AuthData } from "@codev/gateway" import { createGatewaySsoController } from "./controller" const auth: AuthData = { @@ -195,4 +195,95 @@ describe("gateway sso controller", () => { controller.complete("model-a") expect(savedKeys[0]?.baseUrl).toBe("https://gw.example.test") }) + + // The startup pre-flight, the desktop twin of codev-hub's + // ensureFreshGatewayKey. Only a definite rejection acts, and it prompts only + // when a silent re-mint is impossible. + describe("ensureKey", () => { + const creds = { apiKey: "sk-old", model: "m", baseUrl: "https://gw.test" } + + test("leaves a valid key alone", async () => { + const controller = controllerWith({ + loadApiKey: () => creds, + validateApiKey: async () => true, + }) + + expect(await controller.ensureKey()).toEqual({ status: "ok" }) + }) + + // A gateway that cannot be reached is not evidence the key is dead, and + // must never cost someone their session. + test("leaves the key alone when validation cannot answer", async () => { + let refreshed = false + const controller = controllerWith({ + loadApiKey: () => creds, + validateApiKey: async () => { + throw new Error("network down") + }, + silentSso: async () => { + refreshed = true + return auth + }, + }) + + expect(await controller.ensureKey()).toEqual({ status: "ok" }) + expect(refreshed).toBe(false) + }) + + test("re-mints a rejected key silently and hands back the credential", async () => { + let saved: { apiKey: string; model?: string } | undefined + const controller = controllerWith({ + loadApiKey: () => creds, + validateApiKey: async () => false, + silentSso: async () => auth, + fetchApiKey: async () => "sk-new", + saveApiKey: (next) => { + saved = next + }, + }) + + expect(await controller.ensureKey()).toEqual({ + status: "refreshed", + providerID: GATEWAY_PROVIDER.id, + apiKey: "sk-new", + }) + expect(saved?.apiKey).toBe("sk-new") + // The rest of the record survives: model and provider identity are what a + // manually named provider is rewritten from. + expect(saved?.model).toBe("m") + }) + + test("asks for a login when the session cannot re-mint", async () => { + const controller = controllerWith({ + loadApiKey: () => creds, + validateApiKey: async () => false, + silentSso: async () => null, + }) + + expect(await controller.ensureKey()).toEqual({ status: "needs-login" }) + }) + + test("asks for a login when there is no key at all", async () => { + const controller = controllerWith({ loadApiKey: () => null }) + + expect(await controller.ensureKey()).toEqual({ status: "needs-login" }) + }) + + // SSO-minted keys record no base_url, so the saved gateway URL is the only + // thing left to validate against. + test("falls back to the saved gateway url when the key records none", async () => { + let seen = "" + const controller = controllerWith({ + loadApiKey: () => ({ apiKey: "sk-old", model: "m" }), + loadGatewayUrl: () => "https://saved.test", + validateApiKey: async (_key, baseUrl) => { + seen = baseUrl + return true + }, + }) + + expect(await controller.ensureKey()).toEqual({ status: "ok" }) + expect(seen).toBe("https://saved.test") + }) + }) }) diff --git a/packages/desktop/src/main/gateway-sso/controller.ts b/packages/desktop/src/main/gateway-sso/controller.ts index d32691f6b2..e74da6a773 100644 --- a/packages/desktop/src/main/gateway-sso/controller.ts +++ b/packages/desktop/src/main/gateway-sso/controller.ts @@ -4,16 +4,29 @@ import { fetchCodevConfig, fetchModels, GATEWAY_PROVIDER, + loadApiKey, + loadGatewayUrl, login, saveApiKey, saveCodevConfig, silentSso, + validateApiKey, type AuthData, } from "@codev/gateway" -import type { GatewayCompleteResult, GatewaySetupResult, GatewaySsoState } from "@opencode-ai/app/gateway/types" +import type { + GatewayCompleteResult, + GatewayKeyCheck, + GatewaySetupResult, + GatewaySsoState, +} from "@opencode-ai/app/gateway/types" const RETRIES = 3 +// Cap the key check so a slow or hung gateway never delays startup. A real +// /key/info round-trip is far under this; past it we cannot prove the key is +// dead, so we leave it alone (mirrors codev-hub's launch pre-flight). +const KEY_CHECK_TIMEOUT_MS = 2_500 + type Deps = { openUrl: (url: string) => Promise // Electron's net.fetch so enterprise proxies and PAC files apply. @@ -35,6 +48,9 @@ type GatewayFns = { fetchModels: typeof fetchModels saveCodevConfig: typeof saveCodevConfig saveApiKey: typeof saveApiKey + loadApiKey: typeof loadApiKey + loadGatewayUrl: typeof loadGatewayUrl + validateApiKey: typeof validateApiKey } export type GatewaySsoController = ReturnType @@ -48,6 +64,9 @@ export function createGatewaySsoController(deps: Deps) { fetchModels, saveCodevConfig, saveApiKey, + loadApiKey, + loadGatewayUrl, + validateApiKey, ...deps.gateway, } const fetchOpts = { fetchImpl: deps.fetchImpl } @@ -167,6 +186,51 @@ export function createGatewaySsoController(deps: Deps) { setState({ phase: "ready", status: "" }) return { gatewayUrl: config.gatewayUrl, hasApiKey: apiKey.length > 0 } }, + // Startup pre-flight, the desktop twin of codev-hub's ensureFreshGatewayKey. + // Gateway keys have a lifetime CoDev does not control, and nothing here + // noticed a dead one: the provider stays "connected" with a credential that + // 401s on every message, and the configure gate only fires when no provider + // is connected at all — so the sign-in flow was unreachable exactly when it + // was needed. + // + // A rejected key is refreshed silently when the SSO session still allows it, + // which is the common case and costs the user nothing. Only a key that + // cannot be refreshed without a browser round-trip asks for one. + async ensureKey(): Promise { + const creds = gateway.loadApiKey() + if (!creds?.apiKey) return { status: "needs-login" } + // SSO-minted keys record no base_url (see complete()), so fall back to the + // gateway URL saved with the config block. + const baseUrl = creds.baseUrl ?? gateway.loadGatewayUrl() + if (!baseUrl) return { status: "needs-login" } + + // true = valid, false = rejected, null = could not tell. Only a definite + // rejection acts: a gateway that is slow or unreachable must never cost + // someone their session. + const valid = await Promise.race([ + gateway.validateApiKey(creds.apiKey, baseUrl, fetchOpts).catch(() => null), + new Promise((resolve) => setTimeout(resolve, KEY_CHECK_TIMEOUT_MS, null)), + ]) + if (valid !== false) return { status: "ok" } + + const auth = await gateway.silentSso().catch(() => null) + if (!auth) return { status: "needs-login" } + const refreshed = await gateway.fetchApiKey(auth.access_token, fetchOpts).catch(() => "") + if (!refreshed) return { status: "needs-login" } + + // Keep every other field: the provider identity and model are what a + // manually named provider is rewritten from, and dropping them would + // silently re-label it. + gateway.saveApiKey({ ...creds, apiKey: refreshed }) + session = auth + apiKey = refreshed + gatewayUrl = baseUrl + manualKey = false + deps.onConfigured?.() + // The renderer owns writes to the local server, so hand it the credential + // to merge into the auth registry the same way the configure flow does. + return { status: "refreshed", providerID: GATEWAY_PROVIDER.id, apiKey: refreshed } + }, setManualApiKey(key: string) { if (!key.length) throw new Error("empty API key") apiKey = key diff --git a/packages/desktop/src/main/gateway-sso/ipc.ts b/packages/desktop/src/main/gateway-sso/ipc.ts index 967d417c6e..4015372fc8 100644 --- a/packages/desktop/src/main/gateway-sso/ipc.ts +++ b/packages/desktop/src/main/gateway-sso/ipc.ts @@ -43,6 +43,7 @@ export function registerGatewaySsoIpcHandlers(controller: GatewaySsoController) controller.setManualApiKey(requireGatewayIpcString("API key", key)), ) ipcMain.handle("gateway-sso-fetch-models", () => controller.fetchModels()) + ipcMain.handle("gateway-sso-ensure-key", () => controller.ensureKey()) ipcMain.handle("gateway-sso-complete", (_event: IpcMainInvokeEvent, model: unknown) => controller.complete(requireGatewayIpcString("model", model)), ) diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index a451cbc63c..40d41be1f2 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -91,6 +91,7 @@ const api: ElectronAPI = { fetchSetup: () => ipcRenderer.invoke("gateway-sso-fetch-setup"), setManualApiKey: (apiKey) => ipcRenderer.invoke("gateway-sso-set-manual-api-key", apiKey), fetchModels: () => ipcRenderer.invoke("gateway-sso-fetch-models"), + ensureKey: () => ipcRenderer.invoke("gateway-sso-ensure-key"), complete: (model) => ipcRenderer.invoke("gateway-sso-complete", model), }, updater: { diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 69a8f42b59..7c09afddfd 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -26,7 +26,12 @@ export type { SandboxesState, WslInstallState, } from "@opencode-ai/app/sandbox/types" -export type { GatewayCompleteResult, GatewaySetupResult, GatewaySsoState } from "@opencode-ai/app/gateway/types" +export type { + GatewayCompleteResult, + GatewayKeyCheck, + GatewaySetupResult, + GatewaySsoState, +} from "@opencode-ai/app/gateway/types" export type ServerReadyData = { url: string