From 2066a6754b45334278abeb54cc72e2a211d4e539 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 18:00:10 -0700 Subject: [PATCH 1/8] refactor desktop Electron Effect services Co-authored-by: codex --- .../src/app/DesktopAppIdentity.test.ts | 4 +- apps/desktop/src/app/DesktopLifecycle.ts | 6 +- apps/desktop/src/electron/ElectronApp.ts | 115 ++++----- apps/desktop/src/electron/ElectronDialog.ts | 54 ++-- .../desktop/src/electron/ElectronMenu.test.ts | 36 ++- apps/desktop/src/electron/ElectronMenu.ts | 239 +++++++++--------- apps/desktop/src/electron/ElectronProtocol.ts | 44 ++-- .../src/electron/ElectronSafeStorage.ts | 69 +++-- apps/desktop/src/electron/ElectronShell.ts | 23 +- apps/desktop/src/electron/ElectronTheme.ts | 29 +-- apps/desktop/src/electron/ElectronUpdater.ts | 88 ++++--- .../src/electron/ElectronWindow.test.ts | 6 +- apps/desktop/src/electron/ElectronWindow.ts | 76 +++--- apps/desktop/src/main.ts | 12 +- .../src/ssh/DesktopSshPasswordPrompts.test.ts | 8 +- .../src/updates/DesktopUpdates.test.ts | 4 +- .../src/window/DesktopApplicationMenu.test.ts | 15 +- .../src/window/DesktopApplicationMenu.ts | 4 +- apps/desktop/src/window/DesktopWindow.test.ts | 18 +- apps/desktop/src/window/DesktopWindow.ts | 30 ++- 20 files changed, 436 insertions(+), 444 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 7c4c06eb616..06300fbdec9 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import type * as Electron from "electron"; +import type { AboutPanelOptionsOptions } from "electron"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; @@ -30,7 +30,7 @@ type TestEnvironmentInput = Partial; + readonly setAboutPanelOptions: Array; readonly setDockIcon: string[]; readonly setName: string[]; } diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index b62662ad27b..1a19243fa4e 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; -import type * as Electron from "electron"; +import type { Event } from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; @@ -69,7 +69,7 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo ); function handleBeforeQuit( - event: Electron.Event, + event: Event, runEffect: (effect: Effect.Effect) => Promise, allowQuit: () => boolean, markQuitAllowed: () => void, @@ -164,7 +164,7 @@ const make = DesktopLifecycle.of({ desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), ); }); - yield* electronApp.on("before-quit", (event: Electron.Event) => { + yield* electronApp.on("before-quit", (event: Event) => { handleBeforeQuit( event, runEffect, diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 49b432fd5dd..5717af09cbf 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; -import * as Electron from "electron"; +import { app, type AboutPanelOptionsOptions, type App, type RelaunchOptions } from "electron"; export interface ElectronAppMetadata { readonly appVersion: string; @@ -13,41 +13,35 @@ export interface ElectronAppMetadata { readonly runningUnderArm64Translation: boolean; } -export interface ElectronAppShape { - readonly metadata: Effect.Effect; - readonly name: Effect.Effect; - readonly whenReady: Effect.Effect; - readonly quit: Effect.Effect; - readonly exit: (code: number) => Effect.Effect; - readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; - readonly setPath: ( - name: Parameters[0], - path: string, - ) => Effect.Effect; - readonly setName: (name: string) => Effect.Effect; - readonly setAboutPanelOptions: ( - options: Electron.AboutPanelOptionsOptions, - ) => Effect.Effect; - readonly setAppUserModelId: (id: string) => Effect.Effect; - readonly requestSingleInstanceLock: Effect.Effect; - readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; - readonly setAsDefaultProtocolClient: ( - protocol: string, - path?: string, - args?: readonly string[], - ) => Effect.Effect; - readonly setDesktopName: (desktopName: string) => Effect.Effect; - readonly setDockIcon: (iconPath: string) => Effect.Effect; - readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; - readonly on: >( - eventName: string, - listener: (...args: Args) => void, - ) => Effect.Effect; -} - -export class ElectronApp extends Context.Service()( - "@t3tools/desktop/electron/ElectronApp", -) {} +export class ElectronApp extends Context.Service< + ElectronApp, + { + readonly metadata: Effect.Effect; + readonly name: Effect.Effect; + readonly whenReady: Effect.Effect; + readonly quit: Effect.Effect; + readonly exit: (code: number) => Effect.Effect; + readonly relaunch: (options: RelaunchOptions) => Effect.Effect; + readonly setPath: (name: Parameters[0], path: string) => Effect.Effect; + readonly setName: (name: string) => Effect.Effect; + readonly setAboutPanelOptions: (options: AboutPanelOptionsOptions) => Effect.Effect; + readonly setAppUserModelId: (id: string) => Effect.Effect; + readonly requestSingleInstanceLock: Effect.Effect; + readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; + readonly setAsDefaultProtocolClient: ( + protocol: string, + path?: string, + args?: readonly string[], + ) => Effect.Effect; + readonly setDesktopName: (desktopName: string) => Effect.Effect; + readonly setDockIcon: (iconPath: string) => Effect.Effect; + readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronApp") {} const addScopedAppListener = >( eventName: string, @@ -55,79 +49,78 @@ const addScopedAppListener = >( ): Effect.Effect => Effect.acquireRelease( Effect.sync(() => { - Electron.app.on(eventName as any, listener as any); + app.on(eventName as any, listener as any); }), () => Effect.sync(() => { - Electron.app.removeListener(eventName as any, listener as any); + app.removeListener(eventName as any, listener as any); }), ).pipe(Effect.asVoid); -const make = ElectronApp.of({ +export const make = ElectronApp.of({ metadata: Effect.sync(() => ({ - appVersion: Electron.app.getVersion(), - appPath: Electron.app.getAppPath(), - isPackaged: Electron.app.isPackaged, + appVersion: app.getVersion(), + appPath: app.getAppPath(), + isPackaged: app.isPackaged, resourcesPath: process.resourcesPath, - runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, + runningUnderArm64Translation: app.runningUnderARM64Translation === true, })), - name: Effect.sync(() => Electron.app.name), - whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid), + name: Effect.sync(() => app.name), + whenReady: Effect.promise(() => app.whenReady()).pipe(Effect.asVoid), quit: Effect.sync(() => { - Electron.app.quit(); + app.quit(); }), exit: (code) => Effect.sync(() => { - Electron.app.exit(code); + app.exit(code); }), relaunch: (options) => Effect.sync(() => { - Electron.app.relaunch(options); + app.relaunch(options); }), setPath: (name, path) => Effect.sync(() => { - Electron.app.setPath(name, path); + app.setPath(name, path); }), setName: (name) => Effect.sync(() => { - Electron.app.setName(name); + app.setName(name); }), setAboutPanelOptions: (options) => Effect.sync(() => { - Electron.app.setAboutPanelOptions(options); + app.setAboutPanelOptions(options); }), setAppUserModelId: (id) => Effect.sync(() => { - Electron.app.setAppUserModelId(id); + app.setAppUserModelId(id); }), - requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), - isDefaultProtocolClient: (protocol) => - Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), + requestSingleInstanceLock: Effect.sync(() => app.requestSingleInstanceLock()), + isDefaultProtocolClient: (protocol) => Effect.sync(() => app.isDefaultProtocolClient(protocol)), setAsDefaultProtocolClient: (protocol, path, args) => Effect.sync(() => { if (path === undefined) { - return Electron.app.setAsDefaultProtocolClient(protocol); + return app.setAsDefaultProtocolClient(protocol); } - return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); + return app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); }), setDesktopName: (desktopName) => Effect.sync(() => { - const linuxApp = Electron.app as Electron.App & { + const linuxApp = app as App & { setDesktopName?: (desktopName: string) => void; }; linuxApp.setDesktopName?.(desktopName); }), setDockIcon: (iconPath) => Effect.sync(() => { - Electron.app.dock?.setIcon(iconPath); + app.dock?.setIcon(iconPath); }), appendCommandLineSwitch: (switchName, value) => Effect.sync(() => { if (value === undefined) { - Electron.app.commandLine.appendSwitch(switchName); + app.commandLine.appendSwitch(switchName); return; } - Electron.app.commandLine.appendSwitch(switchName, value); + app.commandLine.appendSwitch(switchName, value); }), on: addScopedAppListener, }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 74e6ae58848..d089872b214 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -3,38 +3,41 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Electron from "electron"; +import { + dialog, + type BrowserWindow, + type MessageBoxOptions, + type MessageBoxReturnValue, + type OpenDialogOptions, +} from "electron"; const CONFIRM_BUTTON_INDEX = 1; export interface ElectronDialogPickFolderInput { - readonly owner: Option.Option; + readonly owner: Option.Option; readonly defaultPath: Option.Option; } export interface ElectronDialogConfirmInput { - readonly owner: Option.Option; + readonly owner: Option.Option; readonly message: string; } -export interface ElectronDialogShape { - readonly pickFolder: ( - input: ElectronDialogPickFolderInput, - ) => Effect.Effect>; - readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; - readonly showMessageBox: ( - options: Electron.MessageBoxOptions, - ) => Effect.Effect; - readonly showErrorBox: (title: string, content: string) => Effect.Effect; -} - -export class ElectronDialog extends Context.Service()( - "@t3tools/desktop/electron/ElectronDialog", -) {} +export class ElectronDialog extends Context.Service< + ElectronDialog, + { + readonly pickFolder: ( + input: ElectronDialogPickFolderInput, + ) => Effect.Effect>; + readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; + readonly showMessageBox: (options: MessageBoxOptions) => Effect.Effect; + readonly showErrorBox: (title: string, content: string) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronDialog") {} -const make = ElectronDialog.of({ +export const make = ElectronDialog.of({ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { - const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { + const openDialogOptions: OpenDialogOptions = Option.match(input.defaultPath, { onNone: () => ({ properties: ["openDirectory", "createDirectory"], }), @@ -44,9 +47,8 @@ const make = ElectronDialog.of({ }), }); const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), - onSome: (owner) => - Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), + onNone: () => Effect.promise(() => dialog.showOpenDialog(openDialogOptions)), + onSome: (owner) => Effect.promise(() => dialog.showOpenDialog(owner, openDialogOptions)), }); if (result.canceled) { @@ -69,15 +71,15 @@ const make = ElectronDialog.of({ 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)), + onNone: () => Effect.promise(() => dialog.showMessageBox(options)), + onSome: (owner) => Effect.promise(() => dialog.showMessageBox(owner, options)), }); return result.response === CONFIRM_BUTTON_INDEX; }), - showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), + showMessageBox: (options) => Effect.promise(() => dialog.showMessageBox(options)), showErrorBox: (title, content) => Effect.sync(() => { - Electron.dialog.showErrorBox(title, content); + dialog.showErrorBox(title, content); }), }); diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 4dd8066e3c6..6fd1eea7009 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import type * as Electron from "electron"; +import type { BrowserWindow, MenuItem, MenuItemConstructorOptions, PopupOptions } from "electron"; import { beforeEach, vi } from "vite-plus/test"; const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted( @@ -35,7 +35,7 @@ describe("ElectronMenu", () => { Effect.gen(function* () { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, + window: {} as BrowserWindow, items: [], position: Option.none(), }); @@ -47,23 +47,21 @@ describe("ElectronMenu", () => { it.effect("resolves with the clicked leaf item id", () => Effect.gen(function* () { - buildFromTemplateMock.mockImplementation( - (template: Electron.MenuItemConstructorOptions[]) => ({ - popup: () => { - const firstItem = template[0]; - assert.isDefined(firstItem); - const click = firstItem.click; - if (!click) { - throw new Error("Expected menu item to have a click handler."); - } - click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); - }, - }), - ); + buildFromTemplateMock.mockImplementation((template: MenuItemConstructorOptions[]) => ({ + popup: () => { + const firstItem = template[0]; + assert.isDefined(firstItem); + const click = firstItem.click; + if (!click) { + throw new Error("Expected menu item to have a click handler."); + } + click({} as MenuItem, {} as BrowserWindow, {} as KeyboardEvent); + }, + })); const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, + window: {} as BrowserWindow, items: [{ id: "copy", label: "Copy" }], position: Option.none(), }); @@ -75,14 +73,14 @@ describe("ElectronMenu", () => { it.effect("resolves with none when the menu closes without a click", () => Effect.gen(function* () { buildFromTemplateMock.mockImplementation(() => ({ - popup: (options: Electron.PopupOptions) => { + popup: (options: PopupOptions) => { options.callback?.(); }, })); const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, + window: {} as BrowserWindow, items: [{ id: "copy", label: "Copy" }], position: Option.some({ x: 10.8, y: 20.2 }), }); @@ -103,7 +101,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const popup = electronMenu.popupTemplate({ - window: {} as Electron.BrowserWindow, + window: {} as BrowserWindow, template: [{ label: "Copy" }], }); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 2ffda3dc507..2cb39ea64ad 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -1,11 +1,18 @@ import type { ContextMenuItem } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; 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 Electron from "electron"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + Menu, + nativeImage, + type BrowserWindow, + type MenuItemConstructorOptions, + type NativeImage, + type PopupOptions, +} from "electron"; export interface ElectronMenuPosition { readonly x: number; @@ -13,29 +20,28 @@ export interface ElectronMenuPosition { } export interface ElectronMenuContextInput { - readonly window: Electron.BrowserWindow; + readonly window: BrowserWindow; readonly items: readonly ContextMenuItem[]; readonly position: Option.Option; } export interface ElectronMenuTemplateInput { - readonly window: Electron.BrowserWindow; - readonly template: readonly Electron.MenuItemConstructorOptions[]; + readonly window: BrowserWindow; + readonly template: readonly MenuItemConstructorOptions[]; } -export interface ElectronMenuShape { - readonly setApplicationMenu: ( - template: readonly Electron.MenuItemConstructorOptions[], - ) => Effect.Effect; - readonly showContextMenu: ( - input: ElectronMenuContextInput, - ) => Effect.Effect>; - readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect; -} - -export class ElectronMenu extends Context.Service()( - "@t3tools/desktop/electron/ElectronMenu", -) {} +export class ElectronMenu extends Context.Service< + ElectronMenu, + { + readonly setApplicationMenu: ( + template: readonly MenuItemConstructorOptions[], + ) => Effect.Effect; + readonly showContextMenu: ( + input: ElectronMenuContextInput, + ) => Effect.Effect>; + readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronMenu") {} function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { const normalizedItems: ContextMenuItem[] = []; @@ -80,114 +86,113 @@ const normalizePosition = ( ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); -export const layer = Layer.effect( - ElectronMenu, - Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - let destructiveMenuIconCache: Option.Option | undefined; +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + let destructiveMenuIconCache: Option.Option | undefined; - const getDestructiveMenuIcon = (): Option.Option => { - if (platform !== "darwin") { - return Option.none(); - } - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache; - } + const getDestructiveMenuIcon = (): Option.Option => { + if (platform !== "darwin") { + return Option.none(); + } + if (destructiveMenuIconCache !== undefined) { + return destructiveMenuIconCache; + } - try { - const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ - width: 12, - height: 12, - }); - icon.setTemplateImage(true); - destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); - } catch { - destructiveMenuIconCache = Option.none(); - } + try { + const icon = nativeImage.createFromNamedImage("trash").resize({ + width: 12, + height: 12, + }); + icon.setTemplateImage(true); + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + destructiveMenuIconCache = Option.none(); + } - return destructiveMenuIconCache; - }; + return destructiveMenuIconCache; + }; - const buildTemplate = ( - entries: readonly ContextMenuItem[], - complete: (selectedItemId: Option.Option) => void, - ): Electron.MenuItemConstructorOptions[] => { - const template: Electron.MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } + const buildTemplate = ( + entries: readonly ContextMenuItem[], + complete: (selectedItemId: Option.Option) => void, + ): MenuItemConstructorOptions[] => { + const template: MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; - const itemOption: Electron.MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children, complete); - } else { - itemOption.click = () => complete(Option.some(item.id)); - } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (Option.isSome(destructiveIcon)) { - itemOption.icon = destructiveIcon.value; - } - } + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } - template.push(itemOption); + const itemOption: MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children, complete); + } else { + itemOption.click = () => complete(Option.some(item.id)); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (Option.isSome(destructiveIcon)) { + itemOption.icon = destructiveIcon.value; + } } - return template; - }; + template.push(itemOption); + } - return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.sync(() => { - Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); - }), - popupTemplate: (input) => - Effect.sync(() => { - if (input.template.length === 0) { - return; - } - Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); - }), - showContextMenu: (input) => - Effect.callback>((resume) => { - const normalizedItems = normalizeContextMenuItems(input.items); - if (normalizedItems.length === 0) { - resume(Effect.succeed(Option.none())); + return template; + }; + + return ElectronMenu.of({ + setApplicationMenu: (template) => + Effect.sync(() => { + Menu.setApplicationMenu(Menu.buildFromTemplate([...template])); + }), + popupTemplate: (input) => + Effect.sync(() => { + if (input.template.length === 0) { + return; + } + Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); + }), + showContextMenu: (input) => + Effect.callback>((resume) => { + const normalizedItems = normalizeContextMenuItems(input.items); + if (normalizedItems.length === 0) { + resume(Effect.succeed(Option.none())); + return; + } + + let completed = false; + const complete = (selectedItemId: Option.Option) => { + if (completed) { return; } + completed = true; + resume(Effect.succeed(selectedItemId)); + }; + + const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const popupPosition = normalizePosition(input.position); + const popupOptions = Option.match(popupPosition, { + onNone: (): PopupOptions => ({ + window: input.window, + callback: () => complete(Option.none()), + }), + onSome: (position): PopupOptions => ({ + window: input.window, + x: position.x, + y: position.y, + callback: () => complete(Option.none()), + }), + }); + menu.popup(popupOptions); + }), + }); +}); - let completed = false; - const complete = (selectedItemId: Option.Option) => { - if (completed) { - return; - } - completed = true; - resume(Effect.succeed(selectedItemId)); - }; - - const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); - const popupOptions = Option.match(popupPosition, { - onNone: (): Electron.PopupOptions => ({ - window: input.window, - callback: () => complete(Option.none()), - }), - onSome: (position): Electron.PopupOptions => ({ - window: input.window, - x: position.x, - y: position.y, - callback: () => complete(Option.none()), - }), - }); - menu.popup(popupOptions); - }), - }); - }), -); +export const layer = Layer.effect(ElectronMenu, make); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 3a3e9f180f7..eb9eee1b95b 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,11 +1,11 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as Electron from "electron"; +import { net, protocol } from "electron"; export const DESKTOP_HOST = "app"; export const DESKTOP_PRODUCTION_SCHEME = "t3code"; @@ -23,14 +23,15 @@ export function getDesktopUrl(isDevelopment: boolean): string { return `${getDesktopOrigin(isDevelopment)}/`; } -export class ElectronProtocolRegistrationError extends Data.TaggedError( +export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( "ElectronProtocolRegistrationError", -)<{ - readonly scheme: string; - readonly cause: unknown; -}> { - override get message() { - return `Failed to register ${this.scheme}: protocol.`; + { + scheme: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to register ${this.scheme}: protocol (${String(this.cause)}).`; } } @@ -41,15 +42,14 @@ export interface DesktopProtocolRegistrationInput { readonly clerkFrontendApiHostname: string | undefined; } -export interface ElectronProtocolShape { - readonly registerDesktopProtocol: ( - input: DesktopProtocolRegistrationInput, - ) => Effect.Effect; -} - -export class ElectronProtocol extends Context.Service()( - "@t3tools/desktop/electron/ElectronProtocol", -) {} +export class ElectronProtocol extends Context.Service< + ElectronProtocol, + { + readonly registerDesktopProtocol: ( + input: DesktopProtocolRegistrationInput, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronProtocol") {} export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInput): string { const clerkOrigin = input.clerkFrontendApiHostname @@ -110,11 +110,11 @@ async function proxyRequest( init.body = request.body; (init as RequestInit & { duplex: "half" }).duplex = "half"; } - const response = await Electron.net.fetch(targetUrl.toString(), init); + const response = await net.fetch(targetUrl.toString(), init); return withContentSecurityPolicy(response, contentSecurityPolicy); } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( @@ -126,7 +126,7 @@ const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ try: () => { - Electron.protocol.handle(input.scheme, (request) => + protocol.handle(input.scheme, (request) => proxyRequest(request, input.targetOrigin, contentSecurityPolicy), ); }, @@ -134,7 +134,7 @@ const make = Effect.gen(function* () { }).pipe(Effect.andThen(Ref.set(registered, true))), () => Effect.sync(() => { - Electron.protocol.unhandle(input.scheme); + protocol.unhandle(input.scheme); }).pipe(Effect.andThen(Ref.set(registered, false))), ); }, diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 85313370547..18ef4c5137f 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,68 +1,63 @@ -import * as Electron from "electron"; - +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; +import * as Schema from "effect/Schema"; + +import { safeStorage } from "electron"; -export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( +export class ElectronSafeStorageAvailabilityError extends Schema.TaggedErrorClass()( "ElectronSafeStorageAvailabilityError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron safe storage failed to check encryption availability."; + { cause: Schema.Defect() }, +) { + override get message(): string { + return `Electron safe storage failed to check encryption availability (${String(this.cause)}).`; } } -export class ElectronSafeStorageEncryptError extends Data.TaggedError( +export class ElectronSafeStorageEncryptError extends Schema.TaggedErrorClass()( "ElectronSafeStorageEncryptError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron safe storage failed to encrypt a string."; + { cause: Schema.Defect() }, +) { + override get message(): string { + return `Electron safe storage failed to encrypt a string (${String(this.cause)}).`; } } -export class ElectronSafeStorageDecryptError extends Data.TaggedError( +export class ElectronSafeStorageDecryptError extends Schema.TaggedErrorClass()( "ElectronSafeStorageDecryptError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron safe storage failed to decrypt a string."; + { cause: Schema.Defect() }, +) { + override get message(): string { + return `Electron safe storage failed to decrypt a string (${String(this.cause)}).`; } } -export interface ElectronSafeStorageShape { - readonly isEncryptionAvailable: Effect.Effect; - readonly encryptString: ( - value: string, - ) => Effect.Effect; - readonly decryptString: ( - value: Uint8Array, - ) => Effect.Effect; -} - export class ElectronSafeStorage extends Context.Service< ElectronSafeStorage, - ElectronSafeStorageShape + { + readonly isEncryptionAvailable: Effect.Effect; + readonly encryptString: ( + value: string, + ) => Effect.Effect; + readonly decryptString: ( + value: Uint8Array, + ) => Effect.Effect; + } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -const make = ElectronSafeStorage.of({ +export const make = ElectronSafeStorage.of({ isEncryptionAvailable: Effect.try({ - try: () => Electron.safeStorage.isEncryptionAvailable(), + try: () => safeStorage.isEncryptionAvailable(), catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), encryptString: (value) => Effect.try({ - try: () => Electron.safeStorage.encryptString(value), + try: () => safeStorage.encryptString(value), catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), }), decryptString: (value) => Effect.try({ - try: () => Electron.safeStorage.decryptString(Buffer.from(value)), + try: () => safeStorage.decryptString(Buffer.from(value)), catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), }), }); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 0ecce3bf70e..22381e79471 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Electron from "electron"; +import { clipboard, shell } from "electron"; const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); @@ -20,22 +20,21 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { } } -export interface ElectronShellShape { - readonly openExternal: (rawUrl: unknown) => Effect.Effect; - readonly copyText: (text: string) => Effect.Effect; -} - -export class ElectronShell extends Context.Service()( - "@t3tools/desktop/electron/ElectronShell", -) {} +export class ElectronShell extends Context.Service< + ElectronShell, + { + readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly copyText: (text: string) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronShell") {} -const make = ElectronShell.of({ +export const make = ElectronShell.of({ openExternal: (rawUrl) => Option.match(parseSafeExternalUrl(rawUrl), { onNone: () => Effect.succeed(false), onSome: (externalUrl) => Effect.promise(() => - Electron.shell.openExternal(externalUrl).then( + shell.openExternal(externalUrl).then( () => true, () => false, ), @@ -43,7 +42,7 @@ const make = ElectronShell.of({ }), copyText: (text) => Effect.sync(() => { - Electron.clipboard.writeText(text); + clipboard.writeText(text); }), }); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 1e23d228504..50d9de4f4d3 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -4,34 +4,33 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; -import * as Electron from "electron"; +import { nativeTheme } from "electron"; -export interface ElectronThemeShape { - readonly shouldUseDarkColors: Effect.Effect; - readonly setSource: (theme: DesktopTheme) => Effect.Effect; - readonly onUpdated: (listener: () => void) => Effect.Effect; -} +export class ElectronTheme extends Context.Service< + ElectronTheme, + { + readonly shouldUseDarkColors: Effect.Effect; + readonly setSource: (theme: DesktopTheme) => Effect.Effect; + readonly onUpdated: (listener: () => void) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronTheme") {} -export class ElectronTheme extends Context.Service()( - "@t3tools/desktop/electron/ElectronTheme", -) {} - -const make = ElectronTheme.of({ - shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), +export const make = ElectronTheme.of({ + shouldUseDarkColors: Effect.sync(() => nativeTheme.shouldUseDarkColors), setSource: (theme) => Effect.suspend(() => { - Electron.nativeTheme.themeSource = theme; + nativeTheme.themeSource = theme; return Effect.void; }), onUpdated: (listener) => Effect.acquireRelease( Effect.suspend(() => { - Electron.nativeTheme.on("updated", listener); + nativeTheme.on("updated", listener); return Effect.void; }), () => Effect.suspend(() => { - Electron.nativeTheme.removeListener("updated", listener); + nativeTheme.removeListener("updated", listener); return Effect.void; }), ), diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 7f3edf02aa8..8fd528e7f44 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -1,7 +1,7 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { autoUpdater } from "electron-updater"; @@ -10,33 +10,30 @@ type AutoUpdater = typeof autoUpdater; export type ElectronUpdaterFeedUrl = Parameters[0]; -export class ElectronUpdaterCheckForUpdatesError extends Data.TaggedError( +export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( "ElectronUpdaterCheckForUpdatesError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron updater failed to check for updates."; + { cause: Schema.Defect() }, +) { + override get message(): string { + return `Electron updater failed to check for updates (${String(this.cause)}).`; } } -export class ElectronUpdaterDownloadUpdateError extends Data.TaggedError( +export class ElectronUpdaterDownloadUpdateError extends Schema.TaggedErrorClass()( "ElectronUpdaterDownloadUpdateError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron updater failed to download the update."; + { cause: Schema.Defect() }, +) { + override get message(): string { + return `Electron updater failed to download the update (${String(this.cause)}).`; } } -export class ElectronUpdaterQuitAndInstallError extends Data.TaggedError( +export class ElectronUpdaterQuitAndInstallError extends Schema.TaggedErrorClass()( "ElectronUpdaterQuitAndInstallError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron updater failed to quit and install the update."; + { cause: Schema.Defect() }, +) { + override get message(): string { + return `Electron updater failed to quit and install the update (${String(this.cause)}).`; } } @@ -45,32 +42,31 @@ export type ElectronUpdaterError = | ElectronUpdaterDownloadUpdateError | ElectronUpdaterQuitAndInstallError; -export interface ElectronUpdaterShape { - readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; - readonly setAutoDownload: (value: boolean) => Effect.Effect; - readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; - readonly setChannel: (channel: string) => Effect.Effect; - readonly setAllowPrerelease: (value: boolean) => Effect.Effect; - readonly allowDowngrade: Effect.Effect; - readonly setAllowDowngrade: (value: boolean) => Effect.Effect; - readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; - readonly checkForUpdates: Effect.Effect; - readonly downloadUpdate: Effect.Effect; - readonly quitAndInstall: (options: { - readonly isSilent: boolean; - readonly isForceRunAfter: boolean; - }) => Effect.Effect; - readonly on: >( - eventName: string, - listener: (...args: Args) => void, - ) => Effect.Effect; -} - -export class ElectronUpdater extends Context.Service()( - "@t3tools/desktop/electron/ElectronUpdater", -) {} +export class ElectronUpdater extends Context.Service< + ElectronUpdater, + { + readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; + readonly setAutoDownload: (value: boolean) => Effect.Effect; + readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; + readonly setChannel: (channel: string) => Effect.Effect; + readonly setAllowPrerelease: (value: boolean) => Effect.Effect; + readonly allowDowngrade: Effect.Effect; + readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; + readonly checkForUpdates: Effect.Effect; + readonly downloadUpdate: Effect.Effect; + readonly quitAndInstall: (options: { + readonly isSilent: boolean; + readonly isForceRunAfter: boolean; + }) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronUpdater") {} -export const layer = Layer.succeed(ElectronUpdater, { +export const make = ElectronUpdater.of({ setFeedURL: (options) => Effect.suspend(() => { autoUpdater.setFeedURL(options); @@ -136,4 +132,6 @@ export const layer = Layer.succeed(ElectronUpdater, { }), ).pipe(Effect.asVoid); }, -} satisfies ElectronUpdaterShape); +}); + +export const layer = Layer.succeed(ElectronUpdater, make); diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index cc6c6484245..731a5827182 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import type * as Electron from "electron"; +import type { BrowserWindow } from "electron"; import { beforeEach, vi } from "vite-plus/test"; const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({ @@ -22,7 +22,7 @@ import * as ElectronWindow from "./ElectronWindow.ts"; function makeBrowserWindow(input: { readonly destroyed: boolean }) { return { isDestroyed: vi.fn(() => input.destroyed), - } as unknown as Electron.BrowserWindow; + } as unknown as BrowserWindow; } describe("ElectronWindow", () => { @@ -37,7 +37,7 @@ describe("ElectronWindow", () => { const destroyedWindow = makeBrowserWindow({ destroyed: true }); getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]); - const syncedWindows: Electron.BrowserWindow[] = []; + const syncedWindows: BrowserWindow[] = []; const electronWindow = yield* ElectronWindow.ElectronWindow; yield* electronWindow.syncAllAppearance((window) => Effect.sync(() => { diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 35c1fbc5faa..103e5466770 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -1,45 +1,45 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; -import * as Electron from "electron"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { app, BrowserWindow, type BrowserWindowConstructorOptions } from "electron"; -export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ - readonly cause: unknown; -}> { - override get message() { - return "Failed to create Electron BrowserWindow."; +export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( + "ElectronWindowCreateError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return `Failed to create Electron BrowserWindow (${String(this.cause)}).`; } } -export interface ElectronWindowShape { - readonly create: ( - options: Electron.BrowserWindowConstructorOptions, - ) => Effect.Effect; - readonly main: Effect.Effect>; - readonly currentMainOrFirst: Effect.Effect>; - readonly focusedMainOrFirst: Effect.Effect>; - readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; - readonly clearMain: (window: Option.Option) => Effect.Effect; - readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; - readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; - readonly destroyAll: Effect.Effect; - readonly syncAllAppearance: ( - sync: (window: Electron.BrowserWindow) => Effect.Effect, - ) => Effect.Effect; -} - -export class ElectronWindow extends Context.Service()( - "@t3tools/desktop/electron/ElectronWindow", -) {} +export class ElectronWindow extends Context.Service< + ElectronWindow, + { + readonly create: ( + options: BrowserWindowConstructorOptions, + ) => Effect.Effect; + readonly main: Effect.Effect>; + readonly currentMainOrFirst: Effect.Effect>; + readonly focusedMainOrFirst: Effect.Effect>; + readonly setMain: (window: BrowserWindow) => Effect.Effect; + readonly clearMain: (window: Option.Option) => Effect.Effect; + readonly reveal: (window: BrowserWindow) => Effect.Effect; + readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; + readonly destroyAll: Effect.Effect; + readonly syncAllAppearance: ( + sync: (window: BrowserWindow) => Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronWindow") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; - const mainWindowRef = yield* Ref.make>(Option.none()); + const mainWindowRef = yield* Ref.make>(Option.none()); const liveMain = Ref.get(mainWindowRef).pipe( Effect.map(Option.filter((value) => !value.isDestroyed())), @@ -51,13 +51,13 @@ const make = Effect.gen(function* () { return main; } - return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe( + return Option.fromNullishOr(BrowserWindow.getAllWindows()[0] ?? null).pipe( Option.filter((window) => !window.isDestroyed()), ); }); const focusedMainOrFirst = Effect.sync(() => - Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe( + Option.fromNullishOr(BrowserWindow.getFocusedWindow() ?? null).pipe( Option.filter((window) => !window.isDestroyed()), ), ).pipe( @@ -69,7 +69,7 @@ const make = Effect.gen(function* () { return ElectronWindow.of({ create: (options) => Effect.try({ - try: () => new Electron.BrowserWindow(options), + try: () => new BrowserWindow(options), catch: (cause) => new ElectronWindowCreateError({ cause }), }), main: liveMain, @@ -101,14 +101,14 @@ const make = Effect.gen(function* () { } if (platform === "darwin") { - Electron.app.focus({ steal: true }); + app.focus({ steal: true }); } window.focus(); }), sendAll: (channel, ...args) => Effect.sync(() => { - for (const window of Electron.BrowserWindow.getAllWindows()) { + for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed()) { continue; } @@ -116,14 +116,14 @@ const make = Effect.gen(function* () { } }), destroyAll: Effect.sync(() => { - for (const window of Electron.BrowserWindow.getAllWindows()) { + for (const window of BrowserWindow.getAllWindows()) { window.destroy(); } }), syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* ( - sync: (window: Electron.BrowserWindow) => Effect.Effect, + sync: (window: BrowserWindow) => Effect.Effect, ) { - const windows = Electron.BrowserWindow.getAllWindows(); + const windows = BrowserWindow.getAllWindows(); for (const window of windows) { if (window.isDestroyed()) { continue; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a6ffd9cdab1..a54f23aa6da 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,12 +1,12 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeOS from "node:os"; +import { homedir } from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Electron from "electron"; +import { ipcMain } from "electron"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -19,7 +19,7 @@ import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; -import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; +import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; @@ -60,7 +60,7 @@ const desktopEnvironmentLayer = Layer.unwrap( const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, - homeDirectory: NodeOS.homedir(), + homeDirectory: homedir(), platform, processArch, ...metadata, @@ -106,12 +106,12 @@ const electronLayer = Layer.mergeAll( ElectronDialog.layer, ElectronMenu.layer, ElectronProtocol.layer, - DesktopSecretStorage.layer, + ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, ElectronUpdater.layer, ElectronWindow.layer, - DesktopIpc.layer(Electron.ipcMain), + DesktopIpc.layer(ipcMain), ); const desktopFoundationLayer = Layer.mergeAll( diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index 080a2fe465d..a891720a32b 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -6,7 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; -import type * as Electron from "electron"; +import type { BrowserWindow } from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; @@ -71,9 +71,9 @@ function makeElectronWindowLayer(window: ReturnType["wind ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ create: () => Effect.die("unexpected BrowserWindow creation"), - main: Effect.succeed(Option.some(window as Electron.BrowserWindow)), - currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), - focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + main: Effect.succeed(Option.some(window as BrowserWindow)), + currentMainOrFirst: Effect.succeed(Option.some(window as BrowserWindow)), + focusedMainOrFirst: Effect.succeed(Option.some(window as BrowserWindow)), setMain: () => Effect.void, clearMain: () => Effect.void, reveal: () => Effect.void, diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 34d18f11a77..ad234df0bb5 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -83,7 +83,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); }), ).pipe(Effect.asVoid), - } satisfies ElectronUpdater.ElectronUpdaterShape); + } satisfies ElectronUpdater.ElectronUpdater["Service"]); const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Effect.die("unexpected BrowserWindow creation"), @@ -99,7 +99,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { }), destroyAll: Effect.void, syncAllAppearance: () => Effect.void, - } satisfies ElectronWindow.ElectronWindowShape); + } satisfies ElectronWindow.ElectronWindow["Service"]); const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { start: Effect.void, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f3444c629f7..bbd427e0421 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import type * as Electron from "electron"; +import type { BrowserWindow, MenuItem, MenuItemConstructorOptions } from "electron"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; @@ -46,14 +46,14 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, on: () => Effect.void, -} satisfies ElectronApp.ElectronAppShape); +} satisfies ElectronApp.ElectronApp["Service"]); const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, -} satisfies ElectronDialog.ElectronDialogShape); +} satisfies ElectronDialog.ElectronDialog["Service"]); const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { getState: Effect.die("unexpected getState"), @@ -79,21 +79,20 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => } satisfies DesktopWindow.DesktopWindow["Service"]); const makeElectronMenuLayer = ( - applicationMenuTemplate: Deferred.Deferred, + applicationMenuTemplate: Deferred.Deferred, ) => Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: (template) => Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid), popupTemplate: () => Effect.void, showContextMenu: () => Effect.succeed(Option.none()), - } satisfies ElectronMenu.ElectronMenuShape); + } satisfies ElectronMenu.ElectronMenu["Service"]); describe("DesktopApplicationMenu", () => { it.effect("installs the native menu and routes Settings through DesktopWindow", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); - const applicationMenuTemplate = - yield* Deferred.make(); + const applicationMenuTemplate = yield* Deferred.make(); yield* Effect.gen(function* () { const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; @@ -128,7 +127,7 @@ describe("DesktopApplicationMenu", () => { throw new Error("Expected Settings menu item to have a click handler."); } - settingsClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + settingsClick({} as MenuItem, {} as BrowserWindow, {} as KeyboardEvent); assert.equal(yield* Deferred.await(selectedAction), "open-settings"); }), ); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 04b9c833e44..3216cf66700 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import type * as Electron from "electron"; +import type { MenuItemConstructorOptions } from "electron"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -125,7 +125,7 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; - const template: Electron.MenuItemConstructorOptions[] = []; + const template: MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { template.push({ diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 76413dd0b55..ec64651f288 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import type * as Electron from "electron"; +import type { BrowserWindow, BrowserWindowConstructorOptions, Session } from "electron"; import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ @@ -76,7 +76,7 @@ function makeFakeBrowserWindow() { }; return { - window: window as unknown as Electron.BrowserWindow, + window: window as unknown as BrowserWindow, loadURL: window.loadURL, openDevTools: webContents.openDevTools, setAutoHideCursor: window.setAutoHideCursor, @@ -133,10 +133,10 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( ); function makeTestLayer(input: { - readonly window: Electron.BrowserWindow; + readonly window: BrowserWindow; readonly createCount: Ref.Ref; - readonly mainWindow: Ref.Ref>; - readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; + readonly mainWindow: Ref.Ref>; + readonly createdWindowOptions?: BrowserWindowConstructorOptions[]; readonly openedExternalUrls?: unknown[]; }) { const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { @@ -177,7 +177,7 @@ function makeTestLayer(input: { electronThemeLayer, electronWindowLayer, Layer.mock(PreviewManager.PreviewManager)({ - getBrowserSession: () => Effect.succeed({} as Electron.Session), + getBrowserSession: () => Effect.succeed({} as Session), setMainWindow: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), @@ -213,8 +213,8 @@ describe("DesktopWindow", () => { Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: BrowserWindowConstructorOptions[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, @@ -241,7 +241,7 @@ describe("DesktopWindow", () => { Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); + const mainWindow = yield* Ref.make>(Option.none()); const openedExternalUrls: unknown[] = []; const layer = makeTestLayer({ window: fakeWindow.window, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 1822bb0c98e..f15636ca6a2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -4,19 +4,23 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import type * as Electron from "electron"; +import type { + BrowserWindow, + BrowserWindowConstructorOptions, + MenuItemConstructorOptions, +} from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; -import * as PreviewManager from "../preview/Manager.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; -import * as IpcChannels from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import * as PreviewManager from "../preview/Manager.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -24,7 +28,7 @@ const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; type WindowTitleBarOptions = Pick< - Electron.BrowserWindowConstructorOptions, + BrowserWindowConstructorOptions, "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" >; @@ -45,9 +49,9 @@ export type DesktopWindowError = export class DesktopWindow extends Context.Service< DesktopWindow, { - readonly createMain: Effect.Effect; - readonly ensureMain: Effect.Effect; - readonly revealOrCreateMain: Effect.Effect; + readonly createMain: Effect.Effect; + readonly ensureMain: Effect.Effect; + readonly revealOrCreateMain: Effect.Effect; readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; readonly handleBackendReady: Effect.Effect; @@ -108,7 +112,7 @@ function getWindowTitleBarOptions( } function syncWindowAppearance( - window: Electron.BrowserWindow, + window: BrowserWindow, shouldUseDarkColors: boolean, platform: NodeJS.Platform, ): Effect.Effect { @@ -155,11 +159,11 @@ export const make = Effect.gen(function* () { const runPromise = Effect.runPromiseWith(context); const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< - Electron.BrowserWindow, + BrowserWindow, DesktopWindowError > { yield* previewManager.getBrowserSession(); - const applicationUrl = getDesktopUrl(environment.isDevelopment); + const applicationUrl = ElectronProtocol.getDesktopUrl(environment.isDevelopment); const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; @@ -206,7 +210,7 @@ export const make = Effect.gen(function* () { window.webContents.on("context-menu", (event, params) => { event.preventDefault(); - const menuTemplate: Electron.MenuItemConstructorOptions[] = []; + const menuTemplate: MenuItemConstructorOptions[] = []; if (params.misspelledWord) { for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { @@ -380,7 +384,7 @@ export const make = Effect.gen(function* () { const send = () => { if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action); + targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); void runPromise(electronWindow.reveal(targetWindow)); }; From 4f7f16fd21bbbe3289de49518603aee3bd045042 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:14:26 -0700 Subject: [PATCH 2/8] Normalize remaining desktop service imports Co-authored-by: codex --- apps/desktop/src/app/DesktopLifecycle.ts | 6 +++--- apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts | 4 ++-- apps/desktop/src/window/DesktopApplicationMenu.ts | 6 +++--- apps/desktop/src/window/DesktopWindow.ts | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 1a19243fa4e..10e29ca2772 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -8,7 +8,7 @@ import * as Scope from "effect/Scope"; import type { Event } from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopObservability from "./DesktopObservability.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -37,7 +37,7 @@ export class DesktopLifecycle extends Context.Service< >()("@t3tools/desktop/app/DesktopLifecycle") {} const { logInfo: logLifecycleInfo, logError: logLifecycleError } = - DesktopObservability.makeComponentLogger("desktop-lifecycle"); + makeComponentLogger("desktop-lifecycle"); function addScopedListener>( target: unknown, @@ -122,7 +122,7 @@ function quitFromSignal( ); } -const make = DesktopLifecycle.of({ +export const make = DesktopLifecycle.of({ relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment.DesktopEnvironment; diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index a891720a32b..bb42489c654 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -9,7 +9,7 @@ import * as TestClock from "effect/testing/TestClock"; import type { BrowserWindow } from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as IpcChannels from "../ipc/channels.ts"; +import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; interface SentMessage { @@ -111,7 +111,7 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(testWindow.sentMessages.length, 1); const sent = testWindow.sentMessages[0]; assert.ok(sent); - assert.equal(sent.channel, IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL); + assert.equal(sent.channel, SSH_PASSWORD_PROMPT_CHANNEL); const request = sent.args[0] as { readonly requestId: string; readonly destination: string }; assert.equal(request.destination, "devbox"); assert.equal(testWindow.isRestored(), true); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 3216cf66700..a593d94963f 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import type { MenuItemConstructorOptions } from "electron"; -import * as DesktopObservability from "../app/DesktopObservability.ts"; +import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; @@ -26,9 +26,9 @@ type DesktopApplicationMenuRuntimeServices = | DesktopWindow.DesktopWindow | ElectronDialog.ElectronDialog; -const { logInfo: logUpdaterInfo } = DesktopObservability.makeComponentLogger("desktop-updater"); +const { logInfo: logUpdaterInfo } = makeComponentLogger("desktop-updater"); -const { logError: logMenuError } = DesktopObservability.makeComponentLogger("desktop-menu"); +const { logError: logMenuError } = makeComponentLogger("desktop-menu"); const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* ( action: string, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index f15636ca6a2..7769d11e629 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -12,10 +12,10 @@ import type { import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopObservability from "../app/DesktopObservability.ts"; +import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; -import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; @@ -61,7 +61,7 @@ export class DesktopWindow extends Context.Service< >()("@t3tools/desktop/window/DesktopWindow") {} const { logInfo: logWindowInfo, logWarning: logWindowWarning } = - DesktopObservability.makeComponentLogger("desktop-window"); + makeComponentLogger("desktop-window"); function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, @@ -163,7 +163,7 @@ export const make = Effect.gen(function* () { DesktopWindowError > { yield* previewManager.getBrowserSession(); - const applicationUrl = ElectronProtocol.getDesktopUrl(environment.isDevelopment); + const applicationUrl = getDesktopUrl(environment.isDevelopment); const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; From a06baa086082714f6265364f72e3244294541a1e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:57:16 -0700 Subject: [PATCH 3/8] Preserve desktop service error causes Co-authored-by: codex --- .../app/DesktopConnectionCatalogStore.test.ts | 1 + apps/desktop/src/electron/ElectronProtocol.ts | 2 +- .../src/electron/ElectronSafeStorage.ts | 33 ++++++++++++++----- .../src/electron/ElectronUpdater.test.ts | 2 ++ apps/desktop/src/electron/ElectronUpdater.ts | 33 ++++++++++++++----- apps/desktop/src/electron/ElectronWindow.ts | 10 ++++-- .../settings/DesktopSavedEnvironments.test.ts | 4 +++ 7 files changed, 63 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 26c0c8f8943..5433b8f863e 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -33,6 +33,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref (failDecrypt !== null && (yield* Ref.get(failDecrypt))) ) { return yield* new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + operation: "decrypt a string", cause: new Error("invalid encrypted catalog"), }); } diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index eb9eee1b95b..ed89662d0c8 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -31,7 +31,7 @@ export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( "ElectronSafeStorageAvailabilityError", - { cause: Schema.Defect() }, + { + operation: Schema.Literal("check encryption availability"), + cause: Schema.Defect(), + }, ) { override get message(): string { - return `Electron safe storage failed to check encryption availability (${String(this.cause)}).`; + return `Electron safe storage failed to ${this.operation}.`; } } export class ElectronSafeStorageEncryptError extends Schema.TaggedErrorClass()( "ElectronSafeStorageEncryptError", - { cause: Schema.Defect() }, + { + operation: Schema.Literal("encrypt a string"), + cause: Schema.Defect(), + }, ) { override get message(): string { - return `Electron safe storage failed to encrypt a string (${String(this.cause)}).`; + return `Electron safe storage failed to ${this.operation}.`; } } export class ElectronSafeStorageDecryptError extends Schema.TaggedErrorClass()( "ElectronSafeStorageDecryptError", - { cause: Schema.Defect() }, + { + operation: Schema.Literal("decrypt a string"), + cause: Schema.Defect(), + }, ) { override get message(): string { - return `Electron safe storage failed to decrypt a string (${String(this.cause)}).`; + return `Electron safe storage failed to ${this.operation}.`; } } @@ -48,17 +57,23 @@ export class ElectronSafeStorage extends Context.Service< export const make = ElectronSafeStorage.of({ isEncryptionAvailable: Effect.try({ try: () => safeStorage.isEncryptionAvailable(), - catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), + catch: (cause) => + new ElectronSafeStorageAvailabilityError({ + operation: "check encryption availability", + cause, + }), }), encryptString: (value) => Effect.try({ try: () => safeStorage.encryptString(value), - catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), + catch: (cause) => + new ElectronSafeStorageEncryptError({ operation: "encrypt a string", cause }), }), decryptString: (value) => Effect.try({ try: () => safeStorage.decryptString(Buffer.from(value)), - catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + catch: (cause) => + new ElectronSafeStorageDecryptError({ operation: "decrypt a string", cause }), }), }); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index d2d3edd3696..8e605f0a8e4 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -72,7 +72,9 @@ describe("ElectronUpdater", () => { if (exit._tag === "Failure") { const error = Cause.squash(exit.cause); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); + assert.equal(error.operation, "check for updates"); assert.equal(error.cause, cause); + assert.equal(error.message, "Electron updater failed to check for updates."); } }).pipe(Effect.provide(ElectronUpdater.layer)), ); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 8fd528e7f44..f8c06db05d1 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -12,28 +12,37 @@ export type ElectronUpdaterFeedUrl = Parameters[0]; export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( "ElectronUpdaterCheckForUpdatesError", - { cause: Schema.Defect() }, + { + operation: Schema.Literal("check for updates"), + cause: Schema.Defect(), + }, ) { override get message(): string { - return `Electron updater failed to check for updates (${String(this.cause)}).`; + return `Electron updater failed to ${this.operation}.`; } } export class ElectronUpdaterDownloadUpdateError extends Schema.TaggedErrorClass()( "ElectronUpdaterDownloadUpdateError", - { cause: Schema.Defect() }, + { + operation: Schema.Literal("download the update"), + cause: Schema.Defect(), + }, ) { override get message(): string { - return `Electron updater failed to download the update (${String(this.cause)}).`; + return `Electron updater failed to ${this.operation}.`; } } export class ElectronUpdaterQuitAndInstallError extends Schema.TaggedErrorClass()( "ElectronUpdaterQuitAndInstallError", - { cause: Schema.Defect() }, + { + operation: Schema.Literal("quit and install the update"), + cause: Schema.Defect(), + }, ) { override get message(): string { - return `Electron updater failed to quit and install the update (${String(this.cause)}).`; + return `Electron updater failed to ${this.operation}.`; } } @@ -105,16 +114,22 @@ export const make = ElectronUpdater.of({ }), checkForUpdates: Effect.tryPromise({ try: () => autoUpdater.checkForUpdates(), - catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ cause }), + catch: (cause) => + new ElectronUpdaterCheckForUpdatesError({ operation: "check for updates", cause }), }).pipe(Effect.asVoid), downloadUpdate: Effect.tryPromise({ try: () => autoUpdater.downloadUpdate(), - catch: (cause) => new ElectronUpdaterDownloadUpdateError({ cause }), + catch: (cause) => + new ElectronUpdaterDownloadUpdateError({ operation: "download the update", cause }), }).pipe(Effect.asVoid), quitAndInstall: ({ isSilent, isForceRunAfter }) => Effect.try({ try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), - catch: (cause) => new ElectronUpdaterQuitAndInstallError({ cause }), + catch: (cause) => + new ElectronUpdaterQuitAndInstallError({ + operation: "quit and install the update", + cause, + }), }), on: (eventName, listener) => { const eventTarget = autoUpdater as unknown as { diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 103e5466770..d8d8abb0d6b 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -10,10 +10,13 @@ import { app, BrowserWindow, type BrowserWindowConstructorOptions } from "electr export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowCreateError", - { cause: Schema.Defect() }, + { + resource: Schema.Literal("Electron BrowserWindow"), + cause: Schema.Defect(), + }, ) { override get message(): string { - return `Failed to create Electron BrowserWindow (${String(this.cause)}).`; + return `Failed to create ${this.resource}.`; } } @@ -70,7 +73,8 @@ export const make = Effect.gen(function* () { create: (options) => Effect.try({ try: () => new BrowserWindow(options), - catch: (cause) => new ElectronWindowCreateError({ cause }), + catch: (cause) => + new ElectronWindowCreateError({ resource: "Electron BrowserWindow", cause }), }), main: liveMain, currentMainOrFirst, diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 4e3c8d8ba1d..fe337272995 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -56,6 +56,7 @@ function makeSafeStorageLayer(input: { ? Effect.succeed(input.available) : Effect.fail( new ElectronSafeStorage.ElectronSafeStorageAvailabilityError({ + operation: "check encryption availability", cause: input.availabilityError, }), ), @@ -64,6 +65,7 @@ function makeSafeStorageLayer(input: { ? Effect.succeed(textEncoder.encode(`enc:${value}`)) : Effect.fail( new ElectronSafeStorage.ElectronSafeStorageEncryptError({ + operation: "encrypt a string", cause: input.encryptError, }), ), @@ -71,6 +73,7 @@ function makeSafeStorageLayer(input: { if (input.decryptError !== undefined) { return Effect.fail( new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + operation: "decrypt a string", cause: input.decryptError, }), ); @@ -80,6 +83,7 @@ function makeSafeStorageLayer(input: { if (!decoded.startsWith("enc:")) { return Effect.fail( new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + operation: "decrypt a string", cause: new Error("invalid secret"), }), ); From 575578255ce8234fb7faaad799e6212590d793ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 23:36:25 -0700 Subject: [PATCH 4/8] Remove redundant Electron error discriminators Co-authored-by: codex --- .../app/DesktopConnectionCatalogStore.test.ts | 5 +- .../src/app/DesktopConnectionCatalogStore.ts | 7 +-- .../src/electron/ElectronSafeStorage.ts | 55 +++++------------- .../src/electron/ElectronUpdater.test.ts | 2 +- apps/desktop/src/electron/ElectronUpdater.ts | 56 +++++-------------- apps/desktop/src/electron/ElectronWindow.ts | 2 +- .../settings/DesktopSavedEnvironments.test.ts | 11 ++-- .../src/settings/DesktopSavedEnvironments.ts | 6 +- .../src/updates/DesktopUpdates.test.ts | 5 +- 9 files changed, 44 insertions(+), 105 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 5433b8f863e..3c137147237 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -32,7 +32,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref !decoded.startsWith("encrypted:") || (failDecrypt !== null && (yield* Ref.get(failDecrypt))) ) { - return yield* new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + return yield* new ElectronSafeStorage.ElectronSafeStorageError({ operation: "decrypt a string", cause: new Error("invalid encrypted catalog"), }); @@ -397,7 +397,8 @@ describe("DesktopConnectionCatalogStore", () => { assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}')); yield* Ref.set(failDecrypt, true); const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageDecryptError); + assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageError); + assert.equal(error.operation, "decrypt a string"); yield* Ref.set(failDecrypt, false); assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 7eaf3ec7cf6..f743a0ac19f 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -167,16 +167,13 @@ export class DesktopConnectionCatalogStore extends Context.Service< | DesktopConnectionCatalogStoreDocumentDecodeError | DesktopConnectionCatalogStoreDecodeError | DesktopConnectionCatalogStoreMigrationError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError + | ElectronSafeStorage.ElectronSafeStorageError >; readonly set: ( catalog: string, ) => Effect.Effect< boolean, - | DesktopConnectionCatalogStoreWriteError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageEncryptError + DesktopConnectionCatalogStoreWriteError | ElectronSafeStorage.ElectronSafeStorageError >; readonly clear: Effect.Effect; } diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index dfed1563654..716f14adada 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -5,34 +5,16 @@ import * as Schema from "effect/Schema"; import { safeStorage } from "electron"; -export class ElectronSafeStorageAvailabilityError extends Schema.TaggedErrorClass()( - "ElectronSafeStorageAvailabilityError", - { - operation: Schema.Literal("check encryption availability"), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Electron safe storage failed to ${this.operation}.`; - } -} - -export class ElectronSafeStorageEncryptError extends Schema.TaggedErrorClass()( - "ElectronSafeStorageEncryptError", - { - operation: Schema.Literal("encrypt a string"), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Electron safe storage failed to ${this.operation}.`; - } -} +const ElectronSafeStorageOperation = Schema.Literals([ + "check encryption availability", + "encrypt a string", + "decrypt a string", +]); -export class ElectronSafeStorageDecryptError extends Schema.TaggedErrorClass()( - "ElectronSafeStorageDecryptError", +export class ElectronSafeStorageError extends Schema.TaggedErrorClass()( + "ElectronSafeStorageError", { - operation: Schema.Literal("decrypt a string"), + operation: ElectronSafeStorageOperation, cause: Schema.Defect(), }, ) { @@ -44,13 +26,9 @@ export class ElectronSafeStorageDecryptError extends Schema.TaggedErrorClass; - readonly encryptString: ( - value: string, - ) => Effect.Effect; - readonly decryptString: ( - value: Uint8Array, - ) => Effect.Effect; + readonly isEncryptionAvailable: Effect.Effect; + readonly encryptString: (value: string) => Effect.Effect; + readonly decryptString: (value: Uint8Array) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} @@ -58,22 +36,17 @@ export const make = ElectronSafeStorage.of({ isEncryptionAvailable: Effect.try({ try: () => safeStorage.isEncryptionAvailable(), catch: (cause) => - new ElectronSafeStorageAvailabilityError({ - operation: "check encryption availability", - cause, - }), + new ElectronSafeStorageError({ operation: "check encryption availability", cause }), }), encryptString: (value) => Effect.try({ try: () => safeStorage.encryptString(value), - catch: (cause) => - new ElectronSafeStorageEncryptError({ operation: "encrypt a string", cause }), + catch: (cause) => new ElectronSafeStorageError({ operation: "encrypt a string", cause }), }), decryptString: (value) => Effect.try({ try: () => safeStorage.decryptString(Buffer.from(value)), - catch: (cause) => - new ElectronSafeStorageDecryptError({ operation: "decrypt a string", cause }), + catch: (cause) => new ElectronSafeStorageError({ operation: "decrypt a string", cause }), }), }); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 8e605f0a8e4..dc8d8b30377 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -71,7 +71,7 @@ describe("ElectronUpdater", () => { assert.equal(exit._tag, "Failure"); if (exit._tag === "Failure") { const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterError); assert.equal(error.operation, "check for updates"); assert.equal(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates."); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index f8c06db05d1..11af855a16a 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -10,22 +10,16 @@ type AutoUpdater = typeof autoUpdater; export type ElectronUpdaterFeedUrl = Parameters[0]; -export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( - "ElectronUpdaterCheckForUpdatesError", - { - operation: Schema.Literal("check for updates"), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Electron updater failed to ${this.operation}.`; - } -} +const ElectronUpdaterOperation = Schema.Literals([ + "check for updates", + "download the update", + "quit and install the update", +]); -export class ElectronUpdaterDownloadUpdateError extends Schema.TaggedErrorClass()( - "ElectronUpdaterDownloadUpdateError", +export class ElectronUpdaterError extends Schema.TaggedErrorClass()( + "ElectronUpdaterError", { - operation: Schema.Literal("download the update"), + operation: ElectronUpdaterOperation, cause: Schema.Defect(), }, ) { @@ -34,23 +28,6 @@ export class ElectronUpdaterDownloadUpdateError extends Schema.TaggedErrorClass< } } -export class ElectronUpdaterQuitAndInstallError extends Schema.TaggedErrorClass()( - "ElectronUpdaterQuitAndInstallError", - { - operation: Schema.Literal("quit and install the update"), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Electron updater failed to ${this.operation}.`; - } -} - -export type ElectronUpdaterError = - | ElectronUpdaterCheckForUpdatesError - | ElectronUpdaterDownloadUpdateError - | ElectronUpdaterQuitAndInstallError; - export class ElectronUpdater extends Context.Service< ElectronUpdater, { @@ -62,12 +39,12 @@ export class ElectronUpdater extends Context.Service< readonly allowDowngrade: Effect.Effect; readonly setAllowDowngrade: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; - readonly checkForUpdates: Effect.Effect; - readonly downloadUpdate: Effect.Effect; + readonly checkForUpdates: Effect.Effect; + readonly downloadUpdate: Effect.Effect; readonly quitAndInstall: (options: { readonly isSilent: boolean; readonly isForceRunAfter: boolean; - }) => Effect.Effect; + }) => Effect.Effect; readonly on: >( eventName: string, listener: (...args: Args) => void, @@ -114,22 +91,17 @@ export const make = ElectronUpdater.of({ }), checkForUpdates: Effect.tryPromise({ try: () => autoUpdater.checkForUpdates(), - catch: (cause) => - new ElectronUpdaterCheckForUpdatesError({ operation: "check for updates", cause }), + catch: (cause) => new ElectronUpdaterError({ operation: "check for updates", cause }), }).pipe(Effect.asVoid), downloadUpdate: Effect.tryPromise({ try: () => autoUpdater.downloadUpdate(), - catch: (cause) => - new ElectronUpdaterDownloadUpdateError({ operation: "download the update", cause }), + catch: (cause) => new ElectronUpdaterError({ operation: "download the update", cause }), }).pipe(Effect.asVoid), quitAndInstall: ({ isSilent, isForceRunAfter }) => Effect.try({ try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), catch: (cause) => - new ElectronUpdaterQuitAndInstallError({ - operation: "quit and install the update", - cause, - }), + new ElectronUpdaterError({ operation: "quit and install the update", cause }), }), on: (eventName, listener) => { const eventTarget = autoUpdater as unknown as { diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index d8d8abb0d6b..0eec2d01e34 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -11,7 +11,7 @@ import { app, BrowserWindow, type BrowserWindowConstructorOptions } from "electr export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowCreateError", { - resource: Schema.Literal("Electron BrowserWindow"), + resource: Schema.String, cause: Schema.Defect(), }, ) { diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index fe337272995..534b8727ba6 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -55,7 +55,7 @@ function makeSafeStorageLayer(input: { input.availabilityError === undefined ? Effect.succeed(input.available) : Effect.fail( - new ElectronSafeStorage.ElectronSafeStorageAvailabilityError({ + new ElectronSafeStorage.ElectronSafeStorageError({ operation: "check encryption availability", cause: input.availabilityError, }), @@ -64,7 +64,7 @@ function makeSafeStorageLayer(input: { input.encryptError === undefined ? Effect.succeed(textEncoder.encode(`enc:${value}`)) : Effect.fail( - new ElectronSafeStorage.ElectronSafeStorageEncryptError({ + new ElectronSafeStorage.ElectronSafeStorageError({ operation: "encrypt a string", cause: input.encryptError, }), @@ -72,7 +72,7 @@ function makeSafeStorageLayer(input: { decryptString: (value) => { if (input.decryptError !== undefined) { return Effect.fail( - new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + new ElectronSafeStorage.ElectronSafeStorageError({ operation: "decrypt a string", cause: input.decryptError, }), @@ -82,7 +82,7 @@ function makeSafeStorageLayer(input: { const decoded = textDecoder.decode(value); if (!decoded.startsWith("enc:")) { return Effect.fail( - new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + new ElectronSafeStorage.ElectronSafeStorageError({ operation: "decrypt a string", cause: new Error("invalid secret"), }), @@ -290,7 +290,8 @@ describe("DesktopSavedEnvironments", () => { }) .pipe(Effect.flip); - assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); + assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageError); + assert.equal(error.operation, "check encryption availability"); assert.equal(error.cause, cause); }), { availabilityError: cause }, diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 490777e9e84..4740823c1c0 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -155,13 +155,11 @@ export type DesktopSavedEnvironmentsMutationError = export type DesktopSavedEnvironmentsGetSecretError = | DesktopSavedEnvironmentsReadRegistryError | DesktopSavedEnvironmentSecretDecodeError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError; + | ElectronSafeStorage.ElectronSafeStorageError; export type DesktopSavedEnvironmentsSetSecretError = | DesktopSavedEnvironmentsMutationError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageEncryptError; + | ElectronSafeStorage.ElectronSafeStorageError; export class DesktopSavedEnvironments extends Context.Service< DesktopSavedEnvironments, diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index ad234df0bb5..3e8863e6a29 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -20,10 +20,7 @@ import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; interface UpdatesHarnessOptions { - readonly checkForUpdates?: Effect.Effect< - void, - ElectronUpdater.ElectronUpdaterCheckForUpdatesError - >; + readonly checkForUpdates?: Effect.Effect; readonly env?: Record; } From 2bc9187d31ed5c3b9db2a3226ca931751e9089c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 23:54:04 -0700 Subject: [PATCH 5/8] Remove redundant desktop error operations Co-authored-by: codex --- apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts | 3 --- apps/desktop/src/app/DesktopConnectionCatalogStore.ts | 6 ------ apps/desktop/src/settings/DesktopSavedEnvironments.test.ts | 3 --- apps/desktop/src/settings/DesktopSavedEnvironments.ts | 6 ------ 4 files changed, 18 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 3c137147237..5243f9a5663 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -238,7 +238,6 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDocumentDecodeError, ); - assert.equal(error.operation, "decode-catalog-document"); assert.equal(error.catalogPath, catalogPath); assert.exists(error.cause); assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json"); @@ -273,7 +272,6 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, ); - assert.equal(error.operation, "read-catalog"); assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); assert.strictEqual(error.cause, permissionError); assert.equal( @@ -369,7 +367,6 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDecodeError, ); - assert.equal(error.operation, "decode-encrypted-catalog"); assert.equal(error.resource, "encryptedCatalog"); assert.equal(error.catalogPath, catalogPath); assert.exists(error.cause); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index f743a0ac19f..8467fe3f077 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -92,7 +92,6 @@ const writeError = ( export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDecodeError", { - operation: Schema.Literal("decode-encrypted-catalog"), resource: Schema.Literal("encryptedCatalog"), catalogPath: Schema.String, cause: Schema.Defect(), @@ -106,7 +105,6 @@ export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedError export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreReadError", { - operation: Schema.Literal("read-catalog"), catalogPath: Schema.String, cause: Schema.Defect(), }, @@ -119,7 +117,6 @@ export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorCl export class DesktopConnectionCatalogStoreDocumentDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDocumentDecodeError", { - operation: Schema.Literal("decode-catalog-document"), catalogPath: Schema.String, cause: Schema.Defect(), }, @@ -187,7 +184,6 @@ function decodeSecretBytes( Effect.mapError( (cause) => new DesktopConnectionCatalogStoreDecodeError({ - operation: "decode-encrypted-catalog", resource: "encryptedCatalog", catalogPath, cause, @@ -209,7 +205,6 @@ const readDocument = ( ? Effect.succeed(null) : Effect.fail( new DesktopConnectionCatalogStoreReadError({ - operation: "read-catalog", catalogPath, cause: error, }), @@ -223,7 +218,6 @@ const readDocument = ( Effect.mapError( (cause) => new DesktopConnectionCatalogStoreDocumentDecodeError({ - operation: "decode-catalog-document", catalogPath, cause, }), diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 534b8727ba6..7063d116a61 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -245,7 +245,6 @@ describe("DesktopSavedEnvironments", () => { .getSecret(savedRegistryRecord.environmentId) .pipe(Effect.flip); assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentSecretDecodeError); - assert.equal(error.operation, "decode-secret"); assert.equal(error.environmentId, savedRegistryRecord.environmentId); assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); assert.equal(error.field, "encryptedBearerToken"); @@ -368,7 +367,6 @@ describe("DesktopSavedEnvironments", () => { registryError, DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, ); - assert.equal(registryError.operation, "decode-registry"); assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath); assert.exists(registryError.cause); const secretError = yield* savedEnvironments @@ -414,7 +412,6 @@ describe("DesktopSavedEnvironments", () => { const error = yield* savedEnvironments.getRegistry.pipe(Effect.flip); assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); - assert.equal(error.operation, "read-registry"); assert.equal(error.registryPath, registryPath); assert.strictEqual(error.cause, permissionError); assert.equal(error.message, `Failed to read desktop saved environments at ${registryPath}.`); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 4740823c1c0..64c40d39f0e 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -106,7 +106,6 @@ const writeError = ( export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsReadError", { - operation: Schema.Literal("read-registry"), registryPath: Schema.String, cause: Schema.Defect(), }, @@ -119,7 +118,6 @@ export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsDocumentDecodeError", { - operation: Schema.Literal("decode-registry"), registryPath: Schema.String, cause: Schema.Defect(), }, @@ -132,7 +130,6 @@ export class DesktopSavedEnvironmentsDocumentDecodeError extends Schema.TaggedEr export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentSecretDecodeError", { - operation: Schema.Literal("decode-secret"), environmentId: Schema.String, registryPath: Schema.String, field: Schema.Literal("encryptedBearerToken"), @@ -246,7 +243,6 @@ function readRegistryDocument( ? Effect.succeed(null) : Effect.fail( new DesktopSavedEnvironmentsReadError({ - operation: "read-registry", registryPath, cause: error, }), @@ -260,7 +256,6 @@ function readRegistryDocument( Effect.mapError( (cause) => new DesktopSavedEnvironmentsDocumentDecodeError({ - operation: "decode-registry", registryPath, cause, }), @@ -327,7 +322,6 @@ function decodeSecretBytes( Effect.mapError( (cause) => new DesktopSavedEnvironmentSecretDecodeError({ - operation: "decode-secret", environmentId, registryPath, field: "encryptedBearerToken", From 3c2cb277dfc4a65c9b92d77df4ec66f17239835d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:27:18 -0700 Subject: [PATCH 6/8] Split method-specific desktop errors Co-authored-by: codex --- .../app/DesktopConnectionCatalogStore.test.ts | 6 +- .../src/electron/ElectronSafeStorage.ts | 64 ++++++++++++++----- .../src/electron/ElectronUpdater.test.ts | 3 +- apps/desktop/src/electron/ElectronUpdater.ts | 60 ++++++++++++----- .../settings/DesktopSavedEnvironments.test.ts | 15 ++--- .../src/updates/DesktopUpdates.test.ts | 5 +- 6 files changed, 102 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 5243f9a5663..e0be7f39b39 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -32,8 +32,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref !decoded.startsWith("encrypted:") || (failDecrypt !== null && (yield* Ref.get(failDecrypt))) ) { - return yield* new ElectronSafeStorage.ElectronSafeStorageError({ - operation: "decrypt a string", + return yield* new ElectronSafeStorage.ElectronSafeStorageDecryptError({ cause: new Error("invalid encrypted catalog"), }); } @@ -394,8 +393,7 @@ describe("DesktopConnectionCatalogStore", () => { assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}')); yield* Ref.set(failDecrypt, true); const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageError); - assert.equal(error.operation, "decrypt a string"); + assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageDecryptError); yield* Ref.set(failDecrypt, false); assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 716f14adada..7fe86ec2762 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -5,48 +5,78 @@ import * as Schema from "effect/Schema"; import { safeStorage } from "electron"; -const ElectronSafeStorageOperation = Schema.Literals([ - "check encryption availability", - "encrypt a string", - "decrypt a string", -]); +const electronSafeStorageErrorFields = { + cause: Schema.Defect(), +}; -export class ElectronSafeStorageError extends Schema.TaggedErrorClass()( - "ElectronSafeStorageError", +export class ElectronSafeStorageAvailabilityError extends Schema.TaggedErrorClass()( + "ElectronSafeStorageAvailabilityError", { - operation: ElectronSafeStorageOperation, - cause: Schema.Defect(), + ...electronSafeStorageErrorFields, }, ) { override get message(): string { - return `Electron safe storage failed to ${this.operation}.`; + return "Electron safe storage failed to check encryption availability."; } } +export class ElectronSafeStorageEncryptError extends Schema.TaggedErrorClass()( + "ElectronSafeStorageEncryptError", + { + ...electronSafeStorageErrorFields, + }, +) { + override get message(): string { + return "Electron safe storage failed to encrypt a string."; + } +} + +export class ElectronSafeStorageDecryptError extends Schema.TaggedErrorClass()( + "ElectronSafeStorageDecryptError", + { + ...electronSafeStorageErrorFields, + }, +) { + override get message(): string { + return "Electron safe storage failed to decrypt a string."; + } +} + +export const ElectronSafeStorageError = Schema.Union([ + ElectronSafeStorageAvailabilityError, + ElectronSafeStorageEncryptError, + ElectronSafeStorageDecryptError, +]); +export type ElectronSafeStorageError = typeof ElectronSafeStorageError.Type; +export const isElectronSafeStorageError = Schema.is(ElectronSafeStorageError); + export class ElectronSafeStorage extends Context.Service< ElectronSafeStorage, { - readonly isEncryptionAvailable: Effect.Effect; - readonly encryptString: (value: string) => Effect.Effect; - readonly decryptString: (value: Uint8Array) => Effect.Effect; + readonly isEncryptionAvailable: Effect.Effect; + readonly encryptString: ( + value: string, + ) => Effect.Effect; + readonly decryptString: ( + value: Uint8Array, + ) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} export const make = ElectronSafeStorage.of({ isEncryptionAvailable: Effect.try({ try: () => safeStorage.isEncryptionAvailable(), - catch: (cause) => - new ElectronSafeStorageError({ operation: "check encryption availability", cause }), + catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), encryptString: (value) => Effect.try({ try: () => safeStorage.encryptString(value), - catch: (cause) => new ElectronSafeStorageError({ operation: "encrypt a string", cause }), + catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), }), decryptString: (value) => Effect.try({ try: () => safeStorage.decryptString(Buffer.from(value)), - catch: (cause) => new ElectronSafeStorageError({ operation: "decrypt a string", cause }), + catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), }), }); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index dc8d8b30377..43a3c84dcd4 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -71,8 +71,7 @@ describe("ElectronUpdater", () => { assert.equal(exit._tag, "Failure"); if (exit._tag === "Failure") { const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterError); - assert.equal(error.operation, "check for updates"); + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); assert.equal(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates."); } diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 11af855a16a..8a468a15c20 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -10,24 +10,51 @@ type AutoUpdater = typeof autoUpdater; export type ElectronUpdaterFeedUrl = Parameters[0]; -const ElectronUpdaterOperation = Schema.Literals([ - "check for updates", - "download the update", - "quit and install the update", -]); +const electronUpdaterErrorFields = { + cause: Schema.Defect(), +}; -export class ElectronUpdaterError extends Schema.TaggedErrorClass()( - "ElectronUpdaterError", +export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( + "ElectronUpdaterCheckForUpdatesError", { - operation: ElectronUpdaterOperation, - cause: Schema.Defect(), + ...electronUpdaterErrorFields, }, ) { override get message(): string { - return `Electron updater failed to ${this.operation}.`; + return "Electron updater failed to check for updates."; } } +export class ElectronUpdaterDownloadUpdateError extends Schema.TaggedErrorClass()( + "ElectronUpdaterDownloadUpdateError", + { + ...electronUpdaterErrorFields, + }, +) { + override get message(): string { + return "Electron updater failed to download the update."; + } +} + +export class ElectronUpdaterQuitAndInstallError extends Schema.TaggedErrorClass()( + "ElectronUpdaterQuitAndInstallError", + { + ...electronUpdaterErrorFields, + }, +) { + override get message(): string { + return "Electron updater failed to quit and install the update."; + } +} + +export const ElectronUpdaterError = Schema.Union([ + ElectronUpdaterCheckForUpdatesError, + ElectronUpdaterDownloadUpdateError, + ElectronUpdaterQuitAndInstallError, +]); +export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; +export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); + export class ElectronUpdater extends Context.Service< ElectronUpdater, { @@ -39,12 +66,12 @@ export class ElectronUpdater extends Context.Service< readonly allowDowngrade: Effect.Effect; readonly setAllowDowngrade: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; - readonly checkForUpdates: Effect.Effect; - readonly downloadUpdate: Effect.Effect; + readonly checkForUpdates: Effect.Effect; + readonly downloadUpdate: Effect.Effect; readonly quitAndInstall: (options: { readonly isSilent: boolean; readonly isForceRunAfter: boolean; - }) => Effect.Effect; + }) => Effect.Effect; readonly on: >( eventName: string, listener: (...args: Args) => void, @@ -91,17 +118,16 @@ export const make = ElectronUpdater.of({ }), checkForUpdates: Effect.tryPromise({ try: () => autoUpdater.checkForUpdates(), - catch: (cause) => new ElectronUpdaterError({ operation: "check for updates", cause }), + catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ cause }), }).pipe(Effect.asVoid), downloadUpdate: Effect.tryPromise({ try: () => autoUpdater.downloadUpdate(), - catch: (cause) => new ElectronUpdaterError({ operation: "download the update", cause }), + catch: (cause) => new ElectronUpdaterDownloadUpdateError({ cause }), }).pipe(Effect.asVoid), quitAndInstall: ({ isSilent, isForceRunAfter }) => Effect.try({ try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), - catch: (cause) => - new ElectronUpdaterError({ operation: "quit and install the update", cause }), + catch: (cause) => new ElectronUpdaterQuitAndInstallError({ cause }), }), on: (eventName, listener) => { const eventTarget = autoUpdater as unknown as { diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 7063d116a61..abd25a39f5b 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -55,8 +55,7 @@ function makeSafeStorageLayer(input: { input.availabilityError === undefined ? Effect.succeed(input.available) : Effect.fail( - new ElectronSafeStorage.ElectronSafeStorageError({ - operation: "check encryption availability", + new ElectronSafeStorage.ElectronSafeStorageAvailabilityError({ cause: input.availabilityError, }), ), @@ -64,16 +63,14 @@ function makeSafeStorageLayer(input: { input.encryptError === undefined ? Effect.succeed(textEncoder.encode(`enc:${value}`)) : Effect.fail( - new ElectronSafeStorage.ElectronSafeStorageError({ - operation: "encrypt a string", + new ElectronSafeStorage.ElectronSafeStorageEncryptError({ cause: input.encryptError, }), ), decryptString: (value) => { if (input.decryptError !== undefined) { return Effect.fail( - new ElectronSafeStorage.ElectronSafeStorageError({ - operation: "decrypt a string", + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ cause: input.decryptError, }), ); @@ -82,8 +79,7 @@ function makeSafeStorageLayer(input: { const decoded = textDecoder.decode(value); if (!decoded.startsWith("enc:")) { return Effect.fail( - new ElectronSafeStorage.ElectronSafeStorageError({ - operation: "decrypt a string", + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ cause: new Error("invalid secret"), }), ); @@ -289,8 +285,7 @@ describe("DesktopSavedEnvironments", () => { }) .pipe(Effect.flip); - assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageError); - assert.equal(error.operation, "check encryption availability"); + assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); assert.equal(error.cause, cause); }), { availabilityError: cause }, diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3e8863e6a29..ad234df0bb5 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -20,7 +20,10 @@ import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; interface UpdatesHarnessOptions { - readonly checkForUpdates?: Effect.Effect; + readonly checkForUpdates?: Effect.Effect< + void, + ElectronUpdater.ElectronUpdaterCheckForUpdatesError + >; readonly env?: Record; } From 06eef6575db54f70c5e298e0c2d237e3fbff2bb9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:29:09 -0700 Subject: [PATCH 7/8] Remove redundant window error field Co-authored-by: codex --- apps/desktop/src/electron/ElectronWindow.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 0eec2d01e34..8902503c150 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -11,12 +11,11 @@ import { app, BrowserWindow, type BrowserWindowConstructorOptions } from "electr export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowCreateError", { - resource: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to create ${this.resource}.`; + return "Failed to create Electron BrowserWindow."; } } @@ -73,8 +72,7 @@ export const make = Effect.gen(function* () { create: (options) => Effect.try({ try: () => new BrowserWindow(options), - catch: (cause) => - new ElectronWindowCreateError({ resource: "Electron BrowserWindow", cause }), + catch: (cause) => new ElectronWindowCreateError({ cause }), }), main: liveMain, currentMainOrFirst, From 83049f16eb6e39c70a2dcf6536475dfe15cefd34 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:54:19 -0700 Subject: [PATCH 8/8] Restore Electron namespace imports Co-authored-by: codex --- .../src/app/DesktopAppIdentity.test.ts | 4 +- apps/desktop/src/app/DesktopLifecycle.ts | 6 +- apps/desktop/src/electron/ElectronApp.ts | 60 ++++++++++--------- apps/desktop/src/electron/ElectronDialog.ts | 31 +++++----- .../desktop/src/electron/ElectronMenu.test.ts | 36 +++++------ apps/desktop/src/electron/ElectronMenu.ts | 39 +++++------- apps/desktop/src/electron/ElectronProtocol.ts | 8 +-- .../src/electron/ElectronSafeStorage.ts | 8 +-- apps/desktop/src/electron/ElectronShell.ts | 6 +- apps/desktop/src/electron/ElectronTheme.ts | 10 ++-- .../src/electron/ElectronWindow.test.ts | 6 +- apps/desktop/src/electron/ElectronWindow.ts | 38 ++++++------ apps/desktop/src/main.ts | 4 +- .../src/ssh/DesktopSshPasswordPrompts.test.ts | 8 +-- .../src/window/DesktopApplicationMenu.test.ts | 9 +-- .../src/window/DesktopApplicationMenu.ts | 4 +- apps/desktop/src/window/DesktopWindow.test.ts | 18 +++--- apps/desktop/src/window/DesktopWindow.ts | 20 +++---- 18 files changed, 155 insertions(+), 160 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 06300fbdec9..7c4c06eb616 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import type { AboutPanelOptionsOptions } from "electron"; +import type * as Electron from "electron"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; @@ -30,7 +30,7 @@ type TestEnvironmentInput = Partial; + readonly setAboutPanelOptions: Array; readonly setDockIcon: string[]; readonly setName: string[]; } diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 10e29ca2772..ad08d2f5a2e 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; -import type { Event } from "electron"; +import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; @@ -69,7 +69,7 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo ); function handleBeforeQuit( - event: Event, + event: Electron.Event, runEffect: (effect: Effect.Effect) => Promise, allowQuit: () => boolean, markQuitAllowed: () => void, @@ -164,7 +164,7 @@ export const make = DesktopLifecycle.of({ desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), ); }); - yield* electronApp.on("before-quit", (event: Event) => { + yield* electronApp.on("before-quit", (event: Electron.Event) => { handleBeforeQuit( event, runEffect, diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 5717af09cbf..3e894001e10 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; -import { app, type AboutPanelOptionsOptions, type App, type RelaunchOptions } from "electron"; +import * as Electron from "electron"; export interface ElectronAppMetadata { readonly appVersion: string; @@ -21,10 +21,15 @@ export class ElectronApp extends Context.Service< readonly whenReady: Effect.Effect; readonly quit: Effect.Effect; readonly exit: (code: number) => Effect.Effect; - readonly relaunch: (options: RelaunchOptions) => Effect.Effect; - readonly setPath: (name: Parameters[0], path: string) => Effect.Effect; + readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; + readonly setPath: ( + name: Parameters[0], + path: string, + ) => Effect.Effect; readonly setName: (name: string) => Effect.Effect; - readonly setAboutPanelOptions: (options: AboutPanelOptionsOptions) => Effect.Effect; + readonly setAboutPanelOptions: ( + options: Electron.AboutPanelOptionsOptions, + ) => Effect.Effect; readonly setAppUserModelId: (id: string) => Effect.Effect; readonly requestSingleInstanceLock: Effect.Effect; readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; @@ -49,78 +54,79 @@ const addScopedAppListener = >( ): Effect.Effect => Effect.acquireRelease( Effect.sync(() => { - app.on(eventName as any, listener as any); + Electron.app.on(eventName as any, listener as any); }), () => Effect.sync(() => { - app.removeListener(eventName as any, listener as any); + Electron.app.removeListener(eventName as any, listener as any); }), ).pipe(Effect.asVoid); export const make = ElectronApp.of({ metadata: Effect.sync(() => ({ - appVersion: app.getVersion(), - appPath: app.getAppPath(), - isPackaged: app.isPackaged, + appVersion: Electron.app.getVersion(), + appPath: Electron.app.getAppPath(), + isPackaged: Electron.app.isPackaged, resourcesPath: process.resourcesPath, - runningUnderArm64Translation: app.runningUnderARM64Translation === true, + runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, })), - name: Effect.sync(() => app.name), - whenReady: Effect.promise(() => app.whenReady()).pipe(Effect.asVoid), + name: Effect.sync(() => Electron.app.name), + whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid), quit: Effect.sync(() => { - app.quit(); + Electron.app.quit(); }), exit: (code) => Effect.sync(() => { - app.exit(code); + Electron.app.exit(code); }), relaunch: (options) => Effect.sync(() => { - app.relaunch(options); + Electron.app.relaunch(options); }), setPath: (name, path) => Effect.sync(() => { - app.setPath(name, path); + Electron.app.setPath(name, path); }), setName: (name) => Effect.sync(() => { - app.setName(name); + Electron.app.setName(name); }), setAboutPanelOptions: (options) => Effect.sync(() => { - app.setAboutPanelOptions(options); + Electron.app.setAboutPanelOptions(options); }), setAppUserModelId: (id) => Effect.sync(() => { - app.setAppUserModelId(id); + Electron.app.setAppUserModelId(id); }), - requestSingleInstanceLock: Effect.sync(() => app.requestSingleInstanceLock()), - isDefaultProtocolClient: (protocol) => Effect.sync(() => app.isDefaultProtocolClient(protocol)), + requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), + isDefaultProtocolClient: (protocol) => + Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), setAsDefaultProtocolClient: (protocol, path, args) => Effect.sync(() => { if (path === undefined) { - return app.setAsDefaultProtocolClient(protocol); + return Electron.app.setAsDefaultProtocolClient(protocol); } - return app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); + return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); }), setDesktopName: (desktopName) => Effect.sync(() => { - const linuxApp = app as App & { + const linuxApp = Electron.app as Electron.App & { setDesktopName?: (desktopName: string) => void; }; linuxApp.setDesktopName?.(desktopName); }), setDockIcon: (iconPath) => Effect.sync(() => { - app.dock?.setIcon(iconPath); + Electron.app.dock?.setIcon(iconPath); }), appendCommandLineSwitch: (switchName, value) => Effect.sync(() => { if (value === undefined) { - app.commandLine.appendSwitch(switchName); + Electron.app.commandLine.appendSwitch(switchName); return; } - app.commandLine.appendSwitch(switchName, value); + Electron.app.commandLine.appendSwitch(switchName, value); }), on: addScopedAppListener, }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index d089872b214..057817ec7e6 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -3,23 +3,17 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { - dialog, - type BrowserWindow, - type MessageBoxOptions, - type MessageBoxReturnValue, - type OpenDialogOptions, -} from "electron"; +import * as Electron from "electron"; const CONFIRM_BUTTON_INDEX = 1; export interface ElectronDialogPickFolderInput { - readonly owner: Option.Option; + readonly owner: Option.Option; readonly defaultPath: Option.Option; } export interface ElectronDialogConfirmInput { - readonly owner: Option.Option; + readonly owner: Option.Option; readonly message: string; } @@ -30,14 +24,16 @@ export class ElectronDialog extends Context.Service< input: ElectronDialogPickFolderInput, ) => Effect.Effect>; readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; - readonly showMessageBox: (options: MessageBoxOptions) => Effect.Effect; + readonly showMessageBox: ( + options: Electron.MessageBoxOptions, + ) => 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 openDialogOptions: OpenDialogOptions = Option.match(input.defaultPath, { + const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { onNone: () => ({ properties: ["openDirectory", "createDirectory"], }), @@ -47,8 +43,9 @@ export const make = ElectronDialog.of({ }), }); const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => dialog.showOpenDialog(openDialogOptions)), - onSome: (owner) => Effect.promise(() => dialog.showOpenDialog(owner, openDialogOptions)), + onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), + onSome: (owner) => + Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), }); if (result.canceled) { @@ -71,15 +68,15 @@ export const make = ElectronDialog.of({ message: normalizedMessage, }; const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => dialog.showMessageBox(options)), - onSome: (owner) => Effect.promise(() => dialog.showMessageBox(owner, options)), + onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)), + onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)), }); return result.response === CONFIRM_BUTTON_INDEX; }), - showMessageBox: (options) => Effect.promise(() => dialog.showMessageBox(options)), + showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), showErrorBox: (title, content) => Effect.sync(() => { - dialog.showErrorBox(title, content); + Electron.dialog.showErrorBox(title, content); }), }); diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 6fd1eea7009..4dd8066e3c6 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import type { BrowserWindow, MenuItem, MenuItemConstructorOptions, PopupOptions } from "electron"; +import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted( @@ -35,7 +35,7 @@ describe("ElectronMenu", () => { Effect.gen(function* () { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as BrowserWindow, + window: {} as Electron.BrowserWindow, items: [], position: Option.none(), }); @@ -47,21 +47,23 @@ describe("ElectronMenu", () => { it.effect("resolves with the clicked leaf item id", () => Effect.gen(function* () { - buildFromTemplateMock.mockImplementation((template: MenuItemConstructorOptions[]) => ({ - popup: () => { - const firstItem = template[0]; - assert.isDefined(firstItem); - const click = firstItem.click; - if (!click) { - throw new Error("Expected menu item to have a click handler."); - } - click({} as MenuItem, {} as BrowserWindow, {} as KeyboardEvent); - }, - })); + buildFromTemplateMock.mockImplementation( + (template: Electron.MenuItemConstructorOptions[]) => ({ + popup: () => { + const firstItem = template[0]; + assert.isDefined(firstItem); + const click = firstItem.click; + if (!click) { + throw new Error("Expected menu item to have a click handler."); + } + click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + }, + }), + ); const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as BrowserWindow, + window: {} as Electron.BrowserWindow, items: [{ id: "copy", label: "Copy" }], position: Option.none(), }); @@ -73,14 +75,14 @@ describe("ElectronMenu", () => { it.effect("resolves with none when the menu closes without a click", () => Effect.gen(function* () { buildFromTemplateMock.mockImplementation(() => ({ - popup: (options: PopupOptions) => { + popup: (options: Electron.PopupOptions) => { options.callback?.(); }, })); const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as BrowserWindow, + window: {} as Electron.BrowserWindow, items: [{ id: "copy", label: "Copy" }], position: Option.some({ x: 10.8, y: 20.2 }), }); @@ -101,7 +103,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const popup = electronMenu.popupTemplate({ - window: {} as BrowserWindow, + window: {} as Electron.BrowserWindow, template: [{ label: "Copy" }], }); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 2cb39ea64ad..d9eb3b22eff 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -5,14 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { - Menu, - nativeImage, - type BrowserWindow, - type MenuItemConstructorOptions, - type NativeImage, - type PopupOptions, -} from "electron"; +import * as Electron from "electron"; export interface ElectronMenuPosition { readonly x: number; @@ -20,21 +13,21 @@ export interface ElectronMenuPosition { } export interface ElectronMenuContextInput { - readonly window: BrowserWindow; + readonly window: Electron.BrowserWindow; readonly items: readonly ContextMenuItem[]; readonly position: Option.Option; } export interface ElectronMenuTemplateInput { - readonly window: BrowserWindow; - readonly template: readonly MenuItemConstructorOptions[]; + readonly window: Electron.BrowserWindow; + readonly template: readonly Electron.MenuItemConstructorOptions[]; } export class ElectronMenu extends Context.Service< ElectronMenu, { readonly setApplicationMenu: ( - template: readonly MenuItemConstructorOptions[], + template: readonly Electron.MenuItemConstructorOptions[], ) => Effect.Effect; readonly showContextMenu: ( input: ElectronMenuContextInput, @@ -88,9 +81,9 @@ const normalizePosition = ( export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; - let destructiveMenuIconCache: Option.Option | undefined; + let destructiveMenuIconCache: Option.Option | undefined; - const getDestructiveMenuIcon = (): Option.Option => { + const getDestructiveMenuIcon = (): Option.Option => { if (platform !== "darwin") { return Option.none(); } @@ -99,7 +92,7 @@ export const make = Effect.gen(function* () { } try { - const icon = nativeImage.createFromNamedImage("trash").resize({ + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ width: 12, height: 12, }); @@ -115,8 +108,8 @@ export const make = Effect.gen(function* () { const buildTemplate = ( entries: readonly ContextMenuItem[], complete: (selectedItemId: Option.Option) => void, - ): MenuItemConstructorOptions[] => { - const template: MenuItemConstructorOptions[] = []; + ): Electron.MenuItemConstructorOptions[] => { + const template: Electron.MenuItemConstructorOptions[] = []; let hasInsertedDestructiveSeparator = false; for (const item of entries) { @@ -125,7 +118,7 @@ export const make = Effect.gen(function* () { hasInsertedDestructiveSeparator = true; } - const itemOption: MenuItemConstructorOptions = { + const itemOption: Electron.MenuItemConstructorOptions = { label: item.label, enabled: !item.disabled, }; @@ -150,14 +143,14 @@ export const make = Effect.gen(function* () { return ElectronMenu.of({ setApplicationMenu: (template) => Effect.sync(() => { - Menu.setApplicationMenu(Menu.buildFromTemplate([...template])); + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); }), popupTemplate: (input) => Effect.sync(() => { if (input.template.length === 0) { return; } - Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); + Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); }), showContextMenu: (input) => Effect.callback>((resume) => { @@ -176,14 +169,14 @@ export const make = Effect.gen(function* () { resume(Effect.succeed(selectedItemId)); }; - const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); const popupPosition = normalizePosition(input.position); const popupOptions = Option.match(popupPosition, { - onNone: (): PopupOptions => ({ + onNone: (): Electron.PopupOptions => ({ window: input.window, callback: () => complete(Option.none()), }), - onSome: (position): PopupOptions => ({ + onSome: (position): Electron.PopupOptions => ({ window: input.window, x: position.x, y: position.y, diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index ed89662d0c8..4c80c2c4900 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -5,7 +5,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import { net, protocol } from "electron"; +import * as Electron from "electron"; export const DESKTOP_HOST = "app"; export const DESKTOP_PRODUCTION_SCHEME = "t3code"; @@ -110,7 +110,7 @@ async function proxyRequest( init.body = request.body; (init as RequestInit & { duplex: "half" }).duplex = "half"; } - const response = await net.fetch(targetUrl.toString(), init); + const response = await Electron.net.fetch(targetUrl.toString(), init); return withContentSecurityPolicy(response, contentSecurityPolicy); } @@ -126,7 +126,7 @@ export const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ try: () => { - protocol.handle(input.scheme, (request) => + Electron.protocol.handle(input.scheme, (request) => proxyRequest(request, input.targetOrigin, contentSecurityPolicy), ); }, @@ -134,7 +134,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.andThen(Ref.set(registered, true))), () => Effect.sync(() => { - protocol.unhandle(input.scheme); + Electron.protocol.unhandle(input.scheme); }).pipe(Effect.andThen(Ref.set(registered, false))), ); }, diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 7fe86ec2762..76162c1647a 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { safeStorage } from "electron"; +import * as Electron from "electron"; const electronSafeStorageErrorFields = { cause: Schema.Defect(), @@ -65,17 +65,17 @@ export class ElectronSafeStorage extends Context.Service< export const make = ElectronSafeStorage.of({ isEncryptionAvailable: Effect.try({ - try: () => safeStorage.isEncryptionAvailable(), + try: () => Electron.safeStorage.isEncryptionAvailable(), catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), encryptString: (value) => Effect.try({ - try: () => safeStorage.encryptString(value), + try: () => Electron.safeStorage.encryptString(value), catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), }), decryptString: (value) => Effect.try({ - try: () => safeStorage.decryptString(Buffer.from(value)), + try: () => Electron.safeStorage.decryptString(Buffer.from(value)), catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), }), }); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 22381e79471..316d3138bfa 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { clipboard, shell } from "electron"; +import * as Electron from "electron"; const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); @@ -34,7 +34,7 @@ export const make = ElectronShell.of({ onNone: () => Effect.succeed(false), onSome: (externalUrl) => Effect.promise(() => - shell.openExternal(externalUrl).then( + Electron.shell.openExternal(externalUrl).then( () => true, () => false, ), @@ -42,7 +42,7 @@ export const make = ElectronShell.of({ }), copyText: (text) => Effect.sync(() => { - clipboard.writeText(text); + Electron.clipboard.writeText(text); }), }); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 50d9de4f4d3..ef99a31067a 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; -import { nativeTheme } from "electron"; +import * as Electron from "electron"; export class ElectronTheme extends Context.Service< ElectronTheme, @@ -16,21 +16,21 @@ export class ElectronTheme extends Context.Service< >()("@t3tools/desktop/electron/ElectronTheme") {} export const make = ElectronTheme.of({ - shouldUseDarkColors: Effect.sync(() => nativeTheme.shouldUseDarkColors), + shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), setSource: (theme) => Effect.suspend(() => { - nativeTheme.themeSource = theme; + Electron.nativeTheme.themeSource = theme; return Effect.void; }), onUpdated: (listener) => Effect.acquireRelease( Effect.suspend(() => { - nativeTheme.on("updated", listener); + Electron.nativeTheme.on("updated", listener); return Effect.void; }), () => Effect.suspend(() => { - nativeTheme.removeListener("updated", listener); + Electron.nativeTheme.removeListener("updated", listener); return Effect.void; }), ), diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index 731a5827182..cc6c6484245 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import type { BrowserWindow } from "electron"; +import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({ @@ -22,7 +22,7 @@ import * as ElectronWindow from "./ElectronWindow.ts"; function makeBrowserWindow(input: { readonly destroyed: boolean }) { return { isDestroyed: vi.fn(() => input.destroyed), - } as unknown as BrowserWindow; + } as unknown as Electron.BrowserWindow; } describe("ElectronWindow", () => { @@ -37,7 +37,7 @@ describe("ElectronWindow", () => { const destroyedWindow = makeBrowserWindow({ destroyed: true }); getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]); - const syncedWindows: BrowserWindow[] = []; + const syncedWindows: Electron.BrowserWindow[] = []; const electronWindow = yield* ElectronWindow.ElectronWindow; yield* electronWindow.syncAllAppearance((window) => Effect.sync(() => { diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 8902503c150..0bf98a9610e 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import { app, BrowserWindow, type BrowserWindowConstructorOptions } from "electron"; +import * as Electron from "electron"; export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowCreateError", @@ -23,25 +23,25 @@ export class ElectronWindow extends Context.Service< ElectronWindow, { readonly create: ( - options: BrowserWindowConstructorOptions, - ) => Effect.Effect; - readonly main: Effect.Effect>; - readonly currentMainOrFirst: Effect.Effect>; - readonly focusedMainOrFirst: Effect.Effect>; - readonly setMain: (window: BrowserWindow) => Effect.Effect; - readonly clearMain: (window: Option.Option) => Effect.Effect; - readonly reveal: (window: BrowserWindow) => Effect.Effect; + options: Electron.BrowserWindowConstructorOptions, + ) => Effect.Effect; + readonly main: Effect.Effect>; + readonly currentMainOrFirst: Effect.Effect>; + readonly focusedMainOrFirst: Effect.Effect>; + readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; + readonly clearMain: (window: Option.Option) => Effect.Effect; + readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; readonly destroyAll: Effect.Effect; readonly syncAllAppearance: ( - sync: (window: BrowserWindow) => Effect.Effect, + sync: (window: Electron.BrowserWindow) => Effect.Effect, ) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronWindow") {} export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; - const mainWindowRef = yield* Ref.make>(Option.none()); + const mainWindowRef = yield* Ref.make>(Option.none()); const liveMain = Ref.get(mainWindowRef).pipe( Effect.map(Option.filter((value) => !value.isDestroyed())), @@ -53,13 +53,13 @@ export const make = Effect.gen(function* () { return main; } - return Option.fromNullishOr(BrowserWindow.getAllWindows()[0] ?? null).pipe( + return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe( Option.filter((window) => !window.isDestroyed()), ); }); const focusedMainOrFirst = Effect.sync(() => - Option.fromNullishOr(BrowserWindow.getFocusedWindow() ?? null).pipe( + Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe( Option.filter((window) => !window.isDestroyed()), ), ).pipe( @@ -71,7 +71,7 @@ export const make = Effect.gen(function* () { return ElectronWindow.of({ create: (options) => Effect.try({ - try: () => new BrowserWindow(options), + try: () => new Electron.BrowserWindow(options), catch: (cause) => new ElectronWindowCreateError({ cause }), }), main: liveMain, @@ -103,14 +103,14 @@ export const make = Effect.gen(function* () { } if (platform === "darwin") { - app.focus({ steal: true }); + Electron.app.focus({ steal: true }); } window.focus(); }), sendAll: (channel, ...args) => Effect.sync(() => { - for (const window of BrowserWindow.getAllWindows()) { + for (const window of Electron.BrowserWindow.getAllWindows()) { if (window.isDestroyed()) { continue; } @@ -118,14 +118,14 @@ export const make = Effect.gen(function* () { } }), destroyAll: Effect.sync(() => { - for (const window of BrowserWindow.getAllWindows()) { + for (const window of Electron.BrowserWindow.getAllWindows()) { window.destroy(); } }), syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* ( - sync: (window: BrowserWindow) => Effect.Effect, + sync: (window: Electron.BrowserWindow) => Effect.Effect, ) { - const windows = BrowserWindow.getAllWindows(); + const windows = Electron.BrowserWindow.getAllWindows(); for (const window of windows) { if (window.isDestroyed()) { continue; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a54f23aa6da..f4b32db07c7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -6,7 +6,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { ipcMain } from "electron"; +import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -111,7 +111,7 @@ const electronLayer = Layer.mergeAll( ElectronTheme.layer, ElectronUpdater.layer, ElectronWindow.layer, - DesktopIpc.layer(ipcMain), + DesktopIpc.layer(Electron.ipcMain), ); const desktopFoundationLayer = Layer.mergeAll( diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index bb42489c654..f0b5b1bd8ef 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -6,7 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; -import type { BrowserWindow } from "electron"; +import type * as Electron from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; @@ -71,9 +71,9 @@ function makeElectronWindowLayer(window: ReturnType["wind ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ create: () => Effect.die("unexpected BrowserWindow creation"), - main: Effect.succeed(Option.some(window as BrowserWindow)), - currentMainOrFirst: Effect.succeed(Option.some(window as BrowserWindow)), - focusedMainOrFirst: Effect.succeed(Option.some(window as BrowserWindow)), + main: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), setMain: () => Effect.void, clearMain: () => Effect.void, reveal: () => Effect.void, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index bbd427e0421..04a1971ce46 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import type { BrowserWindow, MenuItem, MenuItemConstructorOptions } from "electron"; +import type * as Electron from "electron"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; @@ -79,7 +79,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => } satisfies DesktopWindow.DesktopWindow["Service"]); const makeElectronMenuLayer = ( - applicationMenuTemplate: Deferred.Deferred, + applicationMenuTemplate: Deferred.Deferred, ) => Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: (template) => @@ -92,7 +92,8 @@ describe("DesktopApplicationMenu", () => { it.effect("installs the native menu and routes Settings through DesktopWindow", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); - const applicationMenuTemplate = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); yield* Effect.gen(function* () { const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; @@ -127,7 +128,7 @@ describe("DesktopApplicationMenu", () => { throw new Error("Expected Settings menu item to have a click handler."); } - settingsClick({} as MenuItem, {} as BrowserWindow, {} as KeyboardEvent); + settingsClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); assert.equal(yield* Deferred.await(selectedAction), "open-settings"); }), ); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index a593d94963f..09cd6bc48c6 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import type { MenuItemConstructorOptions } from "electron"; +import type * as Electron from "electron"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -125,7 +125,7 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; - const template: MenuItemConstructorOptions[] = []; + const template: Electron.MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { template.push({ diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index ec64651f288..76413dd0b55 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import type { BrowserWindow, BrowserWindowConstructorOptions, Session } from "electron"; +import type * as Electron from "electron"; import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ @@ -76,7 +76,7 @@ function makeFakeBrowserWindow() { }; return { - window: window as unknown as BrowserWindow, + window: window as unknown as Electron.BrowserWindow, loadURL: window.loadURL, openDevTools: webContents.openDevTools, setAutoHideCursor: window.setAutoHideCursor, @@ -133,10 +133,10 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( ); function makeTestLayer(input: { - readonly window: BrowserWindow; + readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; - readonly mainWindow: Ref.Ref>; - readonly createdWindowOptions?: BrowserWindowConstructorOptions[]; + readonly mainWindow: Ref.Ref>; + readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; readonly openedExternalUrls?: unknown[]; }) { const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { @@ -177,7 +177,7 @@ function makeTestLayer(input: { electronThemeLayer, electronWindowLayer, Layer.mock(PreviewManager.PreviewManager)({ - getBrowserSession: () => Effect.succeed({} as Session), + getBrowserSession: () => Effect.succeed({} as Electron.Session), setMainWindow: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), @@ -213,8 +213,8 @@ describe("DesktopWindow", () => { Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const createdWindowOptions: BrowserWindowConstructorOptions[] = []; + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, @@ -241,7 +241,7 @@ describe("DesktopWindow", () => { Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); + const mainWindow = yield* Ref.make>(Option.none()); const openedExternalUrls: unknown[] = []; const layer = makeTestLayer({ window: fakeWindow.window, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 7769d11e629..e6cfce3c54f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -4,11 +4,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import type { - BrowserWindow, - BrowserWindowConstructorOptions, - MenuItemConstructorOptions, -} from "electron"; +import type * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -28,7 +24,7 @@ const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; type WindowTitleBarOptions = Pick< - BrowserWindowConstructorOptions, + Electron.BrowserWindowConstructorOptions, "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" >; @@ -49,9 +45,9 @@ export type DesktopWindowError = export class DesktopWindow extends Context.Service< DesktopWindow, { - readonly createMain: Effect.Effect; - readonly ensureMain: Effect.Effect; - readonly revealOrCreateMain: Effect.Effect; + readonly createMain: Effect.Effect; + readonly ensureMain: Effect.Effect; + readonly revealOrCreateMain: Effect.Effect; readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; readonly handleBackendReady: Effect.Effect; @@ -112,7 +108,7 @@ function getWindowTitleBarOptions( } function syncWindowAppearance( - window: BrowserWindow, + window: Electron.BrowserWindow, shouldUseDarkColors: boolean, platform: NodeJS.Platform, ): Effect.Effect { @@ -159,7 +155,7 @@ export const make = Effect.gen(function* () { const runPromise = Effect.runPromiseWith(context); const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< - BrowserWindow, + Electron.BrowserWindow, DesktopWindowError > { yield* previewManager.getBrowserSession(); @@ -210,7 +206,7 @@ export const make = Effect.gen(function* () { window.webContents.on("context-menu", (event, params) => { event.preventDefault(); - const menuTemplate: MenuItemConstructorOptions[] = []; + const menuTemplate: Electron.MenuItemConstructorOptions[] = []; if (params.misspelledWord) { for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {