1// @effect-diagnostics nodeBuiltinImport:off -- Short-lived native helper with private stdio, no shell.
2// @effect-diagnostics globalTimers:off -- Deadlines bound a native child/overlay; progress is receipt-driven.
3import * as NodeChildProcess from "node:child_process";
4import * as Schema from "effect/Schema";
5import type { LinuxCaptureFeedback, LinuxWindowMetadata } from "./LinuxSnapShot.ts";
6
7const decodeEvent = Schema.decodeUnknownSync(
8 Schema.fromJsonString(
9 Schema.Union([
10 Schema.Struct({ event: Schema.Literal("ready"), animate: Schema.Boolean }),
11 Schema.Struct({ event: Schema.Literals(["landed", "done"]) }),
12 ]),
13 ),
14);
15
16/** Ready and landed are compositor receipts. A requested animation is not proof it started. */
17export async function startNativeCaptureFeedback(
18 executable: string,
19 directory: string,
20 options: { bounds: LinuxWindowMetadata["bounds"]; pid: number; flash: boolean; animate: boolean },
21) {
22 const child = NodeChildProcess.spawn(
23 executable,
24 ["feedback", directory, JSON.stringify(options)],
25 {
26 stdio: ["pipe", "pipe", "ignore"],
27 },
28 );
29 const ready = Promise.withResolvers<boolean | undefined>();
30 const landed = Promise.withResolvers<void>();
31 const exited = Promise.withResolvers<void>();
32 let closed = false;
33 let closing = false;
34 let flying = false;
35 let buffer = "";
36 let killTimer: ReturnType<typeof setTimeout> | undefined;
37 const close = () => {
38 if (closing || closed) return;
39 closing = true;
40 ready.resolve(undefined);
41 landed.resolve();
42 child.stdin.end('{"command":"close"}\n');
43 // Kill only our own child if the compositor fails to acknowledge cancellation.
44 killTimer = setTimeout(() => child.kill(), 1000);
45 killTimer.unref();
46 };
47 const deadline = setTimeout(close, 10_000);
48 deadline.unref();
49 const readyDeadline = setTimeout(close, 2000);
50 readyDeadline.unref();
51 child.stdin.on("error", close);
52 child.once("error", close);
53 child.once("close", () => {
54 closed = true;
55 clearTimeout(deadline);
56 clearTimeout(readyDeadline);
57 if (killTimer) clearTimeout(killTimer);
58 ready.resolve(undefined);
59 landed.resolve();
60 exited.resolve();
61 });
62 child.stdout.setEncoding("utf8");
63 child.stdout.on("data", (chunk: string) => {
64 buffer += chunk;
65 if (buffer.length > 4096) return close();
66 let end: number;
67 while ((end = buffer.indexOf("\n")) !== -1) {
68 const line = buffer.slice(0, end);
69 buffer = buffer.slice(end + 1);
70 try {
71 const event = decodeEvent(line);
72 switch (event.event) {
73 case "ready":
74 clearTimeout(readyDeadline);
75 ready.resolve(event.animate);
76 break;
77 case "landed":
78 landed.resolve();
79 break;
80 case "done":
81 close();
82 break;
83 }
84 } catch {
85 close();
86 }
87 }
88 });
89 const animationStarted = await ready.promise;
90 if (animationStarted === undefined) {
91 close();
92 await exited.promise;
93 return undefined;
94 }
95 return {
96 animationStarted,
97 closed: exited.promise,
98 async animateTo(title: string, frame: Parameters<LinuxCaptureFeedback["animateTo"]>[0]) {
99 if (closing || closed || !animationStarted) return;
100 if (!flying) {
101 flying = true;
102 child.stdin.write(`${JSON.stringify({ command: "animate", title, frame })}\n`);
103 }
104 await landed.promise;
105 },
106 async complete() {
107 if (flying) await landed.promise;
108 close();
109 await exited.promise;
110 },
111 close,
112 };
113}