From cd48eaa46df7279d216e57c514acf155b235cc36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 03:56:19 -0700 Subject: [PATCH 1/2] [codex] add structured Electron dialog errors Co-authored-by: codex --- .../src/electron/ElectronDialog.test.ts | 101 +++++++++++++ apps/desktop/src/electron/ElectronDialog.ts | 139 ++++++++++++++++-- 2 files changed, 226 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 9be62e740b2..499d2b4553e 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type { BrowserWindow } from "electron"; @@ -90,4 +91,104 @@ describe("ElectronDialog", () => { ]); }).pipe(Effect.provide(ElectronDialog.layer)), ); + + it.effect("preserves folder picker request context and cause", () => + Effect.gen(function* () { + const cause = new Error("folder picker failed"); + const owner = { id: 7 } as BrowserWindow; + showOpenDialogMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.pickFolder({ + owner: Option.some(owner), + defaultPath: Option.some("/workspace"), + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); + assert.isTrue(ElectronDialog.isElectronDialogError(error)); + assert.strictEqual(error.ownerWindowId, 7); + assert.strictEqual(error.defaultPath, "/workspace"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "window 7"); + assert.include(error.message, "/workspace"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves confirmation request context and cause", () => + Effect.gen(function* () { + const cause = new Error("confirmation failed"); + const owner = { id: 9 } as BrowserWindow; + showMessageBoxMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.confirm({ + owner: Option.some(owner), + message: " Confirm removal? ", + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogConfirmError); + assert.strictEqual(error.ownerWindowId, 9); + assert.strictEqual(error.promptMessage, "Confirm removal?"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "window 9"); + assert.include(error.message, "Confirm removal?"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves message box request context and cause", () => + Effect.gen(function* () { + const cause = new Error("message box failed"); + showMessageBoxMock.mockRejectedValue(cause); + const dialog = yield* ElectronDialog.ElectronDialog; + + const error = yield* Effect.flip( + dialog.showMessageBox({ + type: "warning", + title: "Unsaved changes", + message: "Discard changes?", + detail: "This cannot be undone.", + buttons: ["Cancel", "Discard"], + }), + ); + + assert.instanceOf(error, ElectronDialog.ElectronDialogShowMessageBoxError); + assert.strictEqual(error.type, "warning"); + assert.strictEqual(error.title, "Unsaved changes"); + assert.strictEqual(error.dialogMessage, "Discard changes?"); + assert.strictEqual(error.dialogDetail, "This cannot be undone."); + assert.deepEqual(error.buttons, ["Cancel", "Discard"]); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "warning"); + assert.include(error.message, "Unsaved changes"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("preserves error box request context and cause in the defect", () => + Effect.gen(function* () { + const cause = new Error("error box failed"); + showErrorBoxMock.mockImplementation(() => { + throw cause; + }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const exit = yield* Effect.exit(dialog.showErrorBox("Startup failed", "Could not start.")); + + assert.isTrue(exit._tag === "Failure"); + if (exit._tag === "Success") return; + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronDialog.ElectronDialogShowErrorBoxError); + assert.strictEqual(error.title, "Startup failed"); + assert.strictEqual(error.content, "Could not start."); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "Startup failed"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 057817ec7e6..9cbe562e4ea 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -2,11 +2,81 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; const CONFIRM_BUTTON_INDEX = 1; +export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( + "ElectronDialogPickFolderError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + defaultPath: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath; + return `Failed to open the Electron folder picker for ${owner} with ${defaultPath}.`; + } +} + +export class ElectronDialogConfirmError extends Schema.TaggedErrorClass()( + "ElectronDialogConfirmError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + promptMessage: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + return `Failed to open an Electron confirmation dialog for ${owner}: ${this.promptMessage}`; + } +} + +export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass()( + "ElectronDialogShowMessageBoxError", + { + type: Schema.NullOr(Schema.Literals(["none", "info", "error", "question", "warning"])), + title: Schema.NullOr(Schema.String), + dialogMessage: Schema.String, + dialogDetail: Schema.NullOr(Schema.String), + buttons: Schema.Array(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const type = this.type === null ? "untyped" : this.type; + const title = this.title === null ? "untitled" : this.title; + return `Failed to show the Electron ${type} message box titled ${title}.`; + } +} + +export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass()( + "ElectronDialogShowErrorBoxError", + { + title: Schema.String, + content: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to show the Electron error box titled ${this.title}.`; + } +} + +export const ElectronDialogError = Schema.Union([ + ElectronDialogPickFolderError, + ElectronDialogConfirmError, + ElectronDialogShowMessageBoxError, + ElectronDialogShowErrorBoxError, +]); +export type ElectronDialogError = typeof ElectronDialogError.Type; +export const isElectronDialogError = Schema.is(ElectronDialogError); + export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; readonly defaultPath: Option.Option; @@ -22,17 +92,24 @@ export class ElectronDialog extends Context.Service< { readonly pickFolder: ( input: ElectronDialogPickFolderInput, - ) => Effect.Effect>; - readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; + ) => Effect.Effect, ElectronDialogPickFolderError>; + readonly confirm: ( + input: ElectronDialogConfirmInput, + ) => Effect.Effect; readonly showMessageBox: ( options: Electron.MessageBoxOptions, - ) => Effect.Effect; + ) => Effect.Effect; readonly showErrorBox: (title: string, content: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronDialog") {} export const make = ElectronDialog.of({ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const defaultPath = Option.getOrNull(input.defaultPath); const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { onNone: () => ({ properties: ["openDirectory", "createDirectory"], @@ -42,10 +119,18 @@ export const make = ElectronDialog.of({ defaultPath, }), }); - const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), - onSome: (owner) => - Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), + }), + catch: (cause) => + new ElectronDialogPickFolderError({ + ownerWindowId, + defaultPath, + cause, + }), }); if (result.canceled) { @@ -67,17 +152,43 @@ export const make = ElectronDialog.of({ noLink: true, message: normalizedMessage, }; - const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)), - onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)), + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showMessageBox(options), + onSome: (owner) => Electron.dialog.showMessageBox(owner, options), + }), + catch: (cause) => + new ElectronDialogConfirmError({ + ownerWindowId, + promptMessage: normalizedMessage, + cause, + }), }); return result.response === CONFIRM_BUTTON_INDEX; }), - showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), - showErrorBox: (title, content) => - Effect.sync(() => { - Electron.dialog.showErrorBox(title, content); + showMessageBox: (options) => + Effect.tryPromise({ + try: () => Electron.dialog.showMessageBox(options), + catch: (cause) => + new ElectronDialogShowMessageBoxError({ + type: options.type ?? null, + title: options.title ?? null, + dialogMessage: options.message, + dialogDetail: options.detail ?? null, + buttons: options.buttons ?? [], + cause, + }), }), + showErrorBox: (title, content) => + Effect.try({ + try: () => Electron.dialog.showErrorBox(title, content), + catch: (cause) => new ElectronDialogShowErrorBoxError({ title, content, cause }), + }).pipe(Effect.orDie), }); export const layer = Layer.succeed(ElectronDialog, make); From 513653f3b1a4886c8c368ce5b5e24c1e40a0c39d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:09:53 -0700 Subject: [PATCH 2/2] Redact Electron dialog payloads Co-authored-by: codex --- .../src/electron/ElectronDialog.test.ts | 32 +++++++++++----- apps/desktop/src/electron/ElectronDialog.ts | 38 ++++++++++--------- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 499d2b4553e..388b3fd2c15 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -133,10 +133,11 @@ describe("ElectronDialog", () => { assert.instanceOf(error, ElectronDialog.ElectronDialogConfirmError); assert.strictEqual(error.ownerWindowId, 9); - assert.strictEqual(error.promptMessage, "Confirm removal?"); + assert.strictEqual(error.promptLength, "Confirm removal?".length); + assert.notProperty(error, "promptMessage"); assert.strictEqual(error.cause, cause); assert.include(error.message, "window 9"); - assert.include(error.message, "Confirm removal?"); + assert.notInclude(error.message, "Confirm removal?"); assert.notInclude(error.message, cause.message); }).pipe(Effect.provide(ElectronDialog.layer)), ); @@ -159,13 +160,21 @@ describe("ElectronDialog", () => { assert.instanceOf(error, ElectronDialog.ElectronDialogShowMessageBoxError); assert.strictEqual(error.type, "warning"); - assert.strictEqual(error.title, "Unsaved changes"); - assert.strictEqual(error.dialogMessage, "Discard changes?"); - assert.strictEqual(error.dialogDetail, "This cannot be undone."); - assert.deepEqual(error.buttons, ["Cancel", "Discard"]); + assert.strictEqual(error.titleLength, "Unsaved changes".length); + assert.strictEqual(error.messageLength, "Discard changes?".length); + assert.strictEqual(error.detailLength, "This cannot be undone.".length); + assert.strictEqual(error.buttonCount, 2); + assert.notProperty(error, "title"); + assert.notProperty(error, "dialogMessage"); + assert.notProperty(error, "dialogDetail"); + assert.notProperty(error, "buttons"); assert.strictEqual(error.cause, cause); assert.include(error.message, "warning"); - assert.include(error.message, "Unsaved changes"); + assert.notInclude(error.message, "Unsaved changes"); + assert.notInclude(error.message, "Discard changes?"); + assert.notInclude(error.message, "This cannot be undone."); + assert.notInclude(error.message, "Cancel"); + assert.notInclude(error.message, "Discard"); assert.notInclude(error.message, cause.message); }).pipe(Effect.provide(ElectronDialog.layer)), ); @@ -184,10 +193,13 @@ describe("ElectronDialog", () => { if (exit._tag === "Success") return; const error = Cause.squash(exit.cause); assert.instanceOf(error, ElectronDialog.ElectronDialogShowErrorBoxError); - assert.strictEqual(error.title, "Startup failed"); - assert.strictEqual(error.content, "Could not start."); + assert.strictEqual(error.titleLength, "Startup failed".length); + assert.strictEqual(error.contentLength, "Could not start.".length); + assert.notProperty(error, "title"); + assert.notProperty(error, "content"); assert.strictEqual(error.cause, cause); - assert.include(error.message, "Startup failed"); + assert.notInclude(error.message, "Startup failed"); + assert.notInclude(error.message, "Could not start."); assert.notInclude(error.message, cause.message); }).pipe(Effect.provide(ElectronDialog.layer)), ); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 9cbe562e4ea..be633971bea 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -27,13 +27,13 @@ export class ElectronDialogConfirmError extends Schema.TaggedErrorClass()( "ElectronDialogShowErrorBoxError", { - title: Schema.String, - content: Schema.String, + titleLength: Schema.Number, + contentLength: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to show the Electron error box titled ${this.title}.`; + return `Failed to show the Electron error box with a ${this.titleLength}-character title and ${this.contentLength}-character content.`; } } @@ -165,7 +164,7 @@ export const make = ElectronDialog.of({ catch: (cause) => new ElectronDialogConfirmError({ ownerWindowId, - promptMessage: normalizedMessage, + promptLength: normalizedMessage.length, cause, }), }); @@ -177,17 +176,22 @@ export const make = ElectronDialog.of({ catch: (cause) => new ElectronDialogShowMessageBoxError({ type: options.type ?? null, - title: options.title ?? null, - dialogMessage: options.message, - dialogDetail: options.detail ?? null, - buttons: options.buttons ?? [], + titleLength: options.title?.length ?? null, + messageLength: options.message.length, + detailLength: options.detail?.length ?? null, + buttonCount: options.buttons?.length ?? 0, cause, }), }), showErrorBox: (title, content) => Effect.try({ try: () => Electron.dialog.showErrorBox(title, content), - catch: (cause) => new ElectronDialogShowErrorBoxError({ title, content, cause }), + catch: (cause) => + new ElectronDialogShowErrorBoxError({ + titleLength: title.length, + contentLength: content.length, + cause, + }), }).pipe(Effect.orDie), });