authorgravatar for mwolson@member.fsf.orgMike Olson <mwolson@member.fsf.org> 2026-04-21 11:15:41-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-21 08:15:41-07:00
log055897f0eafd8c2ba981f9d0ced1c21052035f5b
treedf1ea9d2b05502e629d7745a23dff30f57aabc81
parent40b3a800347462405b2d322ed5c0c60331f9e70d
signature Signed by PGP key B5690EEEBB952194

fix: enforce opencode >= 1.14.19 and reveal window on Wayland (#2262)


5 files changed, 177 insertions(+), 11 deletions(-)

apps/desktop/src/main.ts+12-10
...@@ -75,6 +75,7 @@ import {...@@ -75,6 +75,7 @@ import {
75} from "./updateMachine.ts";75} from "./updateMachine.ts";
76import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";76import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
77import { resolveDesktopAppBranding } from "./appBranding.ts";77import { resolveDesktopAppBranding } from "./appBranding.ts";
78import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";
7879
79syncShellEnvironment();80syncShellEnvironment();
8081
...@@ -1998,16 +1999,17 @@ function createWindow(): BrowserWindow {...@@ -1998,16 +1999,17 @@ function createWindow(): BrowserWindow {
1998 emitUpdateState();1999 emitUpdateState();
1999 });2000 });
20002001
2001 let initialRevealScheduled = false;2002 // On Linux/Wayland with `show: false`, Electron's `ready-to-show` only
2002 const revealInitialWindow = () => {2003 // fires after `show()` is called, deadlocking the standard "wait for
2003 if (initialRevealScheduled) {2004 // ready, then show" pattern. Add `did-finish-load` as a Linux-only
2004 return;2005 // fallback so the window still surfaces once the renderer has loaded
2005 }2006 // the page. Other platforms keep the no-flash `ready-to-show` path,
2006 initialRevealScheduled = true;2007 // since `did-finish-load` typically fires before the first paint there.
2007 revealWindow(window);2008 const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)];
2008 };2009 if (process.platform === "linux") {
20092010 revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire));
2010 window.once("ready-to-show", revealInitialWindow);2011 }
2012 bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window));
20112013
2012 if (isDevelopment) {2014 if (isDevelopment) {
2013 void window.loadURL(resolveDesktopDevServerUrl());2015 void window.loadURL(resolveDesktopDevServerUrl());
apps/desktop/src/windowReveal.test.ts created+74
...@@ -0,0 +1,74 @@
1import { EventEmitter } from "node:events";
2
3import { describe, expect, it, vi } from "vitest";
4
5import { bindFirstRevealTrigger } from "./windowReveal.ts";
6
7describe("bindFirstRevealTrigger", () => {
8 it("reveals when the first trigger fires", () => {
9 const window = new EventEmitter();
10 const webContents = new EventEmitter();
11 const reveal = vi.fn();
12
13 bindFirstRevealTrigger(
14 [
15 (fire) => window.once("ready-to-show", fire),
16 (fire) => webContents.once("did-finish-load", fire),
17 ],
18 reveal,
19 );
20
21 window.emit("ready-to-show");
22
23 expect(reveal).toHaveBeenCalledTimes(1);
24 });
25
26 it("reveals when only the fallback trigger fires (Wayland deadlock case)", () => {
27 const window = new EventEmitter();
28 const webContents = new EventEmitter();
29 const reveal = vi.fn();
30
31 bindFirstRevealTrigger(
32 [
33 (fire) => window.once("ready-to-show", fire),
34 (fire) => webContents.once("did-finish-load", fire),
35 ],
36 reveal,
37 );
38
39 webContents.emit("did-finish-load");
40
41 expect(reveal).toHaveBeenCalledTimes(1);
42 });
43
44 it("only reveals once when multiple triggers fire", () => {
45 const window = new EventEmitter();
46 const webContents = new EventEmitter();
47 const reveal = vi.fn();
48
49 bindFirstRevealTrigger(
50 [
51 (fire) => window.once("ready-to-show", fire),
52 (fire) => webContents.once("did-finish-load", fire),
53 ],
54 reveal,
55 );
56
57 webContents.emit("did-finish-load");
58 window.emit("ready-to-show");
59
60 expect(reveal).toHaveBeenCalledTimes(1);
61 });
62
63 it("subscribers using `once` ignore re-emitted events after reveal", () => {
64 const window = new EventEmitter();
65 const reveal = vi.fn();
66
67 bindFirstRevealTrigger([(fire) => window.once("ready-to-show", fire)], reveal);
68
69 window.emit("ready-to-show");
70 window.emit("ready-to-show");
71
72 expect(reveal).toHaveBeenCalledTimes(1);
73 });
74});
apps/desktop/src/windowReveal.ts created+28
...@@ -0,0 +1,28 @@
1export type RevealSubscription = (listener: () => void) => void;
2
3/**
4 * Wire a reveal callback to fire exactly once, on whichever of the provided
5 * event subscribers fires first. Each subscriber is responsible for binding
6 * its own event source.
7 *
8 * Used by the desktop main window's first-paint reveal logic. The standard
9 * Electron pattern is to wait for `ready-to-show` before calling `show()`,
10 * but on Linux/Wayland with `show: false`, `ready-to-show` only fires after
11 * `show()` is called, deadlocking that pattern. Subscribing to both
12 * `ready-to-show` and `did-finish-load` (or any other "renderer is alive"
13 * signal) lets the window surface reliably across platforms.
14 */
15export function bindFirstRevealTrigger(
16 subscribers: readonly RevealSubscription[],
17 reveal: () => void,
18): void {
19 let revealed = false;
20 const fire = () => {
21 if (revealed) return;
22 revealed = true;
23 reveal();
24 };
25 for (const subscribe of subscribers) {
26 subscribe(fire);
27 }
28}
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+32-1
...@@ -16,9 +16,12 @@ import {...@@ -16,9 +16,12 @@ import {
16import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";16import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
17import type { OpenCodeInventory } from "../opencodeRuntime.ts";17import type { OpenCodeInventory } from "../opencodeRuntime.ts";
1818
19const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
20
19const runtimeMock = {21const runtimeMock = {
20 state: {22 state: {
21 runVersionError: null as Error | null,23 runVersionError: null as Error | null,
24 versionStdout: DEFAULT_VERSION_STDOUT,
22 inventoryError: null as Error | null,25 inventoryError: null as Error | null,
23 inventory: {26 inventory: {
24 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },27 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
...@@ -27,6 +30,7 @@ const runtimeMock = {...@@ -27,6 +30,7 @@ const runtimeMock = {
27 },30 },
28 reset() {31 reset() {
29 this.state.runVersionError = null;32 this.state.runVersionError = null;
33 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
30 this.state.inventoryError = null;34 this.state.inventoryError = null;
31 this.state.inventory = {35 this.state.inventory = {
32 providerList: { connected: [], all: [] as unknown[], default: {} },36 providerList: { connected: [], all: [] as unknown[], default: {} },
...@@ -56,7 +60,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -56,7 +60,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
56 cause: runtimeMock.state.runVersionError,60 cause: runtimeMock.state.runVersionError,
57 }),61 }),
58 )62 )
59 : Effect.succeed({ stdout: "opencode 1.0.0\n", stderr: "", code: 0 }),63 : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
60 createOpenCodeSdkClient: () =>64 createOpenCodeSdkClient: () =>
61 ({}) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,65 ({}) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
62 loadOpenCodeInventory: () =>66 loadOpenCodeInventory: () =>
...@@ -108,6 +112,33 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {...@@ -108,6 +112,33 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
108 }),112 }),
109 );113 );
110114
115 it.effect("refuses to probe when opencode is older than the required minimum", () =>
116 Effect.gen(function* () {
117 runtimeMock.state.versionStdout = "opencode 1.4.7\n";
118 const provider = yield* OpenCodeProvider;
119 const snapshot = yield* provider.refresh;
120
121 assert.equal(snapshot.status, "error");
122 assert.equal(snapshot.installed, true);
123 assert.equal(snapshot.version, "1.4.7");
124 assert.ok(snapshot.message?.includes("1.14.19"));
125 assert.ok(snapshot.message?.toLowerCase().includes("upgrade"));
126 }),
127 );
128
129 it.effect("refuses to probe when opencode --version output is unparseable", () =>
130 Effect.gen(function* () {
131 runtimeMock.state.versionStdout = "garbled binary output\n";
132 const provider = yield* OpenCodeProvider;
133 const snapshot = yield* provider.refresh;
134
135 assert.equal(snapshot.status, "error");
136 assert.equal(snapshot.installed, true);
137 assert.equal(snapshot.version, null);
138 assert.ok(snapshot.message?.includes("1.14.19"));
139 }),
140 );
141
111 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>142 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
112 Effect.gen(function* () {143 Effect.gen(function* () {
113 runtimeMock.state.inventory = {144 runtimeMock.state.inventory = {
apps/server/src/provider/Layers/OpenCodeProvider.ts+31
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 parseGenericCliVersion,15 parseGenericCliVersion,
16 providerModelsFromSettings,16 providerModelsFromSettings,
17} from "../providerSnapshot.ts";17} from "../providerSnapshot.ts";
18import { compareCliVersions } from "../cliVersion.ts";
18import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";19import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
19import {20import {
20 OpenCodeRuntime,21 OpenCodeRuntime,
...@@ -24,6 +25,7 @@ import {...@@ -24,6 +25,7 @@ import {
24import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";25import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
2526
26const PROVIDER = "opencode" as const;27const PROVIDER = "opencode" as const;
28const MINIMUM_OPENCODE_VERSION = "1.14.19";
2729
28class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{30class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
29 readonly cause: unknown;31 readonly cause: unknown;
...@@ -354,6 +356,35 @@ export const OpenCodeProviderLive = Layer.effect(...@@ -354,6 +356,35 @@ export const OpenCodeProviderLive = Layer.effect(
354 return fallback(Cause.squash(versionExit.cause));356 return fallback(Cause.squash(versionExit.cause));
355 }357 }
356 version = parseGenericCliVersion(versionExit.value.stdout) ?? null;358 version = parseGenericCliVersion(versionExit.value.stdout) ?? null;
359
360 if (!version) {
361 return fallback(
362 new Error(
363 `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`,
364 ),
365 null,
366 );
367 }
368 if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) {
369 return buildServerProvider({
370 provider: PROVIDER,
371 enabled: input.settings.enabled,
372 checkedAt,
373 models: providerModelsFromSettings(
374 [],
375 PROVIDER,
376 customModels,
377 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
378 ),
379 probe: {
380 installed: true,
381 version,
382 status: "error",
383 auth: { status: "unknown" },
384 message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`,
385 },
386 });
387 }
357 }388 }
358389
359 const inventoryExit = yield* Effect.exit(390 const inventoryExit = yield* Effect.exit(