Repository navigation
Expand file tree
/
Copy pathDesktopQuickMessage.ts
More file actions
648 lines (605 loc) · 24.1 KB
/
Copy pathDesktopQuickMessage.ts
File metadata and controls
648 lines (605 loc) · 24.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
import {
DEFAULT_CLIENT_SETTINGS,
type ClientSettings,
type DesktopQuickMessagePresentation,
type DesktopQuickMessageShowEvent,
type DesktopQuickMessageState,
} from "@t3tools/contracts";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Electron from "electron";
import { makeComponentLogger } from "../app/DesktopObservability.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as ElectronMenu from "../electron/ElectronMenu.ts";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as NativeWindowInteraction from "../electron/NativeWindowInteraction.ts";
import { QUICK_MESSAGE_SHOW_EVENT_CHANNEL } from "../ipc/channels.ts";
import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
import { isWaylandSession, toElectronAccelerator } from "../snapShot/snapShot.ts";
import { installAppContextMenu, type AppContextMenuRuntime } from "../window/AppContextMenu.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import {
QUICK_MESSAGE_SESSION_ISOLATED_ARGUMENT,
QUICK_MESSAGE_WINDOW_ROLE_ARGUMENT,
} from "../windowRole.ts";
/**
* The floating window always uses the isolated session. `detached: false` on
* `createQuickMessagePanelWindow` is the older default-session shape; the
* service does not open it. This is not a user setting.
*/
export const QUICK_MESSAGE_DETACHED_SHELL = true;
// 52rem composer at the default 16px interface font, the frame's 3rem of
// horizontal padding, and 36px of slack so the card is not flush with the window.
const PANEL_WIDTH = 916;
// Taller than the composer so its project and model menus can open below it.
const COMPACT_PANEL_HEIGHT = 520;
const EXPANDED_PANEL_HEIGHT = 760;
// Same bound as the main window. A renderer that dies on boot must not reload
// forever; a later shortcut can still load after the cap is spent.
const RENDERER_RECOVERY_RELOAD_DELAY_MS = 500;
const RENDERER_RECOVERY_MAX_ATTEMPTS = 3;
const RENDERER_RECOVERY_WINDOW_MS = 60_000;
/** Hidden this long, the shortcut opens a new draft instead of where the panel left off. */
const RECOVERABLE_RENDERER_GONE_REASONS = new Set<Electron.RenderProcessGoneDetails["reason"]>([
"crashed",
"oom",
"killed",
"abnormal-exit",
]);
export const QUICK_MESSAGE_PARTITION = "persist:t3code-quick-message";
export const QUICK_MESSAGE_SHORTCUT_TAKEN_MESSAGE =
"This shortcut is already used by the system or another app.";
const WAYLAND_UNSUPPORTED_MESSAGE =
"Global shortcuts aren't available in this Wayland session. Bind a desktop shortcut to T3 Code instead.";
const { logWarning: logQuickMessageWarning } = makeComponentLogger("desktop-quick-message");
function placedPanelBounds(workArea: Electron.Rectangle, height: number): Electron.Rectangle {
const width = Math.min(PANEL_WIDTH, workArea.width);
const clampedHeight = Math.min(height, workArea.height);
return {
x: Math.round(workArea.x + (workArea.width - width) / 2),
y: Math.round(workArea.y + Math.min(workArea.height * 0.2, workArea.height - clampedHeight)),
width,
height: clampedHeight,
};
}
/** Centred on the display, a fifth of the way down, like Spotlight. */
export function quickMessagePanelBounds(workArea: Electron.Rectangle): Electron.Rectangle {
return placedPanelBounds(workArea, COMPACT_PANEL_HEIGHT);
}
/** Same placement as compact, tall enough for the expanded shell, clamped to the display. */
export function quickMessageExpandedPanelBounds(workArea: Electron.Rectangle): Electron.Rectangle {
return placedPanelBounds(workArea, EXPANDED_PANEL_HEIGHT);
}
function clampBounds(bounds: Electron.Rectangle, workArea: Electron.Rectangle): Electron.Rectangle {
const width = Math.min(bounds.width, workArea.width);
const height = Math.min(bounds.height, workArea.height);
const maxX = workArea.x + workArea.width - width;
const maxY = workArea.y + workArea.height - height;
return {
x: Math.min(Math.max(bounds.x, workArea.x), maxX),
y: Math.min(Math.max(bounds.y, workArea.y), maxY),
width,
height,
};
}
/**
* Keeps the user's position and derives 916×760 from the display it lands on.
* Remembering the clamped rect would pin a panel that first opened on a small
* monitor to that smaller size.
*/
function expandedBoundsAt(
origin: Electron.Point,
workArea: Electron.Rectangle,
): Electron.Rectangle {
return clampBounds(
{
x: origin.x,
y: origin.y,
width: Math.min(PANEL_WIDTH, workArea.width),
height: Math.min(EXPANDED_PANEL_HEIGHT, workArea.height),
},
workArea,
);
}
export interface QuickMessagePanelWindowConfig {
readonly detached: boolean;
readonly isMac: boolean;
readonly displayName: string;
readonly preloadPath: string;
}
/**
* `detached: false` is the older default session and `#/quick-message` entry.
* `true` is the isolated partition and `#/` entry. The service passes
* `QUICK_MESSAGE_DETACHED_SHELL` for the first load and an empty-URL reload.
*/
export function quickMessagePanelEntryUrl(isDevelopment: boolean, detached: boolean): string {
const root = ElectronProtocol.getDesktopUrl(isDevelopment);
return detached ? `${root}#/` : `${root}#/quick-message`;
}
export function createQuickMessagePanelWindow(
config: QuickMessagePanelWindowConfig,
): Electron.BrowserWindow {
// Only the isolated shell may touch the partition. The older shape shares
// the default session so relay preferences stay where they are.
const partitionSession = config.detached
? Electron.session.fromPartition(QUICK_MESSAGE_PARTITION)
: undefined;
// The marker and the partition move together. A detached flag without a
// session would claim isolation the renderer does not have.
const isolatedShell = config.detached && partitionSession !== undefined;
return new Electron.BrowserWindow({
width: PANEL_WIDTH,
height: COMPACT_PANEL_HEIGHT,
show: false,
frame: false,
transparent: true,
backgroundColor: "#00000000",
// The composer draws its own shadow; the system one would outline the transparent margin.
hasShadow: false,
// Transparent Electron windows do not apply live native resize; presentation
// changes size with setBounds instead.
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
skipTaskbar: true,
alwaysOnTop: true,
title: config.displayName,
// A panel takes keystrokes without activating the app, so dismissing it returns focus to
// whatever the user was in, and it can float over full-screen apps.
...(config.isMac ? { type: "panel" as const } : {}),
webPreferences: {
preload: config.preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
additionalArguments: [
QUICK_MESSAGE_WINDOW_ROLE_ARGUMENT,
...(isolatedShell ? [QUICK_MESSAGE_SESSION_ISOLATED_ARGUMENT] : []),
],
...(partitionSession === undefined ? {} : { session: partitionSession }),
},
});
}
/**
* Same registration as the default session, minus `session`. `Omit` drops the
* asset-directory / dev-origin union, so this names that union explicitly.
*/
type QuickMessagePartitionProtocolInput = {
readonly scheme: string;
readonly clerkFrontendApiHostname: string | undefined;
} & ({ readonly targetOrigin: URL } | { readonly assetDirectory: string });
/**
* Registers the desktop scheme on the quick-message partition in the caller's
* scope. A nested scope would unhandle it immediately. The default-session
* window keeps the default registration.
*/
export const registerQuickMessagePartitionProtocol = Effect.fn(
"DesktopQuickMessage.registerPartitionProtocol",
)(function* (input: QuickMessagePartitionProtocolInput) {
const protocol = yield* ElectronProtocol.ElectronProtocol;
const session = Electron.session.fromPartition(QUICK_MESSAGE_PARTITION);
yield* protocol.registerDesktopProtocol(
"assetDirectory" in input
? {
scheme: input.scheme,
clerkFrontendApiHostname: input.clerkFrontendApiHostname,
assetDirectory: input.assetDirectory,
session,
}
: {
scheme: input.scheme,
clerkFrontendApiHostname: input.clerkFrontendApiHostname,
targetOrigin: input.targetOrigin,
session,
},
);
});
export class DesktopQuickMessage extends Context.Service<
DesktopQuickMessage,
{
readonly initialize: Effect.Effect<void>;
readonly configure: (settings: ClientSettings) => Effect.Effect<void>;
readonly state: Effect.Effect<DesktopQuickMessageState>;
readonly setShortcutSuppressed: (suppressed: boolean) => Effect.Effect<void>;
readonly isPanel: (webContentsId: number) => Effect.Effect<boolean>;
readonly setPanelReady: Effect.Effect<void>;
readonly setPresentation: (
presentation: DesktopQuickMessagePresentation,
) => Effect.Effect<void>;
readonly dismiss: Effect.Effect<void>;
}
>()("@t3tools/desktop/quickMessage/DesktopQuickMessage") {}
/** @public */
export const make = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
const electronWindow = yield* ElectronWindow.ElectronWindow;
const electronMenu = yield* ElectronMenu.ElectronMenu;
const electronShell = yield* ElectronShell.ElectronShell;
const context = yield* Effect.context<
ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronWindow.ElectronWindow
>();
const runPromise = Effect.runPromiseWith(context);
const runFork = Effect.runForkWith(context);
const isMac = environment.platform === "darwin";
const applicationUrl = ElectronProtocol.getDesktopUrl(environment.isDevelopment);
let settings = DEFAULT_CLIENT_SETTINGS;
let state: DesktopQuickMessageState = { shortcutRegistered: false, message: null };
let presentation: DesktopQuickMessagePresentation = "compact";
// User-moved position only. Compact stays on the cursor display; this does not.
let expandedOrigin: Electron.Point | null = null;
let registeredAccelerator: string | null = null;
let suppressed = false;
let panel: Electron.BrowserWindow | null = null;
let panelReady = false;
let showWhenReady = false;
let hiddenAt: number | null = null;
let appQuitting = false;
// True from a renderer death until reload() or loadURL actually runs. Ready
// signals in between belong to the dead page.
let rendererUnloaded = false;
let rendererRecoveryPending = false;
let rendererRecoveryTimestamps: Array<number> = [];
const rendererRecoveryFibers = new Set<Fiber.Fiber<void, never>>();
const readState = (): DesktopQuickMessageState => ({
shortcutRegistered: state.shortcutRegistered,
message: state.message,
presentation,
});
const livePanel = () => (panel !== null && !panel.isDestroyed() ? panel : null);
const interruptRendererRecovery = () => {
for (const fiber of rendererRecoveryFibers) runFork(Fiber.interrupt(fiber));
rendererRecoveryFibers.clear();
rendererRecoveryPending = false;
};
const destroyPanel = () => {
interruptRendererRecovery();
const window = livePanel();
panel = null;
panelReady = false;
showWhenReady = false;
rendererUnloaded = false;
window?.destroy();
};
const cursorWorkArea = () =>
Electron.screen.getDisplayNearestPoint(Electron.screen.getCursorScreenPoint()).workArea;
const rememberExpandedOrigin = (window: Electron.BrowserWindow) => {
if (window.isDestroyed()) return;
// `move` does not carry the rect, and a drag may not have emitted one yet.
const bounds = window.getBounds();
expandedOrigin = { x: bounds.x, y: bounds.y };
};
const applyPresentationBounds = (window: Electron.BrowserWindow) => {
if (presentation !== "expanded") {
window.setBounds(quickMessagePanelBounds(cursorWorkArea()));
return;
}
// Prefer the display that held the panel. The cursor display is only the
// first placement. A removed monitor resolves to the nearest one.
const area =
expandedOrigin === null
? cursorWorkArea()
: Electron.screen.getDisplayNearestPoint(expandedOrigin).workArea;
window.setBounds(
expandedOrigin === null
? quickMessageExpandedPanelBounds(area)
: expandedBoundsAt(expandedOrigin, area),
);
rememberExpandedOrigin(window);
};
const showPanel = (window: Electron.BrowserWindow) => {
const now = Effect.runSyncWith(context)(Clock.currentTimeMillis);
const freshAfterMinutes = settings.quickMessageNewDraftAfterMinutes;
const fresh =
hiddenAt !== null &&
freshAfterMinutes !== null &&
now - hiddenAt >= freshAfterMinutes * 60_000;
hiddenAt = null;
// The renderer opens a new draft, which is compact; size for it now so the panel does not jump.
if (fresh) presentation = "compact";
applyPresentationBounds(window);
// Chromium can count a transparent floating window as occluded, which pauses its frames
// and leaves menus stuck mid-animation. Hidden, it goes back to being throttled.
window.webContents.setBackgroundThrottling(false);
window.show();
window.focus();
const event: DesktopQuickMessageShowEvent = {
clientSettings: settings,
presentation,
...(fresh ? { fresh } : {}),
};
window.webContents.send(QUICK_MESSAGE_SHOW_EVENT_CHANNEL, event);
};
const resetPanelReady = () => {
panelReady = false;
};
const reloadPanel = (window: Electron.BrowserWindow) => {
if (appQuitting || window.isDestroyed() || window.webContents.isDestroyed()) return;
rendererUnloaded = false;
panelReady = false;
const contents = window.webContents;
// Keep this BrowserWindow. reload repeats its committed route; loadURL is
// only there when the renderer died before the first navigation stuck.
if (contents.getURL() === "") {
void window
.loadURL(quickMessagePanelEntryUrl(environment.isDevelopment, QUICK_MESSAGE_DETACHED_SHELL))
.catch(() => undefined);
return;
}
contents.reload();
};
const scheduleRendererRecovery = (window: Electron.BrowserWindow, force: boolean) => {
if (rendererRecoveryPending || appQuitting || window.isDestroyed()) return;
rendererRecoveryPending = true;
const fiber = runFork(
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
if (!force) {
rendererRecoveryTimestamps = rendererRecoveryTimestamps.filter(
(timestamp) => now - timestamp < RENDERER_RECOVERY_WINDOW_MS,
);
if (rendererRecoveryTimestamps.length >= RENDERER_RECOVERY_MAX_ATTEMPTS) return;
rendererRecoveryTimestamps.push(now);
}
yield* Effect.sleep(RENDERER_RECOVERY_RELOAD_DELAY_MS);
reloadPanel(window);
}).pipe(
Effect.ensuring(
Effect.sync(() => {
rendererRecoveryPending = false;
}),
),
),
);
rendererRecoveryFibers.add(fiber);
fiber.addObserver(() => {
rendererRecoveryFibers.delete(fiber);
});
};
const onRenderProcessGone = (
window: Electron.BrowserWindow,
details: Electron.RenderProcessGoneDetails | undefined,
) => {
panelReady = false;
rendererUnloaded = true;
if (window.isVisible() || showWhenReady) showWhenReady = true;
if (details !== undefined && RECOVERABLE_RENDERER_GONE_REASONS.has(details.reason)) {
scheduleRendererRecovery(window, false);
}
};
const installExternalNavigation = (window: Electron.BrowserWindow) => {
window.webContents.setWindowOpenHandler(({ url }) => {
if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) {
void runPromise(electronShell.openExternal(url));
}
return { action: "deny" };
});
window.webContents.on("will-navigate", (event, url) => {
if (
DesktopWindow.isSameOriginRendererNavigation({
applicationUrl,
navigationUrl: url,
})
) {
return;
}
event.preventDefault();
if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) {
void runPromise(electronShell.openExternal(url));
}
});
};
const createPanel = () => {
rendererUnloaded = false;
const window = createQuickMessagePanelWindow({
detached: QUICK_MESSAGE_DETACHED_SHELL,
isMac,
displayName: environment.displayName,
preloadPath: environment.preloadPath,
});
ElectronWindow.markAuxiliaryWindow(window);
window.setAlwaysOnTop(true, isMac ? "floating" : "normal");
if (isMac) {
window.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
// Electron otherwise briefly turns the whole app into a UIElement process, after which
// macOS stops treating it as a foreground app: no Dock dot, another app's menu bar, and
// the first click into the main window only activates it.
skipTransformProcessType: true,
});
}
const contextMenu = {
popup: (input) => electronMenu.popupTemplate(input),
copyText: (text) => electronShell.copyText(text),
reportCopyImageFailure: logQuickMessageWarning("failed to copy context-menu image"),
runPromise: (effect) => runPromise(effect),
} satisfies AppContextMenuRuntime;
installAppContextMenu(window, window.webContents, contextMenu);
installExternalNavigation(window);
// A native menu blurs the panel while its interaction count is up, then
// can deliver blur and focus in one turn after the count drops. Wait for
// that turn and hide only if focus did not return. A sticky ignore would
// also swallow the next real blur.
let compactBlurEpoch = 0;
const scheduleCompactBlurHide = () => {
const epoch = compactBlurEpoch;
queueMicrotask(() => {
if (epoch !== compactBlurEpoch) return;
if (window.isDestroyed() || presentation !== "compact") return;
if (NativeWindowInteraction.nativeWindowInteractionInProgress(window)) return;
if (window.isFocused()) return;
window.hide();
});
};
window.on("blur", () => {
if (presentation !== "compact") return;
if (NativeWindowInteraction.nativeWindowInteractionInProgress(window)) return;
scheduleCompactBlurHide();
});
window.on("focus", () => {
compactBlurEpoch += 1;
});
window.on("move", () => {
if (presentation === "expanded") rememberExpandedOrigin(window);
});
window.on("hide", () => {
// Dismissed while a reload is in flight: do not show again when it lands.
showWhenReady = false;
hiddenAt = Effect.runSyncWith(context)(Clock.currentTimeMillis);
if (presentation === "expanded") rememberExpandedOrigin(window);
if (!window.isDestroyed()) window.webContents.setBackgroundThrottling(true);
});
// Hide keeps the renderer and its route. Preventing close while the app
// is actually quitting cancels quit and leaves the process running.
window.on("close", (event) => {
if (appQuitting || window.isDestroyed()) return;
event.preventDefault();
window.hide();
});
window.webContents.on("did-start-navigation", (event) => {
if (event.isMainFrame && !event.isSameDocument) resetPanelReady();
});
window.webContents.on("render-process-gone", (_event, details) => {
onRenderProcessGone(window, details);
});
window.once("closed", () => {
interruptRendererRecovery();
if (panel === window) {
panel = null;
panelReady = false;
showWhenReady = false;
rendererUnloaded = false;
}
});
if (!isMac) {
// Elsewhere the app quits once its last window closes, and a hidden panel would keep it
// running after the main window is gone.
const main = Effect.runSyncWith(context)(electronWindow.main);
if (Option.isSome(main)) main.value.once("closed", destroyPanel);
}
void window
.loadURL(quickMessagePanelEntryUrl(environment.isDevelopment, QUICK_MESSAGE_DETACHED_SHELL))
.catch(() => undefined);
return window;
};
const togglePanel = () => {
const existing = livePanel();
if (existing?.isVisible()) {
if (existing.isFocused()) existing.hide();
else existing.focus();
return;
}
const window = existing ?? createPanel();
panel = window;
if (panelReady) {
showPanel(window);
return;
}
showWhenReady = true;
// The automatic cap must not leave this shortcut waiting on a dead page.
if (rendererUnloaded) scheduleRendererRecovery(window, true);
};
const releaseShortcut = () => {
if (registeredAccelerator === null) return;
Electron.globalShortcut.unregister(registeredAccelerator);
registeredAccelerator = null;
};
const applyShortcut = () => {
releaseShortcut();
if (!settings.quickMessageEnabled) {
state = { shortcutRegistered: false, message: null };
return;
}
if (suppressed) return;
if (isWaylandSession(environment.platform, process.env)) {
state = { shortcutRegistered: false, message: WAYLAND_UNSUPPORTED_MESSAGE };
return;
}
const accelerator = toElectronAccelerator(settings.quickMessageShortcut);
let registered = false;
try {
registered = Electron.globalShortcut.register(accelerator, togglePanel);
} catch {
registered = false;
}
if (registered) registeredAccelerator = accelerator;
state = registered
? { shortcutRegistered: true, message: null }
: { shortcutRegistered: false, message: QUICK_MESSAGE_SHORTCUT_TAKEN_MESSAGE };
};
const configure = (next: ClientSettings) =>
Effect.sync(() => {
const shortcutChanged =
next.quickMessageEnabled !== settings.quickMessageEnabled ||
toElectronAccelerator(next.quickMessageShortcut) !==
toElectronAccelerator(settings.quickMessageShortcut);
settings = next;
if (!next.quickMessageEnabled) destroyPanel();
if (shortcutChanged || (next.quickMessageEnabled && registeredAccelerator === null)) {
applyShortcut();
}
});
const onBeforeQuit = () => {
appQuitting = true;
};
Electron.app.on("before-quit", onBeforeQuit);
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
Electron.app.removeListener("before-quit", onBeforeQuit);
releaseShortcut();
destroyPanel();
}),
);
return DesktopQuickMessage.of({
initialize: clientSettings.get.pipe(
Effect.flatMap((stored) =>
configure(Option.getOrElse(stored, () => DEFAULT_CLIENT_SETTINGS)),
),
Effect.ignore,
Effect.withSpan("DesktopQuickMessage.initialize"),
),
configure: Effect.fn("DesktopQuickMessage.configure")(function* (next) {
yield* configure(next);
}),
state: Effect.sync(readState),
setShortcutSuppressed: Effect.fn("DesktopQuickMessage.setShortcutSuppressed")(function* (next) {
yield* Effect.sync(() => {
if (suppressed === next) return;
suppressed = next;
if (next) releaseShortcut();
else applyShortcut();
});
}),
isPanel: Effect.fn("DesktopQuickMessage.isPanel")((webContentsId) =>
Effect.sync(() => livePanel()?.webContents.id === webContentsId),
),
setPanelReady: Effect.sync(() => {
const window = livePanel();
if (window === null || rendererUnloaded) return;
panelReady = true;
if (showWhenReady) {
showWhenReady = false;
showPanel(window);
}
}).pipe(Effect.withSpan("DesktopQuickMessage.setPanelReady")),
setPresentation: Effect.fn("DesktopQuickMessage.setPresentation")(function* (next) {
yield* Effect.sync(() => {
const window = livePanel();
if (window !== null && presentation === "expanded" && next !== "expanded") {
rememberExpandedOrigin(window);
}
presentation = next;
if (window !== null) applyPresentationBounds(window);
});
}),
dismiss: Effect.sync(() => livePanel()?.hide()),
});
});
export const layer = Layer.effect(DesktopQuickMessage, make);