Skip to content
Source
Sign inSign up
Open History to see the last commit

NativeCaptureFeedback.ts

112 lines3.4 KB

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 killTimer = setTimeout(() => child.kill(), 1000);
44 killTimer.unref();
45 };
46 const deadline = setTimeout(close, 10_000);
47 deadline.unref();
48 const readyDeadline = setTimeout(close, 2000);
49 readyDeadline.unref();
50 child.stdin.on("error", close);
51 child.once("error", close);
52 child.once("close", () => {
53 closed = true;
54 clearTimeout(deadline);
55 clearTimeout(readyDeadline);
56 if (killTimer) clearTimeout(killTimer);
57 ready.resolve(undefined);
58 landed.resolve();
59 exited.resolve();
60 });
61 child.stdout.setEncoding("utf8");
62 child.stdout.on("data", (chunk: string) => {
63 buffer += chunk;
64 if (buffer.length > 4096) return close();
65 let end: number;
66 while ((end = buffer.indexOf("\n")) !== -1) {
67 const line = buffer.slice(0, end);
68 buffer = buffer.slice(end + 1);
69 try {
70 const event = decodeEvent(line);
71 switch (event.event) {
72 case "ready":
73 clearTimeout(readyDeadline);
74 ready.resolve(event.animate);
75 break;
76 case "landed":
77 landed.resolve();
78 break;
79 case "done":
80 close();
81 break;
82 }
83 } catch {
84 close();
85 }
86 }
87 });
88 const animationStarted = await ready.promise;
89 if (animationStarted === undefined) {
90 close();
91 await exited.promise;
92 return undefined;
93 }
94 return {
95 animationStarted,
96 closed: exited.promise,
97 async animateTo(title: string, frame: Parameters<LinuxCaptureFeedback["animateTo"]>[0]) {
98 if (closing || closed || !animationStarted) return;
99 if (!flying) {
100 flying = true;
101 child.stdin.write(`${JSON.stringify({ command: "animate", title, frame })}\n`);
102 }
103 await landed.promise;
104 },
105 async complete() {
106 if (flying) await landed.promise;
107 close();
108 await exited.promise;
109 },
110 close,
111 };