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 @@...@@ -1,26 +1,39 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
3import type * as Electron from "electron";6import type * as Electron from "electron";
4import { beforeEach, vi } from "vite-plus/test";7import { beforeEach, vi } from "vite-plus/test";
58
6const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({9const { appFocusMock, browserWindowMock, getAllWindowsMock, getFocusedWindowMock } = vi.hoisted(
7 appFocusMock: vi.fn(),10 () => ({
8 getAllWindowsMock: vi.fn(),11 appFocusMock: vi.fn(),
9}));12 browserWindowMock: vi.fn(function BrowserWindowMock() {}),
13 getAllWindowsMock: vi.fn(),
14 getFocusedWindowMock: vi.fn(),
15 }),
16);
1017
11vi.mock("electron", () => ({18vi.mock("electron", () => ({
12 app: {19 app: {
13 focus: appFocusMock,20 focus: appFocusMock,
14 },21 },
15 BrowserWindow: {22 BrowserWindow: Object.assign(browserWindowMock, {
16 getAllWindows: getAllWindowsMock,23 getAllWindows: getAllWindowsMock,
17 },24 getFocusedWindow: getFocusedWindowMock,
25 }),
18}));26}));
1927
20import * as ElectronWindow from "./ElectronWindow.ts";28import * 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 }) {
23 return {35 return {
36 id: input.id,
24 isDestroyed: vi.fn(() => input.destroyed),37 isDestroyed: vi.fn(() => input.destroyed),
25 } as unknown as Electron.BrowserWindow;38 } as unknown as Electron.BrowserWindow;
26}39}
...@@ -28,13 +41,78 @@ function makeBrowserWindow(input: { readonly destroyed: boolean }) {...@@ -28,13 +41,78 @@ function makeBrowserWindow(input: { readonly destroyed: boolean }) {
28describe("ElectronWindow", () => {41describe("ElectronWindow", () => {
29 beforeEach(() => {42 beforeEach(() => {
30 appFocusMock.mockReset();43 appFocusMock.mockReset();
44 browserWindowMock.mockReset();
31 getAllWindowsMock.mockReset();45 getAllWindowsMock.mockReset();
46 getFocusedWindowMock.mockReset();
32 });47 });
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
34 it.effect("skips windows destroyed before appearance sync runs", () =>112 it.effect("skips windows destroyed before appearance sync runs", () =>
35 Effect.gen(function* () {113 Effect.gen(function* () {
36 const liveWindow = makeBrowserWindow({ destroyed: false });114 const liveWindow = makeBrowserWindow({ id: 1, destroyed: false });
37 const destroyedWindow = makeBrowserWindow({ destroyed: true });115 const destroyedWindow = makeBrowserWindow({ id: 2, destroyed: true });
38 getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]);116 getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]);
39117
40 const syncedWindows: Electron.BrowserWindow[] = [];118 const syncedWindows: Electron.BrowserWindow[] = [];
...@@ -46,6 +124,112 @@ describe("ElectronWindow", () => {...@@ -46,6 +124,112 @@ describe("ElectronWindow", () => {
46 );124 );
47125
48 assert.deepEqual(syncedWindows, [liveWindow]);126 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)),
50 );234 );
51});235});
apps/desktop/src/electron/ElectronWindow.ts+191-44
...@@ -8,14 +8,69 @@ import * as Schema from "effect/Schema";...@@ -8,14 +8,69 @@ import * as Schema from "effect/Schema";
88
9import * as Electron from "electron";9import * 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
11export class ElectronWindowCreateError extends Schema.TaggedErrorClass<ElectronWindowCreateError>()(41export class ElectronWindowCreateError extends Schema.TaggedErrorClass<ElectronWindowCreateError>()(
12 "ElectronWindowCreateError",42 "ElectronWindowCreateError",
13 {43 {
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),
14 cause: Schema.Defect(),67 cause: Schema.Defect(),
15 },68 },
16) {69) {
17 override get message(): string {70 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}.`;
19 }74 }
20}75}
2176
...@@ -43,9 +98,38 @@ export const make = Effect.gen(function* () {...@@ -43,9 +98,38 @@ export const make = Effect.gen(function* () {
43 const platform = yield* HostProcessPlatform;98 const platform = yield* HostProcessPlatform;
44 const mainWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());99 const mainWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
45100
46 const liveMain = Ref.get(mainWindowRef).pipe(101 const listWindows = Effect.try({
47 Effect.map(Option.filter((value) => !value.isDestroyed())),102 try: () => Electron.BrowserWindow.getAllWindows(),
48 );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
50 const currentMainOrFirst = Effect.gen(function* () {134 const currentMainOrFirst = Effect.gen(function* () {
51 const main = yield* liveMain;135 const main = yield* liveMain;
...@@ -53,27 +137,60 @@ export const make = Effect.gen(function* () {...@@ -53,27 +137,60 @@ export const make = Effect.gen(function* () {
53 return main;137 return main;
54 }138 }
55139
56 return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe(140 const first = Option.fromNullishOr((yield* listWindows)[0] ?? null);
57 Option.filter((window) => !window.isDestroyed()),141 if (Option.isNone(first) || (yield* isWindowDestroyed(first.value))) {
58 );142 return Option.none<Electron.BrowserWindow>();
143 }
144 return first;
59 });145 });
60146
61 const focusedMainOrFirst = Effect.sync(() =>147 const focusedMainOrFirst = Effect.gen(function* () {
62 Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe(148 const focused = yield* Effect.try({
63 Option.filter((window) => !window.isDestroyed()),149 try: () => Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null),
64 ),150 catch: (cause) =>
65 ).pipe(151 new ElectronWindowOperationError({
66 Effect.flatMap((focused) =>152 operation: "get-focused-window",
67 Option.isSome(focused) ? Effect.succeed(focused) : currentMainOrFirst,153 platform,
68 ),154 windowId: null,
69 );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
71 return ElectronWindow.of({165 return ElectronWindow.of({
72 create: (options) =>166 create: (options) => {
73 Effect.try({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({
74 try: () => new Electron.BrowserWindow(options),190 try: () => new Electron.BrowserWindow(options),
75 catch: (cause) => new ElectronWindowCreateError({ cause }),191 catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }),
76 }),192 });
193 },
77 main: liveMain,194 main: liveMain,
78 currentMainOrFirst,195 currentMainOrFirst,
79 focusedMainOrFirst,196 focusedMainOrFirst,
...@@ -89,45 +206,75 @@ export const make = Effect.gen(function* () {...@@ -89,45 +206,75 @@ export const make = Effect.gen(function* () {
89 return Option.none();206 return Option.none();
90 }),207 }),
91 reveal: (window) =>208 reveal: (window) =>
92 Effect.sync(() => {209 Effect.try({
93 if (window.isDestroyed()) {210 try: () => {
94 return;211 if (window.isDestroyed()) {
95 }212 return;
213 }
96214
97 if (window.isMinimized()) {215 if (window.isMinimized()) {
98 window.restore();216 window.restore();
99 }217 }
100218
101 if (!window.isVisible()) {219 if (!window.isVisible()) {
102 window.show();220 window.show();
103 }221 }
104222
105 if (platform === "darwin") {223 if (platform === "darwin") {
106 Electron.app.focus({ steal: true });224 Electron.app.focus({ steal: true });
107 }225 }
108226
109 window.focus();227 window.focus();
110 }),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),
111 sendAll: (channel, ...args) =>238 sendAll: (channel, ...args) =>
112 Effect.sync(() => {239 Effect.gen(function* () {
113 for (const window of Electron.BrowserWindow.getAllWindows()) {240 for (const window of yield* listWindows) {
114 if (window.isDestroyed()) {241 if (yield* isWindowDestroyed(window)) {
115 continue;242 continue;
116 }243 }
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);
118 }255 }
119 }),256 }),
120 destroyAll: Effect.sync(() => {257 destroyAll: Effect.gen(function* () {
121 for (const window of Electron.BrowserWindow.getAllWindows()) {258 for (const window of yield* listWindows) {
122 window.destroy();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);
123 }270 }
124 }),271 }),
125 syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* <E, R>(272 syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* <E, R>(
126 sync: (window: Electron.BrowserWindow) => Effect.Effect<void, E, R>,273 sync: (window: Electron.BrowserWindow) => Effect.Effect<void, E, R>,
127 ) {274 ) {
128 const windows = Electron.BrowserWindow.getAllWindows();275 const windows = yield* listWindows;
129 for (const window of windows) {276 for (const window of windows) {
130 if (window.isDestroyed()) {277 if (yield* isWindowDestroyed(window)) {
131 continue;278 continue;
132 }279 }
133 yield* sync(window);280 yield* sync(window);