1import { DESKTOP_TERMINAL_CHANNEL } from "../ipc/channels.ts";
2import { DesktopTerminalResponse, DesktopTerminalRequest } from "@t3tools/contracts";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as PubSub from "effect/PubSub";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import * as Electron from "electron";
10import { getDesktopUrl } from "../electron/ElectronProtocol.ts";
11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12import * as ElectronWindow from "../electron/ElectronWindow.ts";
13
14const encode = Schema.encodeSync(Schema.fromJsonString(DesktopTerminalRequest));
15const decode = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopTerminalResponse));
16const decodeAvailability = Schema.decodeUnknownOption(
17 Schema.Struct({ httpBaseUrl: Schema.String, environmentId: Schema.String }),
18);
19const decodeOpen = Schema.decodeUnknownOption(
20 Schema.Struct({ environmentId: Schema.String, url: Schema.String }),
21);
22
23export class DesktopTerminalHost extends Context.Service<
24 DesktopTerminalHost,
25 {
26 readonly requests: Stream.Stream<Uint8Array>;
27 readonly handleLine: (line: string) => Effect.Effect<void>;
28 readonly reset: Effect.Effect<void>;
29 readonly configure: (httpBaseUrl: string) => Effect.Effect<void>;
30 }
31>()("@t3tools/desktop/backend/DesktopTerminalHost") {}
32
33export const layer = Layer.effect(
34 DesktopTerminalHost,
35 Effect.gen(function* () {
36 const environment = yield* DesktopEnvironment.DesktopEnvironment;
37 const appUrl = new URL(getDesktopUrl(environment.isDevelopment));
38 const windows = yield* ElectronWindow.ElectronWindow;
39 const requests = yield* PubSub.unbounded<Uint8Array>();
40 const run = Effect.runForkWith(yield* Effect.context<never>());
41 let nextId = 0;
42 const ports = new Map<string, Electron.MessagePortMain>();
43 let environmentId: string | null = null;
44 let backendOrigin: string | null = null;
45 const emit = (request: DesktopTerminalRequest) =>
46 run(PubSub.publish(requests, new TextEncoder().encode(`${encode(request)}\n`)));
47 const reset = Effect.sync(() => {
48 environmentId = null;
49 backendOrigin = null;
50 for (const port of ports.values()) {
51 port.postMessage({ type: "close" });
52 port.close();
53 }
54 ports.clear();
55 });
56 const trusted = (event: Electron.IpcMainEvent) => {
57 const window = Effect.runSync(windows.currentMainOrFirst);
58 return (
59 window._tag === "Some" &&
60 window.value.webContents === event.sender &&
61 event.senderFrame === event.sender.mainFrame &&
62 new URL(event.senderFrame.url).protocol === appUrl.protocol &&
63 new URL(event.senderFrame.url).host === appUrl.host
64 );
65 };
66 const onAvailable = (event: Electron.IpcMainEvent, input: unknown) => {
67 const value = decodeAvailability(input);
68 let available = false;
69 if (trusted(event) && value._tag === "Some" && backendOrigin !== null) {
70 try {
71 available =
72 new URL(value.value.httpBaseUrl).origin === backendOrigin &&
73 (environmentId === null || value.value.environmentId === environmentId);
74 } catch {}
75 }
76 event.returnValue = available;
77 };
78 const onOpen = (event: Electron.IpcMainEvent, input: unknown) => {
79 const port = event.ports[0];
80 if (!port) return;
81 const decoded = decodeOpen(input);
82 if (
83 !trusted(event) ||
84 decoded._tag === "None" ||
85 (environmentId !== null && decoded.value.environmentId !== environmentId)
86 ) {
87 port.postMessage({ type: "close" });
88 port.close();
89 return;
90 }
91 try {
92 const url = new URL(decoded.value.url);
93 url.protocol = url.protocol === "wss:" ? "https:" : "http:";
94 if (url.origin !== backendOrigin) {
95 port.postMessage({ type: "close" });
96 port.close();
97 return;
98 }
99 } catch {
100 port.postMessage({ type: "close" });
101 port.close();
102 return;
103 }
104 const id = String(++nextId);
105 ports.set(id, port);
106 const close = () => {
107 if (ports.get(id) !== port) return;
108 ports.delete(id);
109 emit({ type: "close", id });
110 port.close();
111 };
112 port.on("message", ({ data }: { data: unknown }) => {
113 if (typeof data === "string") emit({ type: "message", id, data });
114 else close();
115 });
116 port.on("close", close);
117 const onNavigation = (
118 details: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
119 ) => {
120 if (details.isMainFrame && !details.isSameDocument) close();
121 };
122 event.sender.once("destroyed", close);
123 event.sender.on("did-start-navigation", onNavigation);
124 port.once("close", () => {
125 event.sender.removeListener("destroyed", close);
126 event.sender.removeListener("did-start-navigation", onNavigation);
127 });
128 emit({ type: "open", id, ...decoded.value });
129 port.start();
130 };
131 yield* Effect.acquireRelease(
132 Effect.sync(() => {
133 Electron.ipcMain.on(`${DESKTOP_TERMINAL_CHANNEL}:available`, onAvailable);
134 Electron.ipcMain.on(DESKTOP_TERMINAL_CHANNEL, onOpen);
135 }),
136 () =>
137 reset.pipe(
138 Effect.andThen(
139 Effect.sync(() => {
140 Electron.ipcMain.removeListener(`${DESKTOP_TERMINAL_CHANNEL}:available`, onAvailable);
141 Electron.ipcMain.removeListener(DESKTOP_TERMINAL_CHANNEL, onOpen);
142 }),
143 ),
144 ),
145 );
146 return DesktopTerminalHost.of({
147 requests: Stream.fromPubSub(requests),
148 configure: (url) =>
149 reset.pipe(
150 Effect.andThen(
151 Effect.sync(() => {
152 backendOrigin = new URL(url).origin;
153 }),
154 ),
155 ),
156 reset,
157 handleLine: (line) =>
158 Effect.sync(() => {
159 const response = decode(line);
160 if (response._tag === "None") return;
161 const value = response.value;
162 if (value.type === "ready") {
163 environmentId = value.environmentId;
164 return;
165 }
166 const port = ports.get(value.id);
167 if (!port) return;
168 port.postMessage(value);
169 if (value.type === "close") {
170 ports.delete(value.id);
171 port.close();
172 }
173 }),
174 });
175 }),
176);