1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
5import * as Scope from "effect/Scope";
6
7import * as Electron from "electron";
8import { activeForkRuntime } from "../updates/forkRuntime.ts";
9
10export interface ElectronAppMetadata {
11 readonly appVersion: string;
12 readonly appPath: string;
13 readonly isPackaged: boolean;
14 readonly resourcesPath: string;
15 readonly runningUnderArm64Translation: boolean;
16}
17
18export 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
30export 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
42export 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
87const 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. */
102export 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
212export const layer = Layer.succeed(ElectronApp, make);