1// @effect-diagnostics nodeBuiltinImport:off -- Real temporary files exercise the native portal URI boundary.
2import * as NodeEvents from "node:events";
3import * as NodeFSP from "node:fs/promises";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import * as NodeURL from "node:url";
7import {
8 DBusError,
9 Message,
10 MessageType,
11 RequestNameReply,
12 Variant,
13 type MessageBus,
14} from "dbus-next";
15import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
16
17const { connect, resize, imageSize } = vi.hoisted(() => ({
18 connect: vi.fn(),
19 resize: vi.fn(() => ({ toPNG: () => Buffer.from("resized") })),
20 imageSize: { width: 800, height: 600 },
21}));
22vi.mock("dbus-next", async (original) => ({
23 ...(await original<typeof import("dbus-next")>()),
24 sessionBus: connect,
25}));
26vi.mock("electron", () => ({
27 nativeImage: {
28 createFromBuffer: () => ({
29 isEmpty: () => false,
30 getSize: () => imageSize,
31 resize,
32 }),
33 },
34}));
35
36import {
37 captureLinuxWindow,
38 getLinuxCaptureSupport,
39 readPortalPng,
40 resizeLinuxCapture,
41} from "./LinuxSnapShot.ts";
42
43const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]);
44const appId = "com.t3tools.T3Code";
45const metadata = {
46 title: "Editor",
47 appName: "Text Editor",
48 appIdentifier: "org.gnome.TextEditor.desktop",
49 processId: 42,
50 bounds: { x: -100, y: 20, width: 800, height: 600 },
51};
52
53class FakeBus extends NodeEvents.EventEmitter {
54 calls: Message[] = [];
55 portalVersion = 3;
56 targets: number | undefined = 8;
57 extensionVersion: number | undefined;
58 kdeVersion: number | undefined = 5;
59 kdeError: Error | undefined;
60 status = 0;
61 uri = "file:///missing.png";
62 registryError: Error | undefined;
63 screenshotError: Error | undefined;
64 extensionError: Error | undefined;
65 activationError: Error | undefined;
66 animation: Promise<void> | undefined;
67 sendResponse = true;
68 differentHandle = false;
69 wrongSender = false;
70 requestName = vi.fn(async () => RequestNameReply.PRIMARY_OWNER);
71 disconnect = vi.fn();
72 send = vi.fn();
73
74 signal(path: string, body: unknown[], sender = ":1.2") {
75 this.emit(
76 "message",
77 new Message({
78 type: MessageType.SIGNAL,
79 sender,
80 path,
81 interface: "org.freedesktop.portal.Request",
82 member: "Response",
83 body,
84 }),
85 );
86 }
87
88 async call(message: Message) {
89 this.calls.push(message);
90 const reply = (body: unknown[]) =>
91 new Message({
92 type: MessageType.METHOD_RETURN,
93 replySerial: "1",
94 destination: ":1.23",
95 body,
96 });
97 switch (message.member) {
98 case "Register":
99 if (this.registryError) throw this.registryError;
100 return reply([]);
101 case "GetAll":
102 if (message.destination === "org.kde.KWin.ScreenShot2") {
103 if (this.kdeError) throw this.kdeError;
104 if (this.kdeVersion === undefined)
105 throw new DBusError("org.freedesktop.DBus.Error.ServiceUnknown", "Absent");
106 return reply([{ Version: new Variant("u", this.kdeVersion) }]);
107 }
108 if (message.destination === "org.freedesktop.portal.Desktop")
109 return reply([
110 {
111 version: new Variant("u", this.portalVersion),
112 ...(this.targets === undefined
113 ? {}
114 : { AvailableTargets: new Variant("u", this.targets) }),
115 },
116 ]);
117 if (this.extensionVersion === undefined)
118 throw new DBusError("org.freedesktop.DBus.Error.ServiceUnknown", "Absent");
119 return reply([{ Version: new Variant("u", this.extensionVersion) }]);
120 case "GetNameOwner":
121 return reply([":1.2"]);
122 case "AddMatch":
123 return reply([]);
124 case "Screenshot": {
125 if (this.screenshotError) throw this.screenshotError;
126 const options = message.body[1] as Record<string, Variant<unknown>>;
127 const handle = `/org/freedesktop/portal/desktop/request/1_23/${this.differentHandle ? "actual" : options.handle_token?.value}`;
128 if (this.sendResponse)
129 this.signal(
130 handle,
131 [this.status, { uri: new Variant("s", this.uri) }],
132 this.wrongSender ? ":1.99" : ":1.2",
133 );
134 return reply([handle]);
135 }
136 case "Capture":
137 if (this.extensionError) throw this.extensionError;
138 return reply([png, JSON.stringify(metadata)]);
139 case "CaptureWithFeedback":
140 return reply([png, JSON.stringify(metadata), message.body[1]]);
141 case "Activate":
142 if (this.activationError) throw this.activationError;
143 return reply([]);
144 case "Animate":
145 await this.animation;
146 return reply([]);
147 default:
148 throw new Error(`Unexpected method ${message.member}`);
149 }
150 }
151}
152
153let bus: FakeBus;
154let directory: string;
155beforeEach(async () => {
156 bus = new FakeBus();
157 connect.mockImplementation(() => bus as unknown as MessageBus);
158 directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-portal-test-"));
159 bus.uri = NodeURL.pathToFileURL(NodePath.join(directory, "image.png")).href;
160 await NodeFSP.writeFile(NodePath.join(directory, "image.png"), png);
161 imageSize.width = 800;
162 imageSize.height = 600;
163 vi.stubEnv("FLATPAK_ID", "");
164 vi.stubEnv("SNAP", "");
165 vi.stubEnv("NIRI_SOCKET", "");
166 vi.stubEnv("XDG_CURRENT_DESKTOP", "GNOME");
167});
168
169it("uses native KWin only in an unsandboxed KDE session", async () => {
170 vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE");
171 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("kde");
172 vi.stubEnv("FLATPAK_ID", "com.example.App");
173 expect((await getLinuxCaptureSupport(appId)).linuxBackend).not.toBe("kde");
174});
175
176it("redetects the desktop without using another session's extension or stale Niri socket", async () => {
177 bus.portalVersion = 2;
178 bus.extensionVersion = 2;
179 vi.stubEnv("NIRI_SOCKET", "/previous-session/niri.sock");
180 expect(await getLinuxCaptureSupport(appId)).toEqual({
181 linuxBackend: "gnome-extension",
182 linuxFeedbackAvailable: true,
183 });
184
185 vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE");
186 expect(await getLinuxCaptureSupport(appId)).toEqual({
187 linuxBackend: "kde",
188 linuxFeedbackAvailable: false,
189 });
190 bus.kdeVersion = undefined;
191 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("picker");
192
193 vi.stubEnv("XDG_CURRENT_DESKTOP", "GNOME");
194 expect(await getLinuxCaptureSupport(appId)).toEqual({
195 linuxBackend: "gnome-extension",
196 linuxFeedbackAvailable: true,
197 });
198});
199
200it("uses the standard path when KWin's native capability is missing, but not when denied", async () => {
201 vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE");
202 bus.kdeVersion = undefined;
203 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("screenshot-portal");
204 bus.kdeVersion = 1;
205 bus.portalVersion = 2;
206 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("picker");
207 bus.kdeError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied");
208 await expect(getLinuxCaptureSupport(appId)).rejects.toThrow("Denied");
209});
210
211it("does not fall through to the picker when KDE needs helper setup", async () => {
212 vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE");
213 await expect(captureLinuxWindow(appId)).rejects.toThrow("KDE capture setup");
214 expect(bus.calls.some((call) => call.member === "Screenshot")).toBe(false);
215 expect(bus.disconnect).toHaveBeenCalledOnce();
216});
217it("selects Hyprland without probing a different desktop or falling back on missing setup", async () => {
218 vi.stubEnv("XDG_CURRENT_DESKTOP", "Hyprland");
219 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("hyprland");
220 await expect(captureLinuxWindow(appId)).rejects.toThrow("Hyprland capture setup");
221 expect(bus.calls).toEqual([]);
222});
223afterEach(async () => {
224 vi.useRealTimers();
225 vi.unstubAllEnvs();
226 await NodeFSP.rm(directory, { recursive: true });
227});
228
229it.each([
230 [2, 8, 1, "gnome-extension"],
231 [3, undefined, 1, "gnome-extension"],
232 [3, 7, undefined, "picker"],
233 [3, 8, 1, "screenshot-portal"],
234 [4, 15, undefined, "screenshot-portal"],
235 [2, undefined, 2, "gnome-extension"],
236 [2, undefined, 99, "picker"],
237] as const)(
238 "selects capabilities v%s / targets %s / extension %s",
239 async (version, targets, extension, expected) => {
240 bus.portalVersion = version;
241 bus.targets = targets;
242 bus.extensionVersion = extension;
243 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe(expected);
244 expect(bus.calls[0]?.member).toBe("Register");
245 expect(bus.disconnect).toHaveBeenCalledOnce();
246 },
247);
248
249it.each([1, 2])(
250 "reports feedback separately from capture support for extension v%s",
251 async (version) => {
252 bus.portalVersion = 2;
253 bus.extensionVersion = version;
254 expect(await getLinuxCaptureSupport(appId)).toEqual({
255 linuxBackend: "gnome-extension",
256 linuxFeedbackAvailable: version === 2,
257 });
258 },
259);
260
261it("retains the authenticated connection until the compositor flight has landed", async () => {
262 bus.portalVersion = 2;
263 bus.extensionVersion = 2;
264 const snapshot = await captureLinuxWindow(appId, { flash: false, animate: true });
265 const feedback = snapshot!.feedback!;
266 expect(snapshot).toMatchObject({ png, window: metadata, feedback: { animationStarted: true } });
267 expect(bus.disconnect).not.toHaveBeenCalled();
268 await feedback.activate("T3 Code");
269 const landed = Promise.withResolvers<void>();
270 bus.animation = landed.promise;
271 const flight = feedback.animateTo({ x: 0.1, y: 0.8, width: 0.2, height: 0.1 });
272 const complete = feedback.complete();
273 expect(bus.disconnect).not.toHaveBeenCalled();
274 landed.resolve();
275 await Promise.all([flight, complete]);
276 expect(
277 bus.calls
278 .filter((call) => ["CaptureWithFeedback", "Activate", "Animate"].includes(call.member))
279 .map((call) => call.body),
280 ).toEqual([[false, true], ["T3 Code"], [0.1, 0.8, 0.2, 0.1]]);
281 expect(bus.disconnect).toHaveBeenCalledOnce();
282 feedback.close();
283 expect(bus.disconnect).toHaveBeenCalledOnce();
284});
285
286it("keeps capture working with v1 and does not request unavailable effects", async () => {
287 bus.portalVersion = 2;
288 bus.extensionVersion = 1;
289 expect(await captureLinuxWindow(appId, { flash: true, animate: true })).toEqual({
290 png,
291 window: metadata,
292 });
293 expect(bus.calls.some((call) => call.member === "CaptureWithFeedback")).toBe(false);
294 expect(bus.disconnect).toHaveBeenCalledOnce();
295});
296
297it("can activate without animation, and expires a renderer-abandoned capture", async () => {
298 vi.useFakeTimers();
299 bus.portalVersion = 2;
300 bus.extensionVersion = 2;
301 const snapshot = await captureLinuxWindow(appId, { flash: false, animate: false });
302 expect(snapshot!.feedback!.animationStarted).toBe(false);
303 bus.activationError = new Error("No window");
304 await expect(snapshot!.feedback!.activate("T3 Code")).rejects.toThrow("No window");
305 expect(snapshot!.png).toEqual(png);
306 await vi.advanceTimersByTimeAsync(15_000);
307 expect(bus.disconnect).toHaveBeenCalledOnce();
308});
309
310it.each([false, true])(
311 "accepts an early Response with a changed request path: %s",
312 async (differentHandle) => {
313 bus.differentHandle = differentHandle;
314 expect(await captureLinuxWindow(appId)).toEqual({ png });
315 const screenshot = bus.calls.find((call) => call.member === "Screenshot");
316 expect(screenshot?.body[1]).toMatchObject({
317 target: { signature: "u", value: 8 },
318 interactive: { value: false },
319 });
320 expect(bus.calls.findIndex((call) => call.member === "AddMatch")).toBeLessThan(
321 bus.calls.indexOf(screenshot!),
322 );
323 expect(bus.listenerCount("message")).toBe(0);
324 expect(bus.disconnect).toHaveBeenCalledOnce();
325 },
326);
327
328it.each([1, 2])(
329 "does not try the extension or picker after portal rejection %s",
330 async (status) => {
331 bus.extensionVersion = 1;
332 bus.status = status;
333 await expect(captureLinuxWindow(appId)).rejects.toThrow(
334 status === 1 ? "cancelled" : "did not allow",
335 );
336 expect(bus.calls.some((call) => call.member === "Capture")).toBe(false);
337 expect(bus.disconnect).toHaveBeenCalledOnce();
338 },
339);
340
341it("does not downgrade a failed screenshot request", async () => {
342 bus.screenshotError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied");
343 await expect(captureLinuxWindow(appId)).rejects.toThrow("Denied");
344 expect(bus.send).toHaveBeenCalledWith(expect.objectContaining({ member: "Close" }));
345 expect(bus.disconnect).toHaveBeenCalledOnce();
346});
347
348it.each([false, true])(
349 "times out and closes requests, ignoring forged responses: %s",
350 async (wrongSender) => {
351 vi.useFakeTimers();
352 bus.sendResponse = wrongSender;
353 bus.wrongSender = wrongSender;
354 const capture = expect(captureLinuxWindow(appId)).rejects.toThrow("timed out");
355 await vi.advanceTimersByTimeAsync(120_001);
356 await capture;
357 expect(bus.send).toHaveBeenCalledWith(expect.objectContaining({ member: "Close" }));
358 expect(bus.listenerCount("message")).toBe(0);
359 expect(bus.disconnect).toHaveBeenCalledOnce();
360 },
361);
362
363it("uses the GNOME extension and preserves its window identity", async () => {
364 bus.portalVersion = 2;
365 bus.extensionVersion = 1;
366 expect(await captureLinuxWindow(appId)).toEqual({ png, window: metadata });
367 expect(bus.requestName).toHaveBeenCalledWith(`${appId}.SnapShot`, expect.any(Number));
368 expect(bus.calls.some((call) => call.member === "Screenshot")).toBe(false);
369});
370
371it("fails instead of opening a picker after an extension denial", async () => {
372 bus.portalVersion = 2;
373 bus.extensionVersion = 1;
374 bus.extensionError = new Error("Locked session");
375 await expect(captureLinuxWindow(appId)).rejects.toThrow("Locked session");
376 expect(bus.disconnect).toHaveBeenCalledOnce();
377});
378
379it("allows the picker only when both automatic backends are absent", async () => {
380 bus.portalVersion = 2;
381 expect(await captureLinuxWindow(appId)).toBeUndefined();
382 expect(bus.calls.some((call) => call.member === "Screenshot" || call.member === "Capture")).toBe(
383 false,
384 );
385});
386
387it("tolerates an older portal without Registry, but not registration denial", async () => {
388 bus.registryError = new DBusError("org.freedesktop.DBus.Error.UnknownMethod", "Old portal");
389 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("screenshot-portal");
390 bus.registryError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied identity");
391 await expect(getLinuxCaptureSupport(appId)).rejects.toThrow("Denied identity");
392});
393
394it("does not register a host identity from inside a sandbox", async () => {
395 vi.stubEnv("FLATPAK_ID", appId);
396 await getLinuxCaptureSupport(appId);
397 expect(bus.calls.some((call) => call.member === "Register")).toBe(false);
398});
399
400it("reads only local PNG files without removing the portal file", async () => {
401 expect(await readPortalPng(bus.uri)).toEqual(png);
402 expect(await readPortalPng(bus.uri)).toEqual(png);
403 await expect(readPortalPng("https://example.com/image.png")).rejects.toThrow();
404 await expect(readPortalPng("file://remote/image.png")).rejects.toThrow();
405 await NodeFSP.writeFile(NodePath.join(directory, "image.png"), "not a PNG");
406 await expect(readPortalPng(bus.uri)).rejects.toThrow("Invalid");
407});
408
409it("bounds large images without distorting their aspect ratio", () => {
410 imageSize.width = 4000;
411 imageSize.height = 2000;
412 resizeLinuxCapture(png);
413 expect(resize).toHaveBeenLastCalledWith({ width: 2560, height: 1280, quality: "best" });
414});