authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:10:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:10:45-07:00
log5a2c92e86d16ecba823fdc61e163b8c35969f668
treebb965ee9ca88edc1d43cf4613987771cec84653f
parent61aade9ea4e7df15fd196d9805a00d24ed715f7c
signature Signed by PGP key B5690EEEBB952194

[codex] Structure Electron app boundary failures (#3301)

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

2 files changed, 98 insertions(+), 10 deletions(-)

apps/desktop/src/electron/ElectronApp.test.ts+38
...@@ -100,6 +100,44 @@ describe("ElectronApp", () => {...@@ -100,6 +100,44 @@ describe("ElectronApp", () => {
100 }).pipe(Effect.provide(ElectronApp.layer)),100 }).pipe(Effect.provide(ElectronApp.layer)),
101 );101 );
102102
103 it.effect("reports which app metadata property failed", () =>
104 Effect.gen(function* () {
105 const cause = new Error("version unavailable");
106 getVersionMock.mockImplementationOnce(() => {
107 throw cause;
108 });
109
110 const electronApp = yield* ElectronApp.ElectronApp;
111 const error = yield* electronApp.metadata.pipe(Effect.flip);
112
113 assert.instanceOf(error, ElectronApp.ElectronAppMetadataReadError);
114 assert.strictEqual(error.property, "app-version");
115 assert.strictEqual(error.cause, cause);
116 assert.strictEqual(
117 error.message,
118 'Failed to read Electron app metadata property "app-version".',
119 );
120 }).pipe(Effect.provide(ElectronApp.layer)),
121 );
122
123 it.effect("preserves Electron readiness failures", () =>
124 Effect.gen(function* () {
125 const cause = new Error("ready failed");
126 whenReadyMock.mockRejectedValueOnce(cause);
127
128 const electronApp = yield* ElectronApp.ElectronApp;
129 const error = yield* electronApp.whenReady.pipe(Effect.flip);
130
131 assert.instanceOf(error, ElectronApp.ElectronAppWhenReadyError);
132 assert.strictEqual(error.isPackaged, true);
133 assert.strictEqual(error.cause, cause);
134 assert.strictEqual(
135 error.message,
136 "Failed to wait for the Electron app to become ready (packaged: true).",
137 );
138 }).pipe(Effect.provide(ElectronApp.layer)),
139 );
140
103 it.effect("scopes app event listeners", () =>141 it.effect("scopes app event listeners", () =>
104 Effect.gen(function* () {142 Effect.gen(function* () {
105 const listener = vi.fn();143 const listener = vi.fn();
apps/desktop/src/electron/ElectronApp.ts+60-10
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
4import * as Scope from "effect/Scope";5import * as Scope from "effect/Scope";
56
6import * as Electron from "electron";7import * as Electron from "electron";
...@@ -13,12 +14,36 @@ export interface ElectronAppMetadata {...@@ -13,12 +14,36 @@ export interface ElectronAppMetadata {
13 readonly runningUnderArm64Translation: boolean;14 readonly runningUnderArm64Translation: boolean;
14}15}
1516
17export class ElectronAppMetadataReadError extends Schema.TaggedErrorClass<ElectronAppMetadataReadError>()(
18 "ElectronAppMetadataReadError",
19 {
20 property: Schema.Literals(["app-version", "app-path"]),
21 cause: Schema.Defect(),
22 },
23) {
24 override get message(): string {
25 return `Failed to read Electron app metadata property "${this.property}".`;
26 }
27}
28
29export class ElectronAppWhenReadyError extends Schema.TaggedErrorClass<ElectronAppWhenReadyError>()(
30 "ElectronAppWhenReadyError",
31 {
32 isPackaged: Schema.Boolean,
33 cause: Schema.Defect(),
34 },
35) {
36 override get message(): string {
37 return `Failed to wait for the Electron app to become ready (packaged: ${this.isPackaged}).`;
38 }
39}
40
16export class ElectronApp extends Context.Service<41export class ElectronApp extends Context.Service<
17 ElectronApp,42 ElectronApp,
18 {43 {
19 readonly metadata: Effect.Effect<ElectronAppMetadata>;44 readonly metadata: Effect.Effect<ElectronAppMetadata, ElectronAppMetadataReadError>;
20 readonly name: Effect.Effect<string>;45 readonly name: Effect.Effect<string>;
21 readonly whenReady: Effect.Effect<void>;46 readonly whenReady: Effect.Effect<void, ElectronAppWhenReadyError>;
22 readonly quit: Effect.Effect<void>;47 readonly quit: Effect.Effect<void>;
23 readonly exit: (code: number) => Effect.Effect<void>;48 readonly exit: (code: number) => Effect.Effect<void>;
24 readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect<void>;49 readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect<void>;
...@@ -63,15 +88,40 @@ const addScopedAppListener = <Args extends ReadonlyArray<unknown>>(...@@ -63,15 +88,40 @@ const addScopedAppListener = <Args extends ReadonlyArray<unknown>>(
63 ).pipe(Effect.asVoid);88 ).pipe(Effect.asVoid);
6489
65export const make = ElectronApp.of({90export const make = ElectronApp.of({
66 metadata: Effect.sync(() => ({91 metadata: Effect.gen(function* () {
67 appVersion: Electron.app.getVersion(),92 const appVersion = yield* Effect.try({
68 appPath: Electron.app.getAppPath(),93 try: () => Electron.app.getVersion(),
69 isPackaged: Electron.app.isPackaged,94 catch: (cause) =>
70 resourcesPath: process.resourcesPath,95 new ElectronAppMetadataReadError({
71 runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true,96 property: "app-version",
72 })),97 cause,
98 }),
99 });
100 const appPath = yield* Effect.try({
101 try: () => Electron.app.getAppPath(),
102 catch: (cause) =>
103 new ElectronAppMetadataReadError({
104 property: "app-path",
105 cause,
106 }),
107 });
108
109 return {
110 appVersion,
111 appPath,
112 isPackaged: Electron.app.isPackaged,
113 resourcesPath: process.resourcesPath,
114 runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true,
115 };
116 }),
73 name: Effect.sync(() => Electron.app.name),117 name: Effect.sync(() => Electron.app.name),
74 whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid),118 whenReady: Effect.gen(function* () {
119 const isPackaged = Electron.app.isPackaged;
120 yield* Effect.tryPromise({
121 try: () => Electron.app.whenReady(),
122 catch: (cause) => new ElectronAppWhenReadyError({ isPackaged, cause }),
123 });
124 }),
75 quit: Effect.sync(() => {125 quit: Effect.sync(() => {
76 Electron.app.quit();126 Electron.app.quit();
77 }),127 }),