1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5
6import * as ElectronWindow from "../electron/ElectronWindow.ts";
7import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts";
8
9/** An http(s) link the operating system handed to The Knife as the default browser. */
10export const isWebLink = (value: string) => {
11 if (!URL.canParse(value)) return false;
12 const { protocol } = new URL(value);
13 return protocol === "http:" || protocol === "https:";
14};
15
16/** An HTML file the operating system asked The Knife to open, as browsers are asked to. */
17export const isWebPageFile = (path: string) => /\.x?html?$/i.test(path);
18
19/**
20 * Web links and HTML files macOS opens with T3 Code once it is the default
21 * browser, plus `t3code://` prompt and thread links on every platform. Files
22 * arrive as file:// URLs. They can
23 * arrive before the app is ready (the link that launched it) or while the web
24 * app reloads, so they wait until the renderer says it is listening.
25 */
26export class DesktopWebLinks extends Context.Service<
27 DesktopWebLinks,
28 {
29 /** Queues a link and delivers it as soon as the renderer is listening. */
30 readonly receive: (url: string) => Effect.Effect<void>;
31 /** The renderer started or stopped listening; listening flushes queued links. */
32 readonly setRendererReady: (ready: boolean) => Effect.Effect<void>;
33 }
34>()("@t3tools/desktop/app/DesktopWebLinks") {}
35
36const make = Effect.gen(function* () {
37 const electronWindow = yield* ElectronWindow.ElectronWindow;
38 const pending: Array<string> = [];
39 /** The page that said it is listening; a reload or crash replaces it, and it must say so again. */
40 let listening: Electron.WebContents | null = null;
41 let detachListening: (() => void) | null = null;
42
43 const stopListening = () => {
44 detachListening?.();
45 detachListening = null;
46 listening = null;
47 };
48
49 const flush = Effect.gen(function* () {
50 if (listening === null || pending.length === 0) return;
51 const window = yield* electronWindow.currentMainOrFirst;
52 if (Option.isNone(window) || window.value.webContents !== listening) return;
53 if (listening.isDestroyed()) {
54 stopListening();
55 return;
56 }
57 for (const url of pending.splice(0)) listening.send(WEB_LINK_OPEN_CHANNEL, url);
58 yield* electronWindow.reveal(window.value);
59 });
60
61 const startListening = Effect.gen(function* () {
62 const window = yield* electronWindow.currentMainOrFirst;
63 if (Option.isNone(window) || window.value.webContents.isDestroyed()) return;
64 const webContents = window.value.webContents;
65 if (listening === webContents) return;
66 stopListening();
67 listening = webContents;
68 // Links that arrive while a new page loads wait for it to listen again.
69 const onNavigation = (
70 event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
71 ) => {
72 if (event.isMainFrame && !event.isSameDocument) stopListening();
73 };
74 webContents.on("did-start-navigation", onNavigation);
75 webContents.on("render-process-gone", stopListening);
76 webContents.once("destroyed", stopListening);
77 detachListening = () => {
78 webContents.removeListener("did-start-navigation", onNavigation);
79 webContents.removeListener("render-process-gone", stopListening);
80 webContents.removeListener("destroyed", stopListening);
81 };
82 });
83
84 return DesktopWebLinks.of({
85 receive: (url) =>
86 Effect.suspend(() => {
87 pending.push(url);
88 return flush;
89 }).pipe(Effect.withSpan("DesktopWebLinks.receive")),
90 setRendererReady: (ready) =>
91 (ready ? startListening : Effect.sync(stopListening)).pipe(
92 Effect.andThen(flush),
93 Effect.withSpan("DesktopWebLinks.setRendererReady"),
94 ),
95 });
96});
97
98export const layer = Layer.effect(DesktopWebLinks, make);