authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:23:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:23:46-07:00
logc7b375e9b0f0581a977a2745e3afbf5732aecb15
tree4fb0a87e66e0eb7d6ab0bd185b3e0d4a19c936e5
parentabc253d97dc1d976cf15e69276f2ac7375b26310
signature Signed by PGP key B5690EEEBB952194

[codex] Structure Electron window failures (#3276)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 385 insertions(+), 54 deletions(-)

apps/desktop/src/electron/ElectronWindow.test.ts+194-10
......@@ -1,26 +1,39 @@
11import { assert, describe, it } from "@effect/vitest";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Cause from "effect/Cause";
24import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
36import type * as Electron from "electron";
47import { beforeEach, vi } from "vite-plus/test";
58
6const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({
7 appFocusMock: vi.fn(),
8 getAllWindowsMock: vi.fn(),
9}));
9const { appFocusMock, browserWindowMock, getAllWindowsMock, getFocusedWindowMock } = vi.hoisted(
10 () => ({
11 appFocusMock: vi.fn(),
12 browserWindowMock: vi.fn(function BrowserWindowMock() {}),
13 getAllWindowsMock: vi.fn(),
14 getFocusedWindowMock: vi.fn(),
15 }),
16);
1017
1118vi.mock("electron", () => ({
1219 app: {
1320 focus: appFocusMock,
1421 },
15 BrowserWindow: {
22 BrowserWindow: Object.assign(browserWindowMock, {
1623 getAllWindows: getAllWindowsMock,
17 },
24 getFocusedWindow: getFocusedWindowMock,
25 }),
1826}));
1927
2028import * as ElectronWindow from "./ElectronWindow.ts";
2129
22function makeBrowserWindow(input: { readonly destroyed: boolean }) {
30const TestLayer = ElectronWindow.layer.pipe(
31 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
32);
33
34function makeBrowserWindow(input: { readonly id: number; readonly destroyed: boolean }) {
2335 return {
36 id: input.id,
2437 isDestroyed: vi.fn(() => input.destroyed),
2538 } as unknown as Electron.BrowserWindow;
2639}
......@@ -28,13 +41,78 @@ function makeBrowserWindow(input: { readonly destroyed: boolean }) {
2841describe("ElectronWindow", () => {
2942 beforeEach(() => {
3043 appFocusMock.mockReset();
44 browserWindowMock.mockReset();
3145 getAllWindowsMock.mockReset();
46 getFocusedWindowMock.mockReset();
3247 });
3348
49 it.effect("preserves schema-safe creation context and the Electron cause", () =>
50 Effect.gen(function* () {
51 const cause = new Error("native BrowserWindow construction failed");
52 browserWindowMock.mockImplementationOnce(function BrowserWindowFailure() {
53 throw cause;
54 });
55 const options = {
56 title: "T3 Code",
57 width: 1100,
58 height: 780,
59 minWidth: 840,
60 minHeight: 620,
61 show: false,
62 modal: false,
63 frame: true,
64 transparent: false,
65 backgroundColor: "#101010",
66 icon: {} as Electron.NativeImage,
67 webPreferences: {
68 preload: "/tmp/preload.js",
69 partition: "persist:t3code-preview-test",
70 sandbox: true,
71 contextIsolation: true,
72 nodeIntegration: false,
73 webviewTag: true,
74 spellcheck: true,
75 },
76 } satisfies Electron.BrowserWindowConstructorOptions;
77 const electronWindow = yield* ElectronWindow.ElectronWindow;
78
79 const error = yield* electronWindow.create(options).pipe(Effect.flip);
80
81 assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError);
82 assert.isTrue(ElectronWindow.isElectronWindowCreateError(error));
83 assert.deepEqual(error.options, {
84 title: "T3 Code",
85 width: 1100,
86 height: 780,
87 minWidth: 840,
88 minHeight: 620,
89 show: false,
90 modal: false,
91 frame: true,
92 transparent: false,
93 backgroundColor: "#101010",
94 webPreferences: {
95 preload: "/tmp/preload.js",
96 partition: "persist:t3code-preview-test",
97 sandbox: true,
98 contextIsolation: true,
99 nodeIntegration: false,
100 webviewTag: true,
101 },
102 });
103 assert.isFalse("icon" in error.options);
104 assert.isFalse("spellcheck" in error.options.webPreferences);
105 assert.strictEqual(error.cause, cause);
106 assert.equal(error.message, 'Failed to create Electron BrowserWindow "T3 Code" (1100x780).');
107 assert.notInclude(error.message, cause.message);
108 assert.deepEqual(browserWindowMock.mock.calls, [[options]]);
109 }).pipe(Effect.provide(TestLayer)),
110 );
111
34112 it.effect("skips windows destroyed before appearance sync runs", () =>
35113 Effect.gen(function* () {
36 const liveWindow = makeBrowserWindow({ destroyed: false });
37 const destroyedWindow = makeBrowserWindow({ destroyed: true });
114 const liveWindow = makeBrowserWindow({ id: 1, destroyed: false });
115 const destroyedWindow = makeBrowserWindow({ id: 2, destroyed: true });
38116 getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]);
39117
40118 const syncedWindows: Electron.BrowserWindow[] = [];
......@@ -46,6 +124,112 @@ describe("ElectronWindow", () => {
46124 );
47125
48126 assert.deepEqual(syncedWindows, [liveWindow]);
49 }).pipe(Effect.provide(ElectronWindow.layer)),
127 }).pipe(Effect.provide(TestLayer)),
128 );
129
130 it.effect("preserves window enumeration failures as structured defects", () =>
131 Effect.gen(function* () {
132 const cause = new Error("window enumeration failed");
133 getAllWindowsMock.mockImplementationOnce(() => {
134 throw cause;
135 });
136
137 const electronWindow = yield* ElectronWindow.ElectronWindow;
138 const exit = yield* Effect.exit(electronWindow.currentMainOrFirst);
139
140 assert.equal(exit._tag, "Failure");
141 if (exit._tag === "Failure") {
142 const error = Cause.squash(exit.cause);
143 assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError);
144 assert.equal(error.operation, "list-windows");
145 assert.equal(error.platform, "linux");
146 assert.isNull(error.windowId);
147 assert.isNull(error.channel);
148 assert.strictEqual(error.cause, cause);
149 assert.notInclude(error.message, cause.message);
150 }
151 }).pipe(Effect.provide(TestLayer)),
152 );
153
154 it.effect("preserves reveal failures with the target window", () =>
155 Effect.gen(function* () {
156 const cause = new Error("window restore failed");
157 const window = {
158 id: 41,
159 isDestroyed: vi.fn(() => false),
160 isMinimized: vi.fn(() => true),
161 restore: vi.fn(() => {
162 throw cause;
163 }),
164 } as unknown as Electron.BrowserWindow;
165
166 const electronWindow = yield* ElectronWindow.ElectronWindow;
167 const exit = yield* Effect.exit(electronWindow.reveal(window));
168
169 assert.equal(exit._tag, "Failure");
170 if (exit._tag === "Failure") {
171 const error = Cause.squash(exit.cause);
172 assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError);
173 assert.equal(error.operation, "reveal-window");
174 assert.equal(error.windowId, 41);
175 assert.isNull(error.channel);
176 assert.strictEqual(error.cause, cause);
177 }
178 }).pipe(Effect.provide(TestLayer)),
179 );
180
181 it.effect("preserves message delivery failures with window and channel context", () =>
182 Effect.gen(function* () {
183 const cause = new Error("renderer send failed");
184 const window = {
185 id: 42,
186 isDestroyed: vi.fn(() => false),
187 webContents: {
188 send: vi.fn(() => {
189 throw cause;
190 }),
191 },
192 } as unknown as Electron.BrowserWindow;
193 getAllWindowsMock.mockReturnValueOnce([window]);
194
195 const electronWindow = yield* ElectronWindow.ElectronWindow;
196 const exit = yield* Effect.exit(electronWindow.sendAll("desktop:update", { ready: true }));
197
198 assert.equal(exit._tag, "Failure");
199 if (exit._tag === "Failure") {
200 const error = Cause.squash(exit.cause);
201 assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError);
202 assert.equal(error.operation, "send-window-message");
203 assert.equal(error.windowId, 42);
204 assert.equal(error.channel, "desktop:update");
205 assert.strictEqual(error.cause, cause);
206 }
207 }).pipe(Effect.provide(TestLayer)),
208 );
209
210 it.effect("preserves destroy failures with the target window", () =>
211 Effect.gen(function* () {
212 const cause = new Error("window destroy failed");
213 const window = {
214 id: 43,
215 destroy: vi.fn(() => {
216 throw cause;
217 }),
218 } as unknown as Electron.BrowserWindow;
219 getAllWindowsMock.mockReturnValueOnce([window]);
220
221 const electronWindow = yield* ElectronWindow.ElectronWindow;
222 const exit = yield* Effect.exit(electronWindow.destroyAll);
223
224 assert.equal(exit._tag, "Failure");
225 if (exit._tag === "Failure") {
226 const error = Cause.squash(exit.cause);
227 assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError);
228 assert.equal(error.operation, "destroy-window");
229 assert.equal(error.windowId, 43);
230 assert.isNull(error.channel);
231 assert.strictEqual(error.cause, cause);
232 }
233 }).pipe(Effect.provide(TestLayer)),
50234 );
51235});
apps/desktop/src/electron/ElectronWindow.ts+191-44
......@@ -8,14 +8,69 @@ import * as Schema from "effect/Schema";
88
99import * as Electron from "electron";
1010
11const ElectronWindowCreateOptions = Schema.Struct({
12 title: Schema.NullOr(Schema.String),
13 width: Schema.NullOr(Schema.Number),
14 height: Schema.NullOr(Schema.Number),
15 minWidth: Schema.NullOr(Schema.Number),
16 minHeight: Schema.NullOr(Schema.Number),
17 show: Schema.NullOr(Schema.Boolean),
18 modal: Schema.NullOr(Schema.Boolean),
19 frame: Schema.NullOr(Schema.Boolean),
20 transparent: Schema.NullOr(Schema.Boolean),
21 backgroundColor: Schema.NullOr(Schema.String),
22 webPreferences: Schema.Struct({
23 preload: Schema.NullOr(Schema.String),
24 partition: Schema.NullOr(Schema.String),
25 sandbox: Schema.NullOr(Schema.Boolean),
26 contextIsolation: Schema.NullOr(Schema.Boolean),
27 nodeIntegration: Schema.NullOr(Schema.Boolean),
28 webviewTag: Schema.NullOr(Schema.Boolean),
29 }),
30});
31
32const ElectronWindowOperation = Schema.Literals([
33 "list-windows",
34 "get-focused-window",
35 "inspect-window",
36 "reveal-window",
37 "send-window-message",
38 "destroy-window",
39]);
40
1141export class ElectronWindowCreateError extends Schema.TaggedErrorClass<ElectronWindowCreateError>()(
1242 "ElectronWindowCreateError",
1343 {
44 options: ElectronWindowCreateOptions,
45 cause: Schema.Defect(),
46 },
47) {
48 override get message(): string {
49 const title = this.options.title === null ? "" : ` "${this.options.title}"`;
50 const dimensions =
51 this.options.width === null || this.options.height === null
52 ? ""
53 : ` (${this.options.width}x${this.options.height})`;
54 return `Failed to create Electron BrowserWindow${title}${dimensions}.`;
55 }
56}
57
58export const isElectronWindowCreateError = Schema.is(ElectronWindowCreateError);
59
60export class ElectronWindowOperationError extends Schema.TaggedErrorClass<ElectronWindowOperationError>()(
61 "ElectronWindowOperationError",
62 {
63 operation: ElectronWindowOperation,
64 platform: Schema.String,
65 windowId: Schema.NullOr(Schema.Number),
66 channel: Schema.NullOr(Schema.String),
1467 cause: Schema.Defect(),
1568 },
1669) {
1770 override get message(): string {
18 return "Failed to create Electron BrowserWindow.";
71 const window = this.windowId === null ? "" : ` for window ${this.windowId}`;
72 const channel = this.channel === null ? "" : ` on channel ${JSON.stringify(this.channel)}`;
73 return `Electron window operation ${JSON.stringify(this.operation)} failed${window}${channel} on ${this.platform}.`;
1974 }
2075}
2176
......@@ -43,9 +98,38 @@ export const make = Effect.gen(function* () {
4398 const platform = yield* HostProcessPlatform;
4499 const mainWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
45100
46 const liveMain = Ref.get(mainWindowRef).pipe(
47 Effect.map(Option.filter((value) => !value.isDestroyed())),
48 );
101 const listWindows = Effect.try({
102 try: () => Electron.BrowserWindow.getAllWindows(),
103 catch: (cause) =>
104 new ElectronWindowOperationError({
105 operation: "list-windows",
106 platform,
107 windowId: null,
108 channel: null,
109 cause,
110 }),
111 }).pipe(Effect.orDie);
112
113 const isWindowDestroyed = (window: Electron.BrowserWindow) =>
114 Effect.try({
115 try: () => window.isDestroyed(),
116 catch: (cause) =>
117 new ElectronWindowOperationError({
118 operation: "inspect-window",
119 platform,
120 windowId: window.id,
121 channel: null,
122 cause,
123 }),
124 }).pipe(Effect.orDie);
125
126 const liveMain = Effect.gen(function* () {
127 const main = yield* Ref.get(mainWindowRef);
128 if (Option.isNone(main) || (yield* isWindowDestroyed(main.value))) {
129 return Option.none<Electron.BrowserWindow>();
130 }
131 return main;
132 });
49133
50134 const currentMainOrFirst = Effect.gen(function* () {
51135 const main = yield* liveMain;
......@@ -53,27 +137,60 @@ export const make = Effect.gen(function* () {
53137 return main;
54138 }
55139
56 return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe(
57 Option.filter((window) => !window.isDestroyed()),
58 );
140 const first = Option.fromNullishOr((yield* listWindows)[0] ?? null);
141 if (Option.isNone(first) || (yield* isWindowDestroyed(first.value))) {
142 return Option.none<Electron.BrowserWindow>();
143 }
144 return first;
59145 });
60146
61 const focusedMainOrFirst = Effect.sync(() =>
62 Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe(
63 Option.filter((window) => !window.isDestroyed()),
64 ),
65 ).pipe(
66 Effect.flatMap((focused) =>
67 Option.isSome(focused) ? Effect.succeed(focused) : currentMainOrFirst,
68 ),
69 );
147 const focusedMainOrFirst = Effect.gen(function* () {
148 const focused = yield* Effect.try({
149 try: () => Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null),
150 catch: (cause) =>
151 new ElectronWindowOperationError({
152 operation: "get-focused-window",
153 platform,
154 windowId: null,
155 channel: null,
156 cause,
157 }),
158 }).pipe(Effect.orDie);
159 if (Option.isSome(focused) && !(yield* isWindowDestroyed(focused.value))) {
160 return focused;
161 }
162 return yield* currentMainOrFirst;
163 });
70164
71165 return ElectronWindow.of({
72 create: (options) =>
73 Effect.try({
166 create: (options) => {
167 const webPreferences = options.webPreferences;
168 const diagnosticOptions = {
169 title: options.title ?? null,
170 width: options.width ?? null,
171 height: options.height ?? null,
172 minWidth: options.minWidth ?? null,
173 minHeight: options.minHeight ?? null,
174 show: options.show ?? null,
175 modal: options.modal ?? null,
176 frame: options.frame ?? null,
177 transparent: options.transparent ?? null,
178 backgroundColor: options.backgroundColor ?? null,
179 webPreferences: {
180 preload: webPreferences?.preload ?? null,
181 partition: webPreferences?.partition ?? null,
182 sandbox: webPreferences?.sandbox ?? null,
183 contextIsolation: webPreferences?.contextIsolation ?? null,
184 nodeIntegration: webPreferences?.nodeIntegration ?? null,
185 webviewTag: webPreferences?.webviewTag ?? null,
186 },
187 } satisfies typeof ElectronWindowCreateOptions.Type;
188
189 return Effect.try({
74190 try: () => new Electron.BrowserWindow(options),
75 catch: (cause) => new ElectronWindowCreateError({ cause }),
76 }),
191 catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }),
192 });
193 },
77194 main: liveMain,
78195 currentMainOrFirst,
79196 focusedMainOrFirst,
......@@ -89,45 +206,75 @@ export const make = Effect.gen(function* () {
89206 return Option.none();
90207 }),
91208 reveal: (window) =>
92 Effect.sync(() => {
93 if (window.isDestroyed()) {
94 return;
95 }
209 Effect.try({
210 try: () => {
211 if (window.isDestroyed()) {
212 return;
213 }
96214
97 if (window.isMinimized()) {
98 window.restore();
99 }
215 if (window.isMinimized()) {
216 window.restore();
217 }
100218
101 if (!window.isVisible()) {
102 window.show();
103 }
219 if (!window.isVisible()) {
220 window.show();
221 }
104222
105 if (platform === "darwin") {
106 Electron.app.focus({ steal: true });
107 }
223 if (platform === "darwin") {
224 Electron.app.focus({ steal: true });
225 }
108226
109 window.focus();
110 }),
227 window.focus();
228 },
229 catch: (cause) =>
230 new ElectronWindowOperationError({
231 operation: "reveal-window",
232 platform,
233 windowId: window.id,
234 channel: null,
235 cause,
236 }),
237 }).pipe(Effect.orDie),
111238 sendAll: (channel, ...args) =>
112 Effect.sync(() => {
113 for (const window of Electron.BrowserWindow.getAllWindows()) {
114 if (window.isDestroyed()) {
239 Effect.gen(function* () {
240 for (const window of yield* listWindows) {
241 if (yield* isWindowDestroyed(window)) {
115242 continue;
116243 }
117 window.webContents.send(channel, ...args);
244 yield* Effect.try({
245 try: () => window.webContents.send(channel, ...args),
246 catch: (cause) =>
247 new ElectronWindowOperationError({
248 operation: "send-window-message",
249 platform,
250 windowId: window.id,
251 channel,
252 cause,
253 }),
254 }).pipe(Effect.orDie);
118255 }
119256 }),
120 destroyAll: Effect.sync(() => {
121 for (const window of Electron.BrowserWindow.getAllWindows()) {
122 window.destroy();
257 destroyAll: Effect.gen(function* () {
258 for (const window of yield* listWindows) {
259 yield* Effect.try({
260 try: () => window.destroy(),
261 catch: (cause) =>
262 new ElectronWindowOperationError({
263 operation: "destroy-window",
264 platform,
265 windowId: window.id,
266 channel: null,
267 cause,
268 }),
269 }).pipe(Effect.orDie);
123270 }
124271 }),
125272 syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* <E, R>(
126273 sync: (window: Electron.BrowserWindow) => Effect.Effect<void, E, R>,
127274 ) {
128 const windows = Electron.BrowserWindow.getAllWindows();
275 const windows = yield* listWindows;
129276 for (const window of windows) {
130 if (window.isDestroyed()) {
277 if (yield* isWindowDestroyed(window)) {
131278 continue;
132279 }
133280 yield* sync(window);