1// @effect-diagnostics nodeBuiltinImport:off -- Niri exposes its compositor API through a local Unix socket.
2// @effect-diagnostics globalTimers:off -- Bound native socket operations outside Effect fibers.
3
4import * as NodeFSP from "node:fs/promises";
5import * as NodeNet from "node:net";
6import * as NodeOS from "node:os";
7import * as NodePath from "node:path";
8import * as NodeURL from "node:url";
9import * as Schema from "effect/Schema";
10
11import type { LinuxWindowSnapshot } from "./LinuxSnapShot.ts";
12import { readPortalPng } from "./linuxCaptureSession.ts";
13
14const MAX_MESSAGE_BYTES = 4 * 1024 * 1024;
15const TIMEOUT_MS = 5_000;
16const Id = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }));
17const Size = Schema.Int.check(Schema.isGreaterThan(0));
18const Window = Schema.Struct({
19 id: Id,
20 title: Schema.NullOr(Schema.String),
21 app_id: Schema.NullOr(Schema.String),
22 pid: Schema.NullOr(Size),
23 layout: Schema.Struct({ window_size: Schema.Tuple([Size, Size]) }),
24});
25const Reply = Schema.Union([
26 Schema.Struct({ Ok: Schema.Unknown }),
27 Schema.Struct({ Err: Schema.String }),
28]);
29const WindowsChanged = Schema.Struct({
30 WindowsChanged: Schema.Struct({ windows: Schema.Array(Window) }),
31});
32const WindowChanged = Schema.Struct({
33 WindowOpenedOrChanged: Schema.Struct({ window: Window }),
34});
35const WindowClosed = Schema.Struct({ WindowClosed: Schema.Struct({ id: Id }) });
36const ScreenshotCaptured = Schema.Struct({
37 ScreenshotCaptured: Schema.Struct({ path: Schema.NullOr(Schema.String) }),
38});
39const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
40const decodeReply = Schema.decodeUnknownSync(Reply);
41const decodeFocused = Schema.decodeUnknownSync(
42 Schema.Struct({ FocusedWindow: Schema.NullOr(Window) }),
43);
44const decodeWindows = Schema.decodeUnknownSync(Schema.Struct({ Windows: Schema.Array(Window) }));
45const decodeVersion = Schema.decodeUnknownSync(Schema.Struct({ Version: Schema.String }));
46const isWindowsChanged = Schema.is(WindowsChanged);
47const isWindowChanged = Schema.is(WindowChanged);
48const isWindowClosed = Schema.is(WindowClosed);
49const isScreenshotCaptured = Schema.is(ScreenshotCaptured);
50
51/** A session hint, never a probe of another compositor or a sandbox escape. */
52export function niriSocketPath(env = process.env): string | undefined {
53 if (env.FLATPAK_ID || env.SNAP) return undefined;
54 if (!env.XDG_CURRENT_DESKTOP?.split(":").some((name) => name.toLowerCase() === "niri"))
55 return undefined;
56 return env.NIRI_SOCKET && NodePath.isAbsolute(env.NIRI_SOCKET) ? env.NIRI_SOCKET : undefined;
57}
58
59/** One bounded exchange or event subscription. All waiters end when the socket closes. */
60class NiriConnection {
61 private readonly socket: NodeNet.Socket;
62 private readonly listeners = new Set<(value: unknown) => void>();
63 private readonly failures = new Set<(error: Error) => void>();
64 private failure: Error | undefined;
65
66 constructor(path: string) {
67 this.socket = NodeNet.createConnection(path);
68 this.socket.setEncoding("utf8");
69 let pending = "";
70 this.socket.on("data", (chunk: string) => {
71 pending += chunk;
72 // Bound even an unterminated or malicious reply; ignore unrelated events without retaining them.
73 if (Buffer.byteLength(pending) > MAX_MESSAGE_BYTES) {
74 this.close(new Error("Niri returned an oversized message."));
75 return;
76 }
77 let end: number;
78 while ((end = pending.indexOf("\n")) !== -1) {
79 const line = pending.slice(0, end);
80 pending = pending.slice(end + 1);
81 try {
82 const value = decodeJson(line);
83 for (const listener of this.listeners) listener(value);
84 } catch {
85 this.close(new Error("Niri returned an invalid message."));
86 return;
87 }
88 }
89 });
90 this.socket.on("error", (error) => this.close(error));
91 this.socket.on("close", () => this.close(new Error("Niri disconnected.")));
92 }
93
94 waitFor<T>(select: (value: unknown) => T | undefined): Promise<T> {
95 if (this.failure) return Promise.reject(this.failure);
96 return new Promise((resolve, reject) => {
97 const finish = () => {
98 clearTimeout(timer);
99 this.listeners.delete(listener);
100 this.failures.delete(fail);
101 };
102 const fail = (error: Error) => {
103 finish();
104 reject(error);
105 };
106 const listener = (value: unknown) => {
107 try {
108 const selected = select(value);
109 if (selected !== undefined) {
110 finish();
111 resolve(selected);
112 }
113 } catch (error) {
114 fail(error instanceof Error ? error : new Error("Invalid Niri response."));
115 }
116 };
117 const timer = setTimeout(() => fail(new Error("Niri snapshot timed out.")), TIMEOUT_MS);
118 this.listeners.add(listener);
119 this.failures.add(fail);
120 });
121 }
122
123 send(request: unknown) {
124 this.socket.write(`${JSON.stringify(request)}\n`);
125 }
126
127 close(error = new Error("Niri connection closed.")) {
128 if (this.failure) return;
129 this.failure = error;
130 for (const fail of this.failures) fail(error);
131 this.socket.destroy();
132 }
133}
134
135async function request(path: string, message: unknown): Promise<unknown> {
136 const connection = new NiriConnection(path);
137 try {
138 const reply = connection.waitFor((value) => ({ value: decodeReply(value) }));
139 connection.send(message);
140 const { value } = await reply;
141 if ("Err" in value) throw new Error(`Niri: ${value.Err}`);
142 return value.Ok;
143 } finally {
144 connection.close();
145 }
146}
147
148export async function checkNiriCaptureSupport(path: string): Promise<void> {
149 const version = decodeVersion(await request(path, "Version")).Version;
150 // 25.11 introduced both caller-selected screenshot paths and completion events.
151 const match = /^(?:niri )?(\d+)\.(\d+)/.exec(version);
152 if (!match || Number(match[1]) < 25 || (Number(match[1]) === 25 && Number(match[2]) < 11))
153 throw new Error("SnapShots require Niri 25.11 or newer.");
154}
155
156async function activateNiriWindow(path: string, title: string, signal: AbortSignal): Promise<void> {
157 signal.throwIfAborted();
158 const connection = new NiriConnection(path);
159 const abort = () => connection.close(new Error("Niri activation cancelled."));
160 signal.addEventListener("abort", abort, { once: true });
161 const windows = new Map<number, typeof Window.Type>();
162 try {
163 const target = connection.waitFor((value) => {
164 if (isWindowsChanged(value)) {
165 windows.clear();
166 for (const window of value.WindowsChanged.windows) windows.set(window.id, window);
167 } else if (isWindowChanged(value)) {
168 const window = value.WindowOpenedOrChanged.window;
169 windows.set(window.id, window);
170 } else if (isWindowClosed(value)) {
171 windows.delete(value.WindowClosed.id);
172 } else return undefined;
173 // Never activate another process's lookalike window, or guess between multiple T3 windows.
174 const matches = [...windows.values()].filter(
175 (window) => window.pid === process.pid && window.title === title,
176 );
177 if (matches.length > 1)
178 throw new Error("More than one The Knife window matches the capture destination.");
179 return matches[0];
180 });
181 connection.send("EventStream");
182 const window = await target;
183 signal.throwIfAborted();
184 await request(path, { Action: { FocusWindow: { id: window.id } } });
185 } finally {
186 signal.removeEventListener("abort", abort);
187 connection.close();
188 }
189}
190
191export async function captureNiriWindow(path: string): Promise<LinuxWindowSnapshot> {
192 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-niri-capture-"));
193 const imagePath = NodePath.join(directory, "capture.png");
194 const events = new NiriConnection(path);
195 try {
196 // An initial state event proves subscription, whereas the EventStream reply alone does not.
197 const ready = events.waitFor((value) => (isWindowsChanged(value) ? true : undefined));
198 events.send("EventStream");
199 await ready;
200 const window = decodeFocused(await request(path, "FocusedWindow")).FocusedWindow;
201 if (!window) throw new Error("Niri has no focused window to capture.");
202 const captured = events.waitFor((value) =>
203 isScreenshotCaptured(value) && value.ScreenshotCaptured.path === imagePath ? true : undefined,
204 );
205 // Observe rejection even if the compositor rejects the command before we await its event.
206 void captured.catch(() => undefined);
207 await request(path, {
208 Action: {
209 ScreenshotWindow: {
210 id: window.id,
211 write_to_disk: true,
212 show_pointer: false,
213 path: imagePath,
214 },
215 },
216 });
217 await captured;
218 const png = await readPortalPng(NodeURL.pathToFileURL(imagePath).href);
219 const after = decodeWindows(await request(path, "Windows")).Windows.find(
220 (item) => item.id === window.id,
221 );
222 // A changed/closed window still yields an image, but never attach text from its replacement.
223 const identityUnchanged =
224 after &&
225 after.pid === window.pid &&
226 after.title === window.title &&
227 after.app_id === window.app_id &&
228 after.layout.window_size.every((size, i) => size === window.layout.window_size[i]);
229 const feedback = new AbortController();
230 return {
231 png,
232 ...(identityUnchanged
233 ? {
234 window: {
235 title: window.title ?? "",
236 appName: window.app_id ?? "Application",
237 appIdentifier: window.app_id ?? "",
238 processId: window.pid ?? 0,
239 // Niri reports logical size, not a globally comparable screen origin.
240 bounds: {
241 x: 0,
242 y: 0,
243 width: window.layout.window_size[0],
244 height: window.layout.window_size[1],
245 },
246 accessibilityBoundsReliable: false,
247 },
248 }
249 : {}),
250 feedback: {
251 animationStarted: false,
252 activate: (title) => activateNiriWindow(path, title, feedback.signal),
253 animateTo: async () => undefined,
254 complete: async () => feedback.abort(),
255 close: () => feedback.abort(),
256 },
257 };
258 } finally {
259 events.close();
260 // The private directory is ours; never remove compositor/user-owned screenshot paths.
261 await NodeFSP.rm(directory, { recursive: true, force: true });
262 }
263}