1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6
7import * as Electron from "electron";
8
9export class ElectronDialogPickFolderError extends Schema.TaggedError<ElectronDialogPickFolderError>()(
10 "ElectronDialogPickFolderError",
11 {
12 ownerWindowId: Schema.NullOr(Schema.Number),
13 defaultPath: Schema.NullOr(Schema.String),
14 cause: Schema.Defect(),
15 },
16) {
17 override get message(): string {
18 const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`;
19 const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath;
20 return `Failed to open the Electron folder picker for ${owner} with ${defaultPath}.`;
21 }
22}
23
24export class ElectronDialogPickFilesError extends Schema.TaggedError<ElectronDialogPickFilesError>()(
25 "ElectronDialogPickFilesError",
26 {
27 ownerWindowId: Schema.NullOr(Schema.Number),
28 defaultPath: Schema.NullOr(Schema.String),
29 cause: Schema.Defect(),
30 },
31) {
32 override get message(): string {
33 const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`;
34 const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath;
35 return `Failed to open the Electron file picker for ${owner} with ${defaultPath}.`;
36 }
37}
38
39export class ElectronDialogShowMessageBoxError extends Schema.TaggedError<ElectronDialogShowMessageBoxError>()(
40 "ElectronDialogShowMessageBoxError",
41 {
42 type: Schema.NullOr(Schema.Literals(["none", "info", "error", "question", "warning"])),
43 titleLength: Schema.NullOr(Schema.Number),
44 messageLength: Schema.Number,
45 detailLength: Schema.NullOr(Schema.Number),
46 buttonCount: Schema.Number,
47 cause: Schema.Defect(),
48 },
49) {
50 override get message(): string {
51 const type = this.type === null ? "untyped" : this.type;
52 return `Failed to show the Electron ${type} message box with ${this.buttonCount} buttons.`;
53 }
54}
55
56export class ElectronDialogShowErrorBoxError extends Schema.TaggedError<ElectronDialogShowErrorBoxError>()(
57 "ElectronDialogShowErrorBoxError",
58 {
59 titleLength: Schema.Number,
60 contentLength: Schema.Number,
61 cause: Schema.Defect(),
62 },
63) {
64 override get message(): string {
65 return `Failed to show the Electron error box with a ${this.titleLength}-character title and ${this.contentLength}-character content.`;
66 }
67}
68
69export const ElectronDialogError = Schema.Union([
70 ElectronDialogPickFolderError,
71 ElectronDialogPickFilesError,
72 ElectronDialogShowMessageBoxError,
73 ElectronDialogShowErrorBoxError,
74]);
75export type ElectronDialogError = typeof ElectronDialogError.Type;
76
77export interface ElectronDialogPickFolderInput {
78 readonly owner: Option.Option<Electron.BrowserWindow>;
79 readonly defaultPath: Option.Option<string>;
80}
81
82export interface ElectronDialogPickFilesInput {
83 readonly owner: Option.Option<Electron.BrowserWindow>;
84 readonly defaultPath: Option.Option<string>;
85 readonly filters: readonly Electron.FileFilter[];
86 readonly multiple: boolean;
87}
88
89export class ElectronDialog extends Context.Service<
90 ElectronDialog,
91 {
92 readonly pickFolder: (
93 input: ElectronDialogPickFolderInput,
94 ) => Effect.Effect<Option.Option<string>, ElectronDialogPickFolderError>;
95 readonly pickFiles: (
96 input: ElectronDialogPickFilesInput,
97 ) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
98 /** Pass `owner` to attach the box to that window so it cannot fall behind it. */
99 readonly showMessageBox: (
100 options: Electron.MessageBoxOptions,
101 owner?: Electron.BrowserWindow,
102 ) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
103 readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
104 }
105>()("@t3tools/desktop/electron/ElectronDialog") {}
106
107/** @public Service construction is part of the canonical Effect module API. */
108export const make = ElectronDialog.of({
109 pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) {
110 const ownerWindowId = Option.match(input.owner, {
111 onNone: () => null,
112 onSome: (owner) => owner.id,
113 });
114 const defaultPath = Option.getOrNull(input.defaultPath);
115 const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, {
116 onNone: () => ({
117 properties: ["openDirectory", "createDirectory"],
118 }),
119 onSome: (defaultPath) => ({
120 properties: ["openDirectory", "createDirectory"],
121 defaultPath,
122 }),
123 });
124 const result = yield* Effect.tryPromise({
125 try: () =>
126 Option.match(input.owner, {
127 onNone: () => Electron.dialog.showOpenDialog(openDialogOptions),
128 onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions),
129 }),
130 catch: (cause) =>
131 new ElectronDialogPickFolderError({
132 ownerWindowId,
133 defaultPath,
134 cause,
135 }),
136 });
137
138 if (result.canceled) {
139 return Option.none();
140 }
141 return Option.fromNullishOr(result.filePaths[0]);
142 }),
143 pickFiles: Effect.fn("desktop.electron.dialog.pickFiles")(function* (input) {
144 const ownerWindowId = Option.match(input.owner, {
145 onNone: () => null,
146 onSome: (owner) => owner.id,
147 });
148 const defaultPath = Option.getOrNull(input.defaultPath);
149 const openDialogOptions: Electron.OpenDialogOptions = {
150 properties: input.multiple ? ["openFile", "multiSelections"] : ["openFile"],
151 filters: [...input.filters],
152 ...(defaultPath === null ? {} : { defaultPath }),
153 };
154 const result = yield* Effect.tryPromise({
155 try: () =>
156 Option.match(input.owner, {
157 onNone: () => Electron.dialog.showOpenDialog(openDialogOptions),
158 onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions),
159 }),
160 catch: (cause) =>
161 new ElectronDialogPickFilesError({
162 ownerWindowId,
163 defaultPath,
164 cause,
165 }),
166 });
167 return result.canceled ? [] : result.filePaths;
168 }),
169 showMessageBox: (options, owner) =>
170 Effect.tryPromise({
171 try: () =>
172 owner === undefined
173 ? Electron.dialog.showMessageBox(options)
174 : Electron.dialog.showMessageBox(owner, options),
175 catch: (cause) =>
176 new ElectronDialogShowMessageBoxError({
177 type: options.type ?? null,
178 titleLength: options.title?.length ?? null,
179 messageLength: options.message.length,
180 detailLength: options.detail?.length ?? null,
181 buttonCount: options.buttons?.length ?? 0,
182 cause,
183 }),
184 }),
185 showErrorBox: (title, content) =>
186 Effect.try({
187 try: () => Electron.dialog.showErrorBox(title, content),
188 catch: (cause) =>
189 new ElectronDialogShowErrorBoxError({
190 titleLength: title.length,
191 contentLength: content.length,
192 cause,
193 }),
194 }).pipe(Effect.orDie),
195});
196
197export const layer = Layer.succeed(ElectronDialog, make);