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