Skip to content
Source
Sign inSign up

LinuxSnapShot.test.ts

318 lines11.4 KB

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 { captureLinuxWindow, getLinuxCaptureSupport, readPortalPng } from "./LinuxSnapShot.ts";
37​
38const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]);
39const appId = "com.t3tools.T3Code";
40const metadata = {
41 title: "Editor",
42 appName: "Text Editor",
43 appIdentifier: "org.gnome.TextEditor.desktop",
44 processId: 42,
45 bounds: { x: -100, y: 20, width: 800, height: 600 },
46};
47​
48class FakeBus extends NodeEvents.EventEmitter {
49 calls: Message[] = [];
50 portalVersion = 3;
51 targets: number | undefined = 8;
52 extensionVersion: number | undefined;
53 kdeVersion: number | undefined = 5;
54 kdeError: Error | undefined;
55 status = 0;
56 uri = "file:///missing.png";
57 registryError: Error | undefined;
58 screenshotError: Error | undefined;
59 extensionError: Error | undefined;
60 activationError: Error | undefined;
61 animation: Promise<void> | undefined;
62 sendResponse = true;
63 differentHandle = false;
64 wrongSender = false;
65 requestName = vi.fn(async () => RequestNameReply.PRIMARY_OWNER);
66 disconnect = vi.fn();
67 send = vi.fn();
68​
69 signal(path: string, body: unknown[], sender = ":1.2") {
70 this.emit(
71 "message",
72 new Message({
73 type: MessageType.SIGNAL,
74 sender,
75 path,
76 interface: "org.freedesktop.portal.Request",
77 member: "Response",
78 body,
79 }),
80 );
81 }
82​
83 async call(message: Message) {
84 this.calls.push(message);
85 const reply = (body: unknown[]) =>
86 new Message({
87 type: MessageType.METHOD_RETURN,
88 replySerial: "1",
89 destination: ":1.23",
90 body,
91 });
92 switch (message.member) {
93 case "Register":
94 if (this.registryError) throw this.registryError;
95 return reply([]);
96 case "GetAll":
97 if (message.destination === "org.kde.KWin.ScreenShot2") {
98 if (this.kdeError) throw this.kdeError;
99 if (this.kdeVersion === undefined)
100 throw new DBusError("org.freedesktop.DBus.Error.ServiceUnknown", "Absent");
101 return reply([{ Version: new Variant("u", this.kdeVersion) }]);
102 }
103 if (message.destination === "org.freedesktop.portal.Desktop")
104 return reply([
105 {
106 version: new Variant("u", this.portalVersion),
107 ...(this.targets === undefined
108 ? {}
109 : { AvailableTargets: new Variant("u", this.targets) }),
110 },
111 ]);
112 if (this.extensionVersion === undefined)
113 throw new DBusError("org.freedesktop.DBus.Error.ServiceUnknown", "Absent");
114 return reply([{ Version: new Variant("u", this.extensionVersion) }]);
115 case "GetNameOwner":
116 return reply([":1.2"]);
117 case "AddMatch":
118 return reply([]);
119 case "Screenshot": {
120 if (this.screenshotError) throw this.screenshotError;
121 const options = message.body[1] as Record<string, Variant<unknown>>;
122 const handle = `/org/freedesktop/portal/desktop/request/1_23/${this.differentHandle ? "actual" : options.handle_token?.value}`;
123 if (this.sendResponse)
124 this.signal(
125 handle,
126 [this.status, { uri: new Variant("s", this.uri) }],
127 this.wrongSender ? ":1.99" : ":1.2",
128 );
129 return reply([handle]);
130 }
131 case "Capture":
132 if (this.extensionError) throw this.extensionError;
133 return reply([png, JSON.stringify(metadata)]);
134 case "CaptureWithFeedback":
135 return reply([png, JSON.stringify(metadata), message.body[1]]);
136 case "Activate":
137 if (this.activationError) throw this.activationError;
138 return reply([]);
139 case "Animate":
140 await this.animation;
141 return reply([]);
142 default:
143 throw new Error(`Unexpected method ${message.member}`);
144 }
145 }
147​
148let bus: FakeBus;
149let directory: string;
150beforeEach(async () => {
151 bus = new FakeBus();
152 connect.mockImplementation(() => bus as unknown as MessageBus);
153 directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-portal-test-"));
154 bus.uri = NodeURL.pathToFileURL(NodePath.join(directory, "image.png")).href;
155 await NodeFSP.writeFile(NodePath.join(directory, "image.png"), png);
156 imageSize.width = 800;
157 imageSize.height = 600;
158 vi.stubEnv("FLATPAK_ID", "");
159 vi.stubEnv("SNAP", "");
160 vi.stubEnv("NIRI_SOCKET", "");
161 vi.stubEnv("XDG_CURRENT_DESKTOP", "GNOME");
162});
163​
164it("uses native KWin only in an unsandboxed KDE session", async () => {
165 vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE");
166 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("kde");
167 vi.stubEnv("FLATPAK_ID", "com.example.App");
168 expect((await getLinuxCaptureSupport(appId)).linuxBackend).not.toBe("kde");
169});
170​
171it("redetects the desktop without using another session's extension or stale Niri socket", async () => {
172 bus.portalVersion = 2;
173 bus.extensionVersion = 2;
174 vi.stubEnv("NIRI_SOCKET", "/previous-session/niri.sock");
175 expect(await getLinuxCaptureSupport(appId)).toEqual({
176 linuxBackend: "gnome-extension",
177 linuxFeedbackAvailable: true,
178 });
179​
180 vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE");
181 expect(await getLinuxCaptureSupport(appId)).toEqual({
182 linuxBackend: "kde",
183 linuxFeedbackAvailable: false,
184 });
185 bus.kdeVersion = undefined;
186 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("picker");
187​
188 vi.stubEnv("XDG_CURRENT_DESKTOP", "GNOME");
189 expect(await getLinuxCaptureSupport(appId)).toEqual({
190 linuxBackend: "gnome-extension",
191 linuxFeedbackAvailable: true,
192 });
193});
194​
195it("uses the standard path when KWin's native capability is missing, but not when denied", async () => {
196 vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE");
197 bus.kdeVersion = undefined;
198 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("screenshot-portal");
199 bus.kdeVersion = 1;
200 bus.portalVersion = 2;
201 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("picker");
202 bus.kdeError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied");
203 await expect(getLinuxCaptureSupport(appId)).rejects.toThrow("Denied");
204});
205afterEach(async () => {
206 vi.useRealTimers();
207 vi.unstubAllEnvs();
208 await NodeFSP.rm(directory, { recursive: true });
209});
210​
211it.each([
212 [2, 8, 1, "gnome-extension"],
213 [3, undefined, 1, "gnome-extension"],
214 [3, 7, undefined, "picker"],
215 [3, 8, 1, "screenshot-portal"],
216 [4, 15, undefined, "screenshot-portal"],
217 [2, undefined, 2, "gnome-extension"],
218 [2, undefined, 99, "picker"],
219] as const)(
220 "selects capabilities v%s / targets %s / extension %s",
221 async (version, targets, extension, expected) => {
222 bus.portalVersion = version;
223 bus.targets = targets;
224 bus.extensionVersion = extension;
225 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe(expected);
226 expect(bus.calls[0]?.member).toBe("Register");
227 expect(bus.disconnect).toHaveBeenCalledOnce();
228 },
229);
230​
231it("retains the authenticated connection until the compositor flight has landed", async () => {
232 bus.portalVersion = 2;
233 bus.extensionVersion = 2;
234 const snapshot = await captureLinuxWindow(appId, { flash: false, animate: true });
235 const feedback = snapshot!.feedback!;
236 expect(snapshot).toMatchObject({ png, window: metadata, feedback: { animationStarted: true } });
237 expect(bus.disconnect).not.toHaveBeenCalled();
238 await feedback.activate("T3 Code");
239 const landed = Promise.withResolvers<void>();
240 bus.animation = landed.promise;
241 const flight = feedback.animateTo({ x: 0.1, y: 0.8, width: 0.2, height: 0.1 });
242 const complete = feedback.complete();
243 expect(bus.disconnect).not.toHaveBeenCalled();
244 landed.resolve();
245 await Promise.all([flight, complete]);
246 expect(
247 bus.calls
248 .filter((call) => ["CaptureWithFeedback", "Activate", "Animate"].includes(call.member))
249 .map((call) => call.body),
250 ).toEqual([[false, true], ["T3 Code"], [0.1, 0.8, 0.2, 0.1]]);
251 expect(bus.disconnect).toHaveBeenCalledOnce();
252 feedback.close();
253 expect(bus.disconnect).toHaveBeenCalledOnce();
254});
255​
256it("keeps capture working with v1 and does not request unavailable effects", async () => {
257 bus.portalVersion = 2;
258 bus.extensionVersion = 1;
259 expect(await captureLinuxWindow(appId, { flash: true, animate: true })).toEqual({
260 png,
261 window: metadata,
262 });
263 expect(bus.calls.some((call) => call.member === "CaptureWithFeedback")).toBe(false);
264 expect(bus.disconnect).toHaveBeenCalledOnce();
265});
266​
267it.each([false, true])(
268 "accepts an early Response with a changed request path: %s",
269 async (differentHandle) => {
270 bus.differentHandle = differentHandle;
271 expect(await captureLinuxWindow(appId)).toEqual({ png });
272 const screenshot = bus.calls.find((call) => call.member === "Screenshot");
273 expect(screenshot?.body[1]).toMatchObject({
274 target: { signature: "u", value: 8 },
275 interactive: { value: false },
276 });
277 expect(bus.calls.findIndex((call) => call.member === "AddMatch")).toBeLessThan(
278 bus.calls.indexOf(screenshot!),
279 );
280 expect(bus.listenerCount("message")).toBe(0);
281 expect(bus.disconnect).toHaveBeenCalledOnce();
282 },
283);
284​
285it.each([1, 2])(
286 "does not try the extension or picker after portal rejection %s",
287 async (status) => {
288 bus.extensionVersion = 1;
289 bus.status = status;
290 await expect(captureLinuxWindow(appId)).rejects.toThrow(
291 status === 1 ? "cancelled" : "did not allow",
292 );
293 expect(bus.calls.some((call) => call.member === "Capture")).toBe(false);
294 expect(bus.disconnect).toHaveBeenCalledOnce();
295 },
296);
297​
298it("tolerates an older portal without Registry, but not registration denial", async () => {
299 bus.registryError = new DBusError("org.freedesktop.DBus.Error.UnknownMethod", "Old portal");
300 expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("screenshot-portal");
301 bus.registryError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied identity");
302 await expect(getLinuxCaptureSupport(appId)).rejects.toThrow("Denied identity");
303});
304​
305it("does not register a host identity from inside a sandbox", async () => {
306 vi.stubEnv("FLATPAK_ID", appId);
307 await getLinuxCaptureSupport(appId);
308 expect(bus.calls.some((call) => call.member === "Register")).toBe(false);
309});
310​
311it("reads only local PNG files without removing the portal file", async () => {
312 expect(await readPortalPng(bus.uri)).toEqual(png);
313 expect(await readPortalPng(bus.uri)).toEqual(png);
314 await expect(readPortalPng("https://example.com/image.png")).rejects.toThrow();
315 await expect(readPortalPng("file://remote/image.png")).rejects.toThrow();
316 await NodeFSP.writeFile(NodePath.join(directory, "image.png"), "not a PNG");
317 await expect(readPortalPng(bus.uri)).rejects.toThrow("Invalid");
318});