1// @effect-diagnostics nodeBuiltinImport:off -- Isolated D-Bus integration fixture, never the user's session bus.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeEvents from "node:events";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
7import * as NodeReadline from "node:readline";
8import * as NodeURL from "node:url";
9import { Message, NameFlag, Variant, sessionBus, type MessageBus } from "dbus-next";
10import { expect, it, vi } from "vite-plus/test";
11
12vi.mock("electron", () => ({
13 nativeImage: {
14 createFromBuffer: () => ({
15 isEmpty: () => false,
16 getSize: () => ({ width: 800, height: 600 }),
17 }),
18 },
19}));
20import { LinuxCaptureConnection } from "./LinuxSnapShot.ts";
21import { startNiriCaptureShortcut } from "./NiriCaptureShortcut.ts";
22import { GnomeCaptureSetup } from "./GnomeCaptureSetup.ts";
23import { GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts";
24
25const hasDbus =
26 NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0 &&
27 NodeChildProcess.spawnSync("gdbus", ["help"]).status === 0;
28
29it.runIf(hasDbus)("captures through real D-Bus marshalling on a private bus", async () => {
30 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-private-dbus-"));
31 let daemon: NodeChildProcess.ChildProcess | undefined;
32 let server: MessageBus | undefined;
33 const clients: LinuxCaptureConnection[] = [];
34 let stopNiriShortcut: (() => void) | undefined;
35 try {
36 vi.stubEnv("XDG_CURRENT_DESKTOP", "GNOME");
37 vi.stubEnv("FLATPAK_ID", "");
38 vi.stubEnv("SNAP", "");
39 daemon = NodeChildProcess.spawn(
40 "dbus-daemon",
41 [
42 "--session",
43 "--nofork",
44 "--nopidfile",
45 "--print-address",
46 `--address=unix:path=${NodePath.join(directory, "bus")}`,
47 ],
48 { stdio: ["ignore", "pipe", "pipe"] },
49 );
50 const lines = NodeReadline.createInterface({ input: daemon.stdout! });
51 const [address] = await Promise.race([
52 NodeEvents.EventEmitter.once(lines, "line"),
53 NodeEvents.EventEmitter.once(daemon, "exit").then(() => {
54 throw new Error("Private D-Bus daemon failed to start.");
55 }),
56 ]);
57 lines.close();
58 server = sessionBus({ busAddress: String(address) });
59 server.on("error", () => undefined);
60 await server.requestName("org.freedesktop.portal.Desktop", NameFlag.DO_NOT_QUEUE);
61 await server.requestName("org.gnome.Shell.Extensions.T3SnapShot", NameFlag.DO_NOT_QUEUE);
62 await server.requestName("org.gnome.Shell", NameFlag.DO_NOT_QUEUE);
63 await server.requestName("org.kde.KWin.ScreenShot2", NameFlag.DO_NOT_QUEUE);
64 const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]);
65 const imagePath = NodePath.join(directory, "image.png");
66 await NodeFSP.writeFile(imagePath, png);
67 let portalVersion = 3;
68 let extensionVersion = 1;
69 let feedbackArgs: unknown[] | undefined;
70 let activateTitle: unknown;
71 let animateFrame: unknown[] | undefined;
72 let target: unknown;
73 let clientName: string | undefined;
74 let shellExtensionState = 2;
75 server.addMethodHandler((message: Message) => {
76 if (message.member === "Register") {
77 server!.send(Message.newMethodReturn(message));
78 } else if (message.member === "GetAll") {
79 const properties =
80 message.body[0] === "org.gnome.Shell.Extensions"
81 ? {
82 ShellVersion: new Variant("s", "50.0"),
83 UserExtensionsEnabled: new Variant("b", true),
84 }
85 : message.body[0] === "org.kde.KWin.ScreenShot2"
86 ? { Version: new Variant("u", 5) }
87 : message.body[0] === "org.freedesktop.portal.Screenshot"
88 ? {
89 version: new Variant("u", portalVersion),
90 AvailableTargets: new Variant("u", 8),
91 }
92 : { Version: new Variant("u", extensionVersion) };
93 server!.send(Message.newMethodReturn(message, "a{sv}", [properties]));
94 } else if (message.member === "GetExtensionInfo") {
95 server!.send(
96 Message.newMethodReturn(message, "a{sv}", [
97 { state: new Variant("d", shellExtensionState), version: new Variant("d", 2) },
98 ]),
99 );
100 } else if (message.member === "EnableExtension" || message.member === "DisableExtension") {
101 expect(message.body).toEqual([GNOME_CAPTURE_UUID]);
102 shellExtensionState = message.member === "EnableExtension" ? 1 : 2;
103 server!.send(Message.newMethodReturn(message, "b", [true]));
104 } else if (message.member === "Screenshot") {
105 const options = message.body[1] as Record<string, Variant<unknown>>;
106 target = options.target?.value;
107 const path = `/org/freedesktop/portal/desktop/request/${message.sender.slice(1).replaceAll(".", "_")}/${options.handle_token?.value}`;
108 // The real transport also has to handle Response before the method reply.
109 const signal = Message.newSignal(
110 path,
111 "org.freedesktop.portal.Request",
112 "Response",
113 "ua{sv}",
114 [0, { uri: new Variant("s", NodeURL.pathToFileURL(imagePath).href) }],
115 );
116 signal.destination = message.sender;
117 server!.send(signal);
118 server!.send(Message.newMethodReturn(message, "o", [path]));
119 } else if (message.member === "Activate") {
120 activateTitle = message.body[0];
121 server!.send(Message.newMethodReturn(message));
122 } else if (message.member === "Animate") {
123 animateFrame = message.body;
124 server!.send(Message.newMethodReturn(message));
125 } else if (message.member === "Capture" || message.member === "CaptureWithFeedback") {
126 const feedback = message.member === "CaptureWithFeedback";
127 if (feedback) feedbackArgs = message.body;
128 clientName = message.sender;
129 server!.send(
130 Message.newMethodReturn(message, feedback ? "aysb" : "ays", [
131 png,
132 JSON.stringify({
133 title: "Editor",
134 appName: "Editor",
135 appIdentifier: "editor.desktop",
136 processId: 42,
137 bounds: { x: 0, y: 0, width: 800, height: 600 },
138 }),
139 ...(feedback ? [true] : []),
140 ]),
141 );
142 } else return false;
143 return true;
144 });
145 const connect = () => {
146 const client = new LinuxCaptureConnection(sessionBus({ busAddress: String(address) }));
147 clients.push(client);
148 return client;
149 };
150 const setup = new GnomeCaptureSetup(
151 {
152 bundle: NodePath.resolve(import.meta.dirname, "../../gnome-extension"),
153 dataHome: directory,
154 },
155 sessionBus({ busAddress: String(address) }),
156 );
157 try {
158 expect((await setup.state()).status).toBe("disabled");
159 await setup.perform("enable-extension");
160 expect((await setup.state()).status).toBe("enabled");
161 await setup.perform("disable-extension");
162 expect((await setup.state()).status).toBe("disabled");
163 } finally {
164 setup.close();
165 }
166 const portal = connect();
167 expect(await portal.backend("com.t3tools.T3Code")).toBe("screenshot-portal");
168 expect(await portal.capturePortal()).toEqual({ png });
169 expect(target).toBe(8);
170 portalVersion = 2;
171 const extension = connect();
172 expect(await extension.backend("com.t3tools.T3Code")).toBe("gnome-extension");
173 expect(await extension.captureExtension("com.t3tools.T3Code")).toMatchObject({
174 png,
175 window: { processId: 42 },
176 });
177 const owner = await server.call(
178 new Message({
179 destination: "org.freedesktop.DBus",
180 path: "/org/freedesktop/DBus",
181 interface: "org.freedesktop.DBus",
182 member: "GetNameOwner",
183 signature: "s",
184 body: ["com.t3tools.T3Code.SnapShot"],
185 }),
186 );
187 expect(owner?.body[0]).toBe(clientName);
188 extension.close();
189 extensionVersion = 2;
190 const updated = connect();
191 expect(await updated.backend("com.t3tools.T3Code")).toBe("gnome-extension");
192 const snapshot = await updated.captureExtension("com.t3tools.T3Code", {
193 flash: true,
194 animate: true,
195 });
196 expect(snapshot.feedback?.animationStarted).toBe(true);
197 await snapshot.feedback!.activate("T3 Code");
198 await snapshot.feedback!.animateTo({ x: 0.1, y: 0.8, width: 0.2, height: 0.1 });
199 await snapshot.feedback!.complete();
200 expect(feedbackArgs).toEqual([true, true]);
201 expect(activateTitle).toBe("T3 Code");
202 expect(animateFrame).toEqual([0.1, 0.8, 0.2, 0.1]);
203 vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE");
204 const kde = connect();
205 expect(await kde.backend("com.t3tools.T3Code")).toBe("kde");
206 expect(kde.feedbackAvailable).toBe(false);
207 kde.close();
208 const triggered = vi.fn();
209 const failed = vi.fn();
210 const niriBus = sessionBus({ busAddress: String(address) });
211 stopNiriShortcut = await startNiriCaptureShortcut(
212 "com.t3tools.T3Code.NiriTest",
213 triggered,
214 failed,
215 niriBus,
216 );
217 const command = [
218 "call",
219 "--session",
220 "--dest",
221 "com.t3tools.T3Code.NiriTest.SnapShot",
222 "--object-path",
223 "/com/t3tools/SnapShot",
224 "--method",
225 "com.t3tools.SnapShot.Capture",
226 ];
227 // Exercise the actual command copied to Niri's configuration, including gdbus introspection.
228 await new Promise<void>((resolve, reject) => {
229 NodeChildProcess.execFile(
230 "gdbus",
231 command,
232 {
233 env: { ...process.env, DBUS_SESSION_BUS_ADDRESS: String(address) },
234 timeout: 3_000,
235 },
236 (error) => (error ? reject(error) : resolve()),
237 );
238 });
239 expect(triggered).toHaveBeenCalledOnce();
240 expect(failed).not.toHaveBeenCalled();
241 const invalid = server.call(
242 new Message({
243 destination: "com.t3tools.T3Code.NiriTest.SnapShot",
244 path: "/com/t3tools/SnapShot",
245 interface: "com.t3tools.SnapShot",
246 member: "Capture",
247 signature: "s",
248 body: ["not allowed"],
249 }),
250 );
251 await expect(invalid).rejects.toThrow("Capture takes no arguments");
252 expect(triggered).toHaveBeenCalledOnce();
253 await expect(
254 startNiriCaptureShortcut(
255 "com.t3tools.T3Code.NiriTest",
256 triggered,
257 failed,
258 sessionBus({ busAddress: String(address) }),
259 ),
260 ).rejects.toThrow("already owns");
261 stopNiriShortcut();
262 const restarted = await startNiriCaptureShortcut(
263 "com.t3tools.T3Code.NiriTest",
264 triggered,
265 failed,
266 sessionBus({ busAddress: String(address) }),
267 );
268 restarted();
269 } finally {
270 vi.unstubAllEnvs();
271 stopNiriShortcut?.();
272 for (const client of clients) client.close();
273 server?.disconnect();
274 if (daemon && daemon.exitCode === null) {
275 const exited = NodeEvents.EventEmitter.once(daemon, "exit");
276 daemon.kill("SIGTERM");
277 await exited;
278 }
279 await NodeFSP.rm(directory, { recursive: true });
280 }
281});