| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Schema from "effect/Schema"; |
| 5 | import * as Scope from "effect/Scope"; |
| 6 | |
| 7 | import * as Electron from "electron"; |
| 8 | import { activeForkRuntime } from "../updates/forkRuntime.ts"; |
| 9 | |
| 10 | export interface ElectronAppMetadata { |
| 11 | readonly appVersion: string; |
| 12 | readonly appPath: string; |
| 13 | readonly isPackaged: boolean; |
| 14 | readonly resourcesPath: string; |
| 15 | readonly runningUnderArm64Translation: boolean; |
| 16 | } |
| 17 | |
| 18 | export class ElectronAppMetadataReadError extends Schema.TaggedError<ElectronAppMetadataReadError>()( |
| 19 | "ElectronAppMetadataReadError", |
| 20 | { |
| 21 | property: Schema.Literals(["app-version", "app-path"]), |
| 22 | cause: Schema.Defect(), |
| 23 | }, |
| 24 | ) { |
| 25 | override get message(): string { |
| 26 | return `Failed to read Electron app metadata property "${this.property}".`; |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | export class ElectronAppWhenReadyError extends Schema.TaggedError<ElectronAppWhenReadyError>()( |
| 31 | "ElectronAppWhenReadyError", |
| 32 | { |
| 33 | isPackaged: Schema.Boolean, |
| 34 | cause: Schema.Defect(), |
| 35 | }, |
| 36 | ) { |
| 37 | override get message(): string { |
| 38 | return `Failed to wait for the Electron app to become ready (packaged: ${this.isPackaged}).`; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | export class ElectronApp extends Context.Service< |
| 43 | ElectronApp, |
| 44 | { |
| 45 | readonly metadata: Effect.Effect<ElectronAppMetadata, ElectronAppMetadataReadError>; |
| 46 | readonly name: Effect.Effect<string>; |
| 47 | /** |
| 48 | * The OS locale, read from the operating system rather than from Chromium's |
| 49 | * resolved application locale — the packaged app ships only the `en-US` |
| 50 | * locale pak, so `app.getLocale()` and the renderer's `Intl` default are |
| 51 | * pinned to `en-US` however the machine is configured. |
| 52 | */ |
| 53 | readonly systemLocale: Effect.Effect<string>; |
| 54 | readonly whenReady: Effect.Effect<void, ElectronAppWhenReadyError>; |
| 55 | readonly quit: Effect.Effect<void>; |
| 56 | readonly exit: (code: number) => Effect.Effect<void>; |
| 57 | readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect<void>; |
| 58 | readonly setPath: ( |
| 59 | name: Parameters<Electron.App["setPath"]>[0], |
| 60 | path: string, |
| 61 | ) => Effect.Effect<void>; |
| 62 | readonly setName: (name: string) => Effect.Effect<void>; |
| 63 | readonly setAboutPanelOptions: ( |
| 64 | options: Electron.AboutPanelOptionsOptions, |
| 65 | ) => Effect.Effect<void>; |
| 66 | readonly setAppUserModelId: (id: string) => Effect.Effect<void>; |
| 67 | readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>; |
| 68 | readonly setAsDefaultProtocolClient: ( |
| 69 | protocol: string, |
| 70 | path?: string, |
| 71 | args?: readonly string[], |
| 72 | ) => Effect.Effect<boolean>; |
| 73 | readonly setDesktopName: (desktopName: string) => Effect.Effect<void>; |
| 74 | readonly setDockIcon: (iconPath: string) => Effect.Effect<void>; |
| 75 | readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>; |
| 76 | readonly onBeforeQuitForUpdate: ( |
| 77 | listener: () => void, |
| 78 | ) => Effect.Effect<void, never, Scope.Scope>; |
| 79 | readonly removeCommandLineSwitch: (switchName: string) => Effect.Effect<void>; |
| 80 | readonly on: <Args extends ReadonlyArray<unknown>>( |
| 81 | eventName: string, |
| 82 | listener: (...args: Args) => void, |
| 83 | ) => Effect.Effect<void, never, Scope.Scope>; |
| 84 | } |
| 85 | >()("@t3tools/desktop/electron/ElectronApp") {} |
| 86 | |
| 87 | const addScopedAppListener = <Args extends ReadonlyArray<unknown>>( |
| 88 | eventName: string, |
| 89 | listener: (...args: Args) => void, |
| 90 | ): Effect.Effect<void, never, Scope.Scope> => |
| 91 | Effect.acquireRelease( |
| 92 | Effect.sync(() => { |
| 93 | Electron.app.on(eventName as any, listener as any); |
| 94 | }), |
| 95 | () => |
| 96 | Effect.sync(() => { |
| 97 | Electron.app.removeListener(eventName as any, listener as any); |
| 98 | }), |
| 99 | ).pipe(Effect.asVoid); |
| 100 | |
| 101 | /** @public Service construction is part of the canonical Effect module API. */ |
| 102 | export const make = ElectronApp.of({ |
| 103 | metadata: Effect.gen(function* () { |
| 104 | const appVersion = yield* Effect.try({ |
| 105 | try: () => activeForkRuntime()?.version ?? Electron.app.getVersion(), |
| 106 | catch: (cause) => |
| 107 | new ElectronAppMetadataReadError({ |
| 108 | property: "app-version", |
| 109 | cause, |
| 110 | }), |
| 111 | }); |
| 112 | const appPath = yield* Effect.try({ |
| 113 | try: () => activeForkRuntime()?.directory ?? Electron.app.getAppPath(), |
| 114 | catch: (cause) => |
| 115 | new ElectronAppMetadataReadError({ |
| 116 | property: "app-path", |
| 117 | cause, |
| 118 | }), |
| 119 | }); |
| 120 | |
| 121 | return { |
| 122 | appVersion, |
| 123 | appPath, |
| 124 | isPackaged: Electron.app.isPackaged, |
| 125 | resourcesPath: process.resourcesPath, |
| 126 | runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, |
| 127 | }; |
| 128 | }), |
| 129 | name: Effect.sync(() => Electron.app.name), |
| 130 | // macOS derives this from NSLocale, which uses POSIX-style identifiers |
| 131 | // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so |
| 132 | // the tag is normalized here rather than in the renderer that consumes it. |
| 133 | systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), |
| 134 | whenReady: Effect.gen(function* () { |
| 135 | const isPackaged = Electron.app.isPackaged; |
| 136 | yield* Effect.tryPromise({ |
| 137 | try: () => Electron.app.whenReady(), |
| 138 | catch: (cause) => new ElectronAppWhenReadyError({ isPackaged, cause }), |
| 139 | }); |
| 140 | }), |
| 141 | quit: Effect.sync(() => { |
| 142 | Electron.app.quit(); |
| 143 | }), |
| 144 | exit: (code) => |
| 145 | Effect.sync(() => { |
| 146 | Electron.app.exit(code); |
| 147 | }), |
| 148 | relaunch: (options) => |
| 149 | Effect.sync(() => { |
| 150 | Electron.app.relaunch(options); |
| 151 | }), |
| 152 | setPath: (name, path) => |
| 153 | Effect.sync(() => { |
| 154 | Electron.app.setPath(name, path); |
| 155 | }), |
| 156 | setName: (name) => |
| 157 | Effect.sync(() => { |
| 158 | Electron.app.setName(name); |
| 159 | }), |
| 160 | setAboutPanelOptions: (options) => |
| 161 | Effect.sync(() => { |
| 162 | Electron.app.setAboutPanelOptions(options); |
| 163 | }), |
| 164 | setAppUserModelId: (id) => |
| 165 | Effect.sync(() => { |
| 166 | Electron.app.setAppUserModelId(id); |
| 167 | }), |
| 168 | getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), |
| 169 | setAsDefaultProtocolClient: (protocol, path, args) => |
| 170 | Effect.sync(() => { |
| 171 | if (path === undefined) { |
| 172 | return Electron.app.setAsDefaultProtocolClient(protocol); |
| 173 | } |
| 174 | return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); |
| 175 | }), |
| 176 | setDesktopName: (desktopName) => |
| 177 | Effect.sync(() => { |
| 178 | const linuxApp = Electron.app as Electron.App & { |
| 179 | setDesktopName?: (desktopName: string) => void; |
| 180 | }; |
| 181 | linuxApp.setDesktopName?.(desktopName); |
| 182 | }), |
| 183 | setDockIcon: (iconPath) => |
| 184 | Effect.sync(() => { |
| 185 | Electron.app.dock?.setIcon(iconPath); |
| 186 | }), |
| 187 | appendCommandLineSwitch: (switchName, value) => |
| 188 | Effect.sync(() => { |
| 189 | if (value === undefined) { |
| 190 | Electron.app.commandLine.appendSwitch(switchName); |
| 191 | return; |
| 192 | } |
| 193 | Electron.app.commandLine.appendSwitch(switchName, value); |
| 194 | }), |
| 195 | onBeforeQuitForUpdate: (listener) => |
| 196 | Effect.acquireRelease( |
| 197 | Effect.sync(() => { |
| 198 | Electron.autoUpdater.on("before-quit-for-update", listener); |
| 199 | }), |
| 200 | () => |
| 201 | Effect.sync(() => { |
| 202 | Electron.autoUpdater.removeListener("before-quit-for-update", listener); |
| 203 | }), |
| 204 | ).pipe(Effect.asVoid), |
| 205 | removeCommandLineSwitch: (switchName) => |
| 206 | Effect.sync(() => { |
| 207 | Electron.app.commandLine.removeSwitch(switchName); |
| 208 | }), |
| 209 | on: addScopedAppListener, |
| 210 | }); |
| 211 | |
| 212 | export const layer = Layer.succeed(ElectronApp, make); |