1// @effect-diagnostics nodeBuiltinImport:off -- Isolated helper installation fixtures, never the user's data directory.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7const execute = vi.hoisted(() => vi.fn());
8const startEffects = vi.hoisted(() => vi.fn());
9vi.mock("node:child_process", () => ({ execFile: execute }));
10vi.mock("./NativeCaptureFeedback.ts", () => ({ startNativeCaptureFeedback: startEffects }));
11vi.mock("electron", () => ({
12 nativeImage: {
13 createFromBuffer: () => ({ isEmpty: () => false, getSize: () => ({ width: 2, height: 1 }) }),
14 },
15}));
16import {
17 KdeCaptureSetup,
18 captureKdeWindow,
19 isKdeCaptureSession,
20 kdeCaptureDesktopEntry,
21 kdeCapturePaths,
22} from "./KdeSnapShot.ts";
23
24let directory: string;
25let paths: { bundle: string; dataHome: string };
26let setup: KdeCaptureSetup;
27let denial = false;
28const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]);
29const window = {
30 title: "Document",
31 appName: "Kate",
32 appIdentifier: "org.kde.kate",
33 processId: 123,
34 bounds: { x: 20, y: 10, width: 800, height: 600 },
35 clientBounds: { x: 20, y: 39, width: 800, height: 571 },
36};
37
38beforeEach(async () => {
39 directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-kde-test-"));
40 paths = {
41 bundle: NodePath.join(directory, "bundled-helper"),
42 dataHome: NodePath.join(directory, "user data"),
43 };
44 await NodeFSP.writeFile(paths.bundle, "bundled executable");
45 setup = new KdeCaptureSetup(paths);
46 denial = false;
47 startEffects.mockReset();
48 startEffects.mockResolvedValue(undefined);
49 execute.mockReset();
50 execute.mockImplementation(
51 (
52 executable: string,
53 args: string[],
54 _options: unknown,
55 callback: (error: Error | null, stdout: string, stderr: string) => void,
56 ) => {
57 if (args[0] === "capture") {
58 void NodeFSP.writeFile(NodePath.join(args[1]!, "capture.png"), png).then(() =>
59 callback(null, JSON.stringify({ window }), ""),
60 );
61 } else if (args[0] === "check" && denial)
62 callback(new Error("Denied"), "", "org.kde.KWin.ScreenShot2.Error.NoAuthorized");
63 else
64 callback(
65 null,
66 executable === "kbuildsycoca6" ? "" : '{"ready":true,"feedbackAvailable":true}',
67 "",
68 );
69 },
70 );
71});
72afterEach(async () => {
73 await NodeFSP.rm(directory, { recursive: true, force: true });
74});
75
76it("detects KDE only outside a sandbox", () => {
77 expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "KDE" })).toBe(true);
78 expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "GNOME" })).toBe(false);
79 expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "KDE", FLATPAK_ID: "app" })).toBe(false);
80 expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "KDE", SNAP: "/snap/app" })).toBe(false);
81});
82
83it("does not install or request screenshots during initial discovery", async () => {
84 expect((await setup.state()).status).toBe("not-installed");
85 expect(execute).not.toHaveBeenCalled();
86 await expect(NodeFSP.stat(paths.dataHome)).rejects.toMatchObject({ code: "ENOENT" });
87});
88
89it("installs offline at a stable path and verifies permissions using the installed identity", async () => {
90 await setup.perform("install-kde-helper");
91 const { executable, desktop } = kdeCapturePaths(paths);
92 expect(await NodeFSP.readFile(executable, "utf8")).toBe("bundled executable");
93 expect((await NodeFSP.stat(executable)).mode & 0o777).toBe(0o755);
94 expect(await NodeFSP.readFile(desktop, "utf8")).toBe(kdeCaptureDesktopEntry(executable));
95 expect(kdeCaptureDesktopEntry(executable)).toContain(`Exec="${executable}" check`);
96 expect((await setup.state()).status).toBe("ready");
97 expect((await setup.state()).feedbackAvailable).toBe(true);
98 expect(execute.mock.calls.map(([file, args]) => [file, args])).toEqual([
99 ["kbuildsycoca6", ["--noincremental"]],
100 [
101 "systemd-run",
102 [
103 "--user",
104 "--quiet",
105 "--wait",
106 "--collect",
107 "--pipe",
108 "--service-type=exec",
109 "kbuildsycoca6",
110 "--noincremental",
111 ],
112 ],
113 [executable, ["check"]],
114 [executable, ["check"]],
115 ]);
116});
117
118it("does not mistake installed files for KDE authorization, or fall back to a picker on denial", async () => {
119 await setup.perform("install-kde-helper");
120 denial = true;
121 expect(await setup.state()).toMatchObject({
122 status: "error",
123 message: expect.stringContaining("Reinstall"),
124 });
125 await expect(captureKdeWindow(paths)).rejects.toThrow("Settings → SnapShots");
126 expect(execute.mock.calls.some(([, args]) => args[0] === "capture")).toBe(false);
127});
128
129it("detects a bundled update, updates explicitly, and removes only capture's registration", async () => {
130 await setup.perform("install-kde-helper");
131 const unrelated = NodePath.join(paths.dataHome, "applications", "another-app.desktop");
132 await NodeFSP.writeFile(unrelated, "keep me");
133 await NodeFSP.writeFile(paths.bundle, "updated helper");
134 expect((await setup.state()).status).toBe("update-required");
135 await setup.perform("install-kde-helper");
136 expect((await setup.state()).status).toBe("ready");
137 await setup.perform("remove-kde-helper");
138 expect((await setup.state()).status).toBe("not-installed");
139 expect(await NodeFSP.readFile(unrelated, "utf8")).toBe("keep me");
140});
141
142it("repairs the old semicolon permission entry without replacing unrelated registrations", async () => {
143 await setup.perform("install-kde-helper");
144 const { desktop } = kdeCapturePaths(paths);
145 const installed = await NodeFSP.readFile(desktop, "utf8");
146 const permission = "X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2";
147 expect(installed.split("\n")).toContain(permission);
148 await NodeFSP.writeFile(desktop, installed.replace(`${permission}\n`, `${permission};\n`));
149 execute.mockClear();
150 expect((await setup.state()).status).toBe("update-required");
151 expect(execute).not.toHaveBeenCalled();
152 await setup.perform("install-kde-helper");
153 expect((await setup.state()).status).toBe("ready");
154 expect((await NodeFSP.readFile(desktop, "utf8")).split("\n")).toContain(permission);
155 expect(await NodeFSP.readdir(NodePath.dirname(desktop))).toEqual([NodePath.basename(desktop)]);
156});
157
158it("still verifies capture access when a systemd user manager is unavailable", async () => {
159 const executeNormally = execute.getMockImplementation()!;
160 execute.mockImplementation((file, args, options, callback) => {
161 if (file === "systemd-run") callback(new Error("ENOENT"), "", "");
162 else executeNormally(file, args, options, callback);
163 });
164 await setup.perform("install-kde-helper");
165 expect((await setup.state()).status).toBe("ready");
166 denial = true;
167 expect((await setup.state()).status).toBe("error");
168});
169
170it("does not report a successful install when the registry cannot be refreshed", async () => {
171 execute.mockImplementation((_file, _args, _options, callback) =>
172 callback(new Error("Failed"), "", "Failed to rebuild registry"),
173 );
174 await expect(setup.perform("install-kde-helper")).rejects.toThrow("KDE couldn't register");
175});
176
177it("refuses symlink destinations and unrelated desktop entries", async () => {
178 const { desktop, executable } = kdeCapturePaths(paths);
179 await NodeFSP.mkdir(NodePath.dirname(desktop), { recursive: true });
180 await NodeFSP.writeFile(desktop, "[Desktop Entry]\nName=Unrelated");
181 await expect(setup.perform("install-kde-helper")).rejects.toThrow("Another desktop entry");
182 await NodeFSP.unlink(desktop);
183 await NodeFSP.mkdir(NodePath.dirname(executable), { recursive: true });
184 await NodeFSP.symlink(paths.bundle, executable);
185 await expect(setup.perform("install-kde-helper")).rejects.toThrow("regular files");
186 expect(await NodeFSP.readFile(paths.bundle, "utf8")).toBe("bundled executable");
187});
188
189it("captures with verified identity, cleans private files, and activates only the owning T3 PID/title", async () => {
190 await setup.perform("install-kde-helper");
191 const capture = await captureKdeWindow(paths);
192 expect(capture.png).toEqual(png);
193 expect(capture.window).toEqual(window);
194 expect(capture.feedback?.animationStarted).toBe(false);
195 const [, args] = execute.mock.calls.find(([, args]) => args[0] === "capture")!;
196 await expect(NodeFSP.stat(args[1])).rejects.toMatchObject({ code: "ENOENT" });
197 await capture.feedback?.activate('My "Draft"');
198 const [, activation] = execute.mock.calls.find(([, args]) => args[0] === "activate")!;
199 expect(activation.slice(2)).toEqual([String(process.pid), 'My "Draft"']);
200 await expect(NodeFSP.stat(activation[1])).rejects.toMatchObject({ code: "ENOENT" });
201 capture.feedback?.close();
202});
203
204it("retains the frozen screenshot for feedback and removes it after the renderer acknowledges landing", async () => {
205 await setup.perform("install-kde-helper");
206 const closed = Promise.withResolvers<void>();
207 const animateTo = vi.fn().mockResolvedValue(undefined);
208 startEffects.mockResolvedValue({
209 animationStarted: true,
210 closed: closed.promise,
211 animateTo,
212 complete: async () => {
213 closed.resolve();
214 },
215 close: () => closed.resolve(),
216 });
217 const capture = await captureKdeWindow(paths, { flash: true, animate: true });
218 const [, args] = execute.mock.calls.find(([, args]) => args[0] === "capture")!;
219 expect(await NodeFSP.readFile(NodePath.join(args[1], "capture.png"))).toEqual(png);
220 expect(capture.feedback?.animationStarted).toBe(true);
221 await capture.feedback?.activate("Draft");
222 const frame = { x: 0.2, y: 0.5, width: 0.1, height: 0.2 };
223 await capture.feedback?.animateTo(frame);
224 expect(animateTo).toHaveBeenCalledWith("Draft", frame);
225 await capture.feedback?.complete();
226 await expect(NodeFSP.stat(args[1])).rejects.toMatchObject({ code: "ENOENT" });
227});
228
229it("keeps a successfully captured image when optional KDE effects fail", async () => {
230 await setup.perform("install-kde-helper");
231 startEffects.mockRejectedValue(new Error("QML unavailable"));
232 const capture = await captureKdeWindow(paths, { flash: true, animate: true });
233 expect(capture.png).toEqual(png);
234 expect(capture.feedback?.animationStarted).toBe(false);
235 const [, args] = execute.mock.calls.find(([, args]) => args[0] === "capture")!;
236 await expect(NodeFSP.stat(args[1])).rejects.toMatchObject({ code: "ENOENT" });
237 capture.feedback?.close();
238});