Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,20 @@ already in use` and no way forward short of finding the file in a temporary

### Desktop / Web UI

- **Code's tab strip shows one project at a time** — the per-folder tab lists
- **No more "Could not reach Local Server" on corporate proxies** — on
networks with a proxy auto-configuration script, the app often opened onto
"Could not reach Local Server / Retrying automatically…" and then connected
by itself a few seconds later. The server was never down: the app's browser
engine holds every request — even ones to the local server on the same
machine — until it has finished downloading and evaluating the network's
proxy configuration, and on corporate networks that can take longer than the
ten seconds the app waited before declaring the server unreachable. The app
now starts that proxy lookup early, while the local server is still booting,
and if the ten seconds still run out it double-checks through a channel that
doesn't wait on the proxy — when that says the server is fine, it keeps the
loading screen up until the connection comes through instead of flashing an
error. Remote servers you add yourself still report unreachable after ten
seconds as before. — the per-folder tab lists
that **Work** already had now apply to **Code** too: the strip across the top
shows the tabs of the project you are in, keyed by server and folder, instead
of every Code tab from every server in one long row. Clicking a project in
Expand Down
2 changes: 2 additions & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ from the squash message first, or use a plain merge.
| Desktop gateway SSO wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Main instantiates the fork-only `src/main/gateway-sso/` controller (wrapping `@codev/gateway` `login`/`silentSso` — node-only loopback OAuth — with `shell.openExternal` and a `net.fetch` wrapper) and registers its `gateway-sso-*` IPC channels next to the sandbox ones, passing an `onConfigured` callback that restarts the running sandboxes (`SandboxController.restartRunning`) because a guest is handed its codev config once at boot and cannot see credentials that appear afterwards; preload exposes the `gatewaySso` namespace (subscribe fan-out, sandbox pattern) with types re-exported from `@opencode-ai/app/gateway/types`; the renderer platform maps `gatewaySso: window.api.gatewaySso`. | SSO login for the desktop configure gate must run in the main process (`node:http` loopback server, `~/.codev/auth.json` writes); `net.fetch` so enterprise proxies/PAC apply (egress precedent). | Keep the wiring; take upstream's surrounding changes. Keep channel names `gateway-sso-*` stable. |
| `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern). | Keep the export; take upstream's other manifest changes. |
| `packages/app/src/i18n/{en,es}.ts` | Added the `gateway.configure.*` key family (configure-gate dialog) and reworded `sidebar.gettingStarted.line1` — the "includes free models" pitch no longer holds with the hosted providers hidden. | Strings for the fork's configure gate; the getting-started card must not advertise models the fork does not offer. | Keep our keys and copy; take upstream's surrounding i18n changes. |
| `packages/desktop/src/main/index.ts` | Deleted upstream's `app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")`. | Chromium's `<-loopback>` bypass rule means the opposite of what it reads as: it *subtracts* the implicit rules that keep loopback off the proxy, i.e. it opts localhost traffic *into* proxying (`net/docs/proxy.md`). Today it is inert — Electron reads `--proxy-bypass-list` only together with `--proxy-server` (`shell/browser/browser_process_impl.cc`), which the app never passes — but it becomes actively harmful the moment anything launches the app with `--proxy-server`: the renderer's sidecar traffic would be routed into the proxy. Chromium already bypasses loopback by default, so nothing replaces it. | Keep deleted. If upstream adds new proxy switches, check them against Chromium's implicit-bypass semantics before taking them. |
| Startup proxy-stall gate: `packages/desktop/src/main/{index,ipc}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx`, `packages/app/src/app.tsx`, `packages/app/src/context/platform.tsx` | Three additive pieces. (1) `main/index.ts` fires `session.defaultSession.resolveProxy(url)` for the sidecar URL as soon as the port is known (fire-and-forget, result logged). (2) A `local-server-health` IPC chain: main answers with the Node-fetch `checkHealth` against the spawned sidecar once `serverReady` resolves (`false` if initialization failed), preload exposes `localServerHealth()`, the renderer platform maps it, and `platform.tsx` gains the optional `localServerHealth?()` capability. (3) `app.tsx` `ConnectionGate`: the health-check effect is restructured around an inner `poll`; when the 10 s blocking window expires without a healthy answer and the connection is the built-in sidecar, it asks `platform.localServerHealth` — if the host process vouches for the server it keeps blocking on the splash for up to another 50 s instead of showing the unreachable error. Background mode and `type: "http"` behavior are unchanged, and without the capability (web) the gate behaves exactly as upstream. | Chromium queues every renderer request — loopback included — until proxy initialization (PAC download / WPAD discovery) resolves; the implicit loopback-bypass rules are only applied once the resolution service is READY (`net/proxy_resolution/configured_proxy_resolution_service.cc`). On corporate networks that takes tens of seconds (measured 21 s vs 64 ms with an unreachable PAC host), so users behind a proxy saw "Could not reach Local Server" at every launch while the server was healthy the whole time — main's own Node-fetch health check (loopback in `NO_PROXY`) had already passed before the window opened. The prewarm overlaps proxy init with sidecar startup; the gate fallback distinguishes "server down" from "renderer network stack not ready". | Keep all three pieces; take upstream's surrounding changes. If upstream restructures `ConnectionGate`, re-apply the vouch-then-extend fallback around whatever its blocking window becomes. Keep the `local-server-health` channel name stable. |
| `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`; `autoUpdater.quitAndInstall()` → `quitAndInstall(true, true)`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. The `quitAndInstall` arguments are load-bearing against the assisted installer (see the `electron-builder.config.ts` row): the defaults are `isSilent = false, isForceRunAfter = false`, and `NsisUpdater.doInstall` appends `/S` only when `isSilent` — so with an assisted installer the default would run the full wizard on **every** update, re-asking install mode and directory for a location the user already chose. `isForceRunAfter` is honoured only when `isSilent` is set (`BaseUpdater.quitAndInstall` passes `autoRunAppAfterInstall` instead when it is not), so both arguments are needed to install quietly *and* come back up. | Keep `false`; take upstream's other updater settings. Keep `quitAndInstall(true, true)` for as long as `nsis.oneClick` is `false`; the two only make sense together. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). |
| Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; the `install-wsl` job additionally treats exit 3010 (`WSL_INSTALL_REBOOT_REQUIRED`, re-raised by the fork's elevated script) as success with `pendingRestart: true` rather than as a failure — `wsl --version` answers from the freshly installed MSI before the just-enabled VirtualMachinePlatform is active, so no post-install probe can detect the pending reboot; otherwise both return the same `WslCommandResult` shape and the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads from the codev storage host — the runtime as a per-architecture MSI named by its own manifest (`/codev/installers/wsl/manifest.json`, assets under `/codev/installers/wsl/<version>/`) and staged through `stageAsset` for resume + sha256 verification, the distro from the pinned `wsl.zip` — enables VirtualMachinePlatform and Microsoft-Windows-Subsystem-Linux via offline dism + installs the MSI behind one UAC prompt (Windows 10 requires the WSL optional component even for the standalone MSI — without it every `wsl` distro command fails with Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED; on machines that already have it dism is a no-op), and falls back to upstream's `wsl --install` / `--web-download` paths only when the download cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps, the 3010 handling in `install-wsl`, and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. |
| Async settings stores: `packages/desktop/src/main/{store,index}.ts`, `packages/desktop/package.json` | `store.ts` no longer uses electron-store: `getStore` now returns the fork-only `store-file.ts` store — in-memory state, flushed write-behind through `store-writer.ts`'s serialized latest-wins async atomic writer (temp → fsync → rename via `fs.promises`, so the fsync runs on the libuv threadpool). Adds `flushStoreWrites(timeoutMs)`; `removeStoreFile`/`removeStoreFileIfEmpty` discard/flush the writer around the delete so a late flush cannot resurrect the file. `index.ts`: `stopSidecars` awaits `flushStoreWrites(1500)`. `package.json`: the `electron-store` dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. | conf v15's `store.set` is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. | Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend `store-file.ts` to match — its API is the superset the desktop actually uses (`get/set/has/delete/clear/store/path`). Do not reintroduce a synchronous write path on the main thread. |
Expand Down
39 changes: 26 additions & 13 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {

function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
const server = useServer()
const platform = usePlatform()
const checkServerHealth = useCheckServerHealth()

const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
Expand All @@ -448,19 +449,31 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; start
props.disableHealthCheck
? true
: Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current

while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
const current = server.current
if (!current) return true

const poll = Effect.gen(function* () {
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(current.http))
if (res.healthy) return true
if (checkMode() === "background" || current.type === "http") return false
}
})
const first = yield* poll.pipe(
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(null) }),
)
if (first !== null) return first
// Renderer fetches ride Chromium's network stack, which queues even
// loopback requests until proxy initialization (PAC/WPAD) resolves —
// tens of seconds on some corporate networks. If the host process can
// still reach the built-in sidecar the server is fine: keep blocking
// on the splash instead of reporting it unreachable.
const localServerHealth = platform.localServerHealth
if (!localServerHealth || !ServerConnection.builtin(current)) return false
const vouched = yield* Effect.promise(() => localServerHealth().catch(() => false))
if (!vouched) return false
return yield* poll.pipe(Effect.timeoutOrElse({ duration: "50 seconds", orElse: () => Effect.succeed(false) }))
}).pipe(Effect.ensuring(Effect.sync(() => setCheckMode("background"))), Effect.runPromise),
)
const checking = createMemo(
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ type PlatformBase = {
/** Fetch override */
fetch?: typeof fetch

/**
* Host-process reachability of the built-in local sidecar, bypassing the
* renderer network stack (Electron only). Lets the startup gate tell a dead
* server from a renderer fetch stalled behind proxy initialization.
*/
localServerHealth?(): Promise<boolean>

/** Get the configured default server URL (platform-specific) */
getDefaultServer?(): Promise<ServerConnection.Key | null>

Expand Down
22 changes: 20 additions & 2 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { homedir, tmpdir } from "node:os"
import { join } from "node:path"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import type { Event } from "electron"
import { app, net, shell } from "electron"
import { app, net, session, shell } from "electron"

import { Deferred, Effect, Fiber } from "effect"
import contextMenu from "electron-context-menu"
Expand All @@ -26,6 +26,7 @@ import {
isOldLayoutEligible,
} from "./onboarding"
import {
checkHealth,
getDefaultServerUrl,
preferAppEnv,
setDefaultServerUrl,
Expand Down Expand Up @@ -202,7 +203,6 @@ const main = Effect.gen(function* () {

ensureLoopbackNoProxy()
useEnvProxy()
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
const features = app.commandLine.getSwitchValue("enable-features")
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
Expand Down Expand Up @@ -297,6 +297,15 @@ const main = Effect.gen(function* () {
(e) => Effect.runPromise(e),
),
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
// The renderer's own health checks ride Chromium's network stack, which
// can stall behind proxy initialization even for loopback; this answers
// from the main process's Node fetch so the gate can tell "server down"
// from "renderer network not ready".
localServerHealth: () =>
Effect.runPromise(Deferred.await(serverReady)).then(
(ready) => checkHealth(ready.url, ready.password),
() => false,
),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
isFirstLaunchOnboardingPending,
Expand Down Expand Up @@ -381,6 +390,15 @@ const main = Effect.gen(function* () {
const url = `http://${hostname}:${port}`
const password = randomUUID()

// Chromium queues every renderer request — loopback included — behind its
// proxy initialization (PAC download / WPAD discovery), which can take tens
// of seconds on corporate networks. Resolve once now so that wait overlaps
// sidecar startup instead of stalling the renderer's first health check.
void session.defaultSession
.resolveProxy(url)
.then((proxy) => logger.log("proxy resolution warmed", { proxy }))
.catch((error) => logger.warn("proxy resolution warmup failed", error))

const loadingTask = yield* Effect.gen(function* () {
logger.log("sidecar connection started", { url })

Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type Deps = {
killSidecar: () => Promise<void> | void
relaunch: () => void
awaitInitialization: () => Promise<ServerReadyData>
localServerHealth: () => Promise<boolean>
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
Expand All @@ -49,6 +50,7 @@ export function registerIpcHandlers(deps: Deps) {

ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
ipcMain.handle("local-server-health", () => deps.localServerHealth())
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const api: ElectronAPI = {
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
installCli: () => ipcRenderer.invoke("install-cli"),
awaitInitialization: () => ipcRenderer.invoke("await-initialization"),
localServerHealth: () => ipcRenderer.invoke("local-server-health"),
wslServers: {
getState: () => ipcRenderer.invoke("wsl-servers-get-state"),
subscribe: (cb) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/desktop/src/preload/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: () => Promise<ServerReadyData>
// Main-process (Node fetch) reachability of the built-in sidecar; immune to
// renderer-side proxy stalls.
localServerHealth: () => Promise<boolean>
wslServers: WslServersAPI
sandboxes: SandboxesAPI
gatewaySso: GatewaySsoAPI
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/renderer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
return fetch(input, init)
},

localServerHealth: () => window.api.localServerHealth(),

getDefaultServer: async () => {
const url = await window.api.getDefaultServerUrl().catch(() => null)
if (!url) return null
Expand Down
Loading