1// @effect-diagnostics globalTimers:off -- D-Bus deadlines run at a native async boundary.
2// @effect-diagnostics nodeBuiltinImport:off -- Read the portal file through one bounded file descriptor at the native adapter boundary.
3
4import * as NodeCrypto from "node:crypto";
5import {
6 DBusError,
7 Message,
8 MessageFlag,
9 MessageType,
10 NameFlag,
11 RequestNameReply,
12 Variant,
13 sessionBus,
14 type MessageBus,
15 type MessageLike,
16} from "dbus-next";
17import * as Schema from "effect/Schema";
18import { isKdeCaptureSession, type KdeCapturePaths } from "./KdeSnapShot.ts";
19import { isGnomeCaptureSession, readPortalPng, resizeLinuxCapture } from "./linuxCaptureSession.ts";
20export { readPortalPng, resizeLinuxCapture } from "./linuxCaptureSession.ts";
21import { isHyprlandCaptureSession, type HyprlandCapturePaths } from "./HyprlandSnapShot.ts";
22
23const PORTAL = "org.freedesktop.portal.Desktop";
24const PORTAL_PATH = "/org/freedesktop/portal/desktop";
25const SCREENSHOT = "org.freedesktop.portal.Screenshot";
26const REQUEST = "org.freedesktop.portal.Request";
27const EXTENSION = "org.gnome.Shell.Extensions.T3SnapShot";
28const EXTENSION_PATH = "/org/gnome/Shell/Extensions/T3SnapShot";
29const DBUS = "org.freedesktop.DBus";
30const DBUS_PATH = "/org/freedesktop/DBus";
31const UInt = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 0xffff_ffff }));
32const UIntVariant = Schema.Struct({ signature: Schema.Literal("u"), value: UInt });
33const StringVariant = Schema.Struct({ signature: Schema.Literal("s"), value: Schema.String });
34const PortalProperties = Schema.Struct({
35 version: UIntVariant,
36 AvailableTargets: Schema.optional(UIntVariant),
37});
38const WindowMetadata = Schema.Struct({
39 title: Schema.String,
40 appName: Schema.String,
41 appIdentifier: Schema.String,
42 processId: UInt,
43 bounds: Schema.Struct({
44 x: Schema.Int,
45 y: Schema.Int,
46 width: Schema.Int.check(Schema.isGreaterThan(0)),
47 height: Schema.Int.check(Schema.isGreaterThan(0)),
48 }),
49});
50export type LinuxWindowMetadata = typeof WindowMetadata.Type & {
51 readonly clientBounds?: typeof WindowMetadata.Type.bounds;
52 readonly accessibilityBoundsReliable?: boolean;
53};
54const decodeProperties = Schema.decodeUnknownSync(PortalProperties);
55const decodeExtension = Schema.decodeUnknownSync(Schema.Struct({ Version: UIntVariant }));
56const decodeKde = Schema.decodeUnknownSync(Schema.Struct({ Version: UIntVariant }));
57const decodeString = Schema.decodeUnknownSync(Schema.String);
58const decodeBoolean = Schema.decodeUnknownSync(Schema.Boolean);
59const decodeResponse = Schema.decodeUnknownSync(
60 Schema.Tuple([UInt, Schema.Record(Schema.String, Schema.Unknown)]),
61);
62const decodeUri = Schema.decodeUnknownSync(StringVariant);
63const decodeWindow = Schema.decodeUnknownSync(Schema.fromJsonString(WindowMetadata));
64export type LinuxCaptureBackend =
65 | "screenshot-portal"
66 | "gnome-extension"
67 | "niri"
68 | "kde"
69 | "hyprland"
70 | "picker";
71export type LinuxWindowSnapshot = {
72 readonly png: Buffer;
73 readonly window?: LinuxWindowMetadata;
74 readonly feedback?: LinuxCaptureFeedback;
75};
76
77export type LinuxCaptureFeedback = {
78 readonly animationStarted: boolean;
79 readonly activate: (title: string) => Promise<void>;
80 readonly animateTo: (frame: {
81 x: number;
82 y: number;
83 width: number;
84 height: number;
85 }) => Promise<void>;
86 readonly complete: () => Promise<void>;
87 readonly close: () => void;
88};
89type FeedbackOptions = { readonly flash: boolean; readonly animate: boolean };
90
91function unavailable(error: unknown): boolean {
92 return (
93 error instanceof DBusError &&
94 ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod"].some(
95 (name) => error.type === `${DBUS}.Error.${name}`,
96 )
97 );
98}
99
100/** One connection per operation: portal registration and request ownership stay together. */
101export class LinuxCaptureConnection {
102 private readonly bus: MessageBus;
103 private readonly disconnected: Promise<never>;
104 private uniqueName = "";
105 private extensionVersion = 0;
106 private closed = false;
107 private feedbackTimer: ReturnType<typeof setTimeout> | undefined;
108
109 get feedbackAvailable() {
110 return this.extensionVersion === 2;
111 }
112
113 constructor(bus = sessionBus()) {
114 this.bus = bus;
115 this.disconnected = new Promise((_, reject) => {
116 bus.on("error", reject);
117 });
118 // A disconnect between calls must not become an unhandled rejection.
119 void this.disconnected.catch(() => undefined);
120 }
121
122 close() {
123 if (this.closed) return;
124 this.closed = true;
125 clearTimeout(this.feedbackTimer);
126 this.bus.disconnect();
127 }
128
129 private async wait<T>(pending: Promise<T>, timeoutMs = 5_000): Promise<T> {
130 let timer: ReturnType<typeof setTimeout> | undefined;
131 try {
132 return await Promise.race([
133 pending,
134 this.disconnected,
135 new Promise<never>((_, reject) => {
136 timer = setTimeout(() => reject(new Error("Snapshot timed out.")), timeoutMs);
137 }),
138 ]);
139 } finally {
140 clearTimeout(timer);
141 }
142 }
143
144 private async call(message: MessageLike) {
145 const reply = await this.wait(this.bus.call(new Message(message)));
146 if (!reply) throw new Error("Missing D-Bus reply.");
147 this.uniqueName = reply.destination;
148 return reply;
149 }
150
151 private async properties(destination: string, path: string, iface: string) {
152 const reply = await this.call({
153 destination,
154 path,
155 interface: "org.freedesktop.DBus.Properties",
156 member: "GetAll",
157 signature: "s",
158 body: [iface],
159 });
160 return reply.body[0] as unknown;
161 }
162
163 async backend(appId: string): Promise<LinuxCaptureBackend> {
164 if (isKdeCaptureSession()) {
165 const kde = await this.properties(
166 "org.kde.KWin.ScreenShot2",
167 "/org/kde/KWin/ScreenShot2",
168 "org.kde.KWin.ScreenShot2",
169 ).catch((error: unknown) => {
170 if (!unavailable(error)) throw error;
171 return undefined;
172 });
173 if (kde !== undefined && decodeKde(kde).Version.value >= 2) return "kde";
174 }
175 // Sandboxed apps already have an identity. Host apps register before any portal calls.
176 if (!process.env.FLATPAK_ID && !process.env.SNAP) {
177 await this.call({
178 destination: PORTAL,
179 path: PORTAL_PATH,
180 interface: "org.freedesktop.host.portal.Registry",
181 member: "Register",
182 signature: "sa{sv}",
183 body: [appId, {}],
184 }).catch((error: unknown) => {
185 if (!unavailable(error)) throw error;
186 });
187 }
188 const properties = await this.properties(PORTAL, PORTAL_PATH, SCREENSHOT).catch(
189 (error: unknown) => {
190 if (!unavailable(error)) throw error;
191 return undefined;
192 },
193 );
194 if (properties !== undefined) {
195 const parsed = decodeProperties(properties);
196 if (parsed.version.value >= 3 && ((parsed.AvailableTargets?.value ?? 0) & 8) !== 0) {
197 return "screenshot-portal";
198 }
199 }
200 if (!isGnomeCaptureSession(process.env)) return "picker";
201 const extension = await this.properties(EXTENSION, EXTENSION_PATH, EXTENSION).catch(
202 (error: unknown) => {
203 if (!unavailable(error)) throw error;
204 return undefined;
205 },
206 );
207 if (extension !== undefined) {
208 const parsed = decodeExtension(extension);
209 this.extensionVersion = parsed.Version.value;
210 if (this.extensionVersion === 1 || this.extensionVersion === 2) return "gnome-extension";
211 }
212 return "picker";
213 }
214
215 async capturePortal(): Promise<LinuxWindowSnapshot> {
216 const owner = await this.call({
217 destination: DBUS,
218 path: DBUS_PATH,
219 interface: DBUS,
220 member: "GetNameOwner",
221 signature: "s",
222 body: [PORTAL],
223 });
224 const sender = decodeString(owner.body[0]);
225 const token = `t3_${NodeCrypto.randomUUID().replaceAll("-", "")}`;
226 const namespace = `${PORTAL_PATH}/request/${this.uniqueName.slice(1).replaceAll(".", "_")}/`;
227 let handle = namespace + token;
228 let completed = false;
229 // Subscribe before Screenshot: the response may precede its method reply.
230 const responses = new Map<string, unknown>();
231 let deliver: ((body: unknown) => void) | undefined;
232 const listener = (message: Message) => {
233 if (
234 message.type !== MessageType.SIGNAL ||
235 message.sender !== sender ||
236 message.interface !== REQUEST ||
237 message.member !== "Response" ||
238 !message.path.startsWith(namespace)
239 )
240 return;
241 if (deliver && message.path === handle) deliver(message.body);
242 else if (responses.size < 8) responses.set(message.path, message.body);
243 };
244 this.bus.on("message", listener);
245 try {
246 await this.call({
247 destination: DBUS,
248 path: DBUS_PATH,
249 interface: DBUS,
250 member: "AddMatch",
251 signature: "s",
252 body: [
253 `type='signal',sender='${sender}',interface='${REQUEST}',member='Response',path_namespace='${namespace.slice(0, -1)}'`,
254 ],
255 });
256 const reply = await this.call({
257 destination: sender,
258 path: PORTAL_PATH,
259 interface: SCREENSHOT,
260 member: "Screenshot",
261 signature: "sa{sv}",
262 body: [
263 "",
264 {
265 handle_token: new Variant("s", token),
266 interactive: new Variant("b", false),
267 modal: new Variant("b", false),
268 target: new Variant("u", 8),
269 },
270 ],
271 });
272 handle = decodeString(reply.body[0]);
273 if (!handle.startsWith(namespace)) throw new Error("Invalid screenshot request handle.");
274 const body = await this.wait(
275 new Promise<unknown>((resolve) => {
276 deliver = resolve;
277 if (responses.has(handle)) resolve(responses.get(handle));
278 }),
279 120_000,
280 );
281 const [status, results] = decodeResponse(body);
282 completed = true;
283 if (status === 1) throw new Error("Snapshot was cancelled.");
284 if (status !== 0) throw new Error("Your desktop did not allow the snapshot.");
285 const uri = decodeUri(results.uri).value;
286 return { png: await readPortalPng(uri) };
287 } finally {
288 this.bus.removeListener("message", listener);
289 if (!completed) {
290 this.bus.send(
291 new Message({
292 destination: sender,
293 path: handle,
294 interface: REQUEST,
295 member: "Close",
296 flags: MessageFlag.NO_REPLY_EXPECTED,
297 }),
298 );
299 }
300 // Closing this dedicated connection also removes the signal match.
301 }
302 }
303
304 async captureExtension(appId: string, options?: FeedbackOptions): Promise<LinuxWindowSnapshot> {
305 const result = await this.wait(
306 this.bus.requestName(`${appId}.SnapShot`, NameFlag.DO_NOT_QUEUE),
307 );
308 if (result !== RequestNameReply.PRIMARY_OWNER) {
309 throw new Error("Another The Knife instance is capturing a window. Try again.");
310 }
311 const withFeedback = this.feedbackAvailable && options !== undefined;
312 const reply = await this.call({
313 destination: EXTENSION,
314 path: EXTENSION_PATH,
315 interface: EXTENSION,
316 member: withFeedback ? "CaptureWithFeedback" : "Capture",
317 ...(withFeedback ? { signature: "bb", body: [options.flash, options.animate] } : {}),
318 });
319 const bytes: unknown = reply.body[0];
320 if (!(bytes instanceof Uint8Array)) throw new Error("Invalid extension screenshot.");
321 const window = decodeWindow(reply.body[1]);
322 const png = resizeLinuxCapture(Buffer.from(bytes));
323 if (!withFeedback) return { png, window };
324 const animationStarted = decodeBoolean(reply.body[2]);
325 // Keep the same authenticated sender alive through activation and the compositor flight.
326 this.feedbackTimer = setTimeout(() => this.close(), 15_000);
327 let flight: Promise<void> | undefined;
328 const feedback: LinuxCaptureFeedback = {
329 animationStarted,
330 activate: async (title) => {
331 await this.feedbackCall("Activate", "s", [title]);
332 },
333 animateTo: (frame) => {
334 flight ??= this.feedbackCall("Animate", "dddd", [
335 frame.x,
336 frame.y,
337 frame.width,
338 frame.height,
339 ]);
340 return flight;
341 },
342 complete: async () => {
343 try {
344 await flight;
345 } finally {
346 this.close();
347 }
348 },
349 close: () => this.close(),
350 };
351 return { png, window, feedback };
352 }
353
354 private async feedbackCall(member: string, signature: string, body: unknown[]): Promise<void> {
355 if (this.closed) return;
356 await this.call({
357 destination: EXTENSION,
358 path: EXTENSION_PATH,
359 interface: EXTENSION,
360 member,
361 signature,
362 body,
363 });
364 }
365}
366
367export async function getLinuxCaptureSupport(appId: string) {
368 if (isHyprlandCaptureSession())
369 return { linuxBackend: "hyprland" as const, linuxFeedbackAvailable: false };
370 const { niriSocketPath, checkNiriCaptureSupport } = await import("./NiriSnapShot.ts");
371 const niri = niriSocketPath();
372 if (niri) {
373 await checkNiriCaptureSupport(niri);
374 return { linuxBackend: "niri" as const, linuxFeedbackAvailable: false };
375 }
376 const connection = new LinuxCaptureConnection();
377 try {
378 const linuxBackend = await connection.backend(appId);
379 return { linuxBackend, linuxFeedbackAvailable: connection.feedbackAvailable };
380 } finally {
381 connection.close();
382 }
383}
384
385/** Undefined means capability missing, not denied: only that case may open the picker. */
386export async function captureLinuxWindow(
387 appId: string,
388 options?: FeedbackOptions,
389 kdePaths?: KdeCapturePaths,
390 hyprlandPaths?: HyprlandCapturePaths,
391): Promise<LinuxWindowSnapshot | undefined> {
392 if (isHyprlandCaptureSession()) {
393 if (!hyprlandPaths) throw new Error("Hyprland capture setup is unavailable in this build.");
394 const { captureHyprlandWindow } = await import("./HyprlandSnapShot.ts");
395 return captureHyprlandWindow(hyprlandPaths, options);
396 }
397 const { niriSocketPath, captureNiriWindow } = await import("./NiriSnapShot.ts");
398 const niri = niriSocketPath();
399 if (niri) return captureNiriWindow(niri);
400 const connection = new LinuxCaptureConnection();
401 let retained = false;
402 try {
403 switch (await connection.backend(appId)) {
404 case "kde": {
405 if (!kdePaths) throw new Error("KDE capture setup is unavailable in this build.");
406 const { captureKdeWindow } = await import("./KdeSnapShot.ts");
407 return await captureKdeWindow(kdePaths, options);
408 }
409 case "screenshot-portal":
410 return await connection.capturePortal();
411 case "gnome-extension": {
412 const snapshot = await connection.captureExtension(appId, options);
413 retained = snapshot.feedback !== undefined;
414 return snapshot;
415 }
416 case "picker":
417 return undefined;
418 }
419 } finally {
420 if (!retained) connection.close();
421 }
422}