| 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 | |
| 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | import * as NodeNet from "node:net"; |
| 6 | import * as NodeOS from "node:os"; |
| 7 | import * as NodePath from "node:path"; |
| 8 | import * as NodeURL from "node:url"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | |
| 11 | import type { LinuxWindowSnapshot } from "./LinuxSnapShot.ts"; |
| 12 | import { readPortalPng } from "./linuxCaptureSession.ts"; |
| 13 | |
| 14 | const MAX_MESSAGE_BYTES = 4 * 1024 * 1024; |
| 15 | const TIMEOUT_MS = 5_000; |
| 16 | const Id = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })); |
| 17 | const Size = Schema.Int.check(Schema.isGreaterThan(0)); |
| 18 | const 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 | }); |
| 25 | const Reply = Schema.Union([ |
| 26 | Schema.Struct({ Ok: Schema.Unknown }), |
| 27 | Schema.Struct({ Err: Schema.String }), |
| 28 | ]); |
| 29 | const WindowsChanged = Schema.Struct({ |
| 30 | WindowsChanged: Schema.Struct({ windows: Schema.Array(Window) }), |
| 31 | }); |
| 32 | const WindowChanged = Schema.Struct({ |
| 33 | WindowOpenedOrChanged: Schema.Struct({ window: Window }), |
| 34 | }); |
| 35 | const WindowClosed = Schema.Struct({ WindowClosed: Schema.Struct({ id: Id }) }); |
| 36 | const ScreenshotCaptured = Schema.Struct({ |
| 37 | ScreenshotCaptured: Schema.Struct({ path: Schema.NullOr(Schema.String) }), |
| 38 | }); |
| 39 | const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 40 | const decodeReply = Schema.decodeUnknownSync(Reply); |
| 41 | const decodeFocused = Schema.decodeUnknownSync( |
| 42 | Schema.Struct({ FocusedWindow: Schema.NullOr(Window) }), |
| 43 | ); |
| 44 | const decodeWindows = Schema.decodeUnknownSync(Schema.Struct({ Windows: Schema.Array(Window) })); |
| 45 | const decodeVersion = Schema.decodeUnknownSync(Schema.Struct({ Version: Schema.String })); |
| 46 | const isWindowsChanged = Schema.is(WindowsChanged); |
| 47 | const isWindowChanged = Schema.is(WindowChanged); |
| 48 | const isWindowClosed = Schema.is(WindowClosed); |
| 49 | const isScreenshotCaptured = Schema.is(ScreenshotCaptured); |
| 50 | |
| 51 | /** A session hint, never a probe of another compositor or a sandbox escape. */ |
| 52 | export 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. */ |
| 60 | class 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 | |
| 135 | async 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 | |
| 148 | export 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 | |
| 156 | async 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 | |
| 191 | export 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 | } |