| 1 | // @effect-diagnostics nodeBuiltinImport:off -- Isolated helper installation fixtures, never the user's data directory. |
| 2 | import * as NodeFSP from "node:fs/promises"; |
| 3 | import * as NodeOS from "node:os"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 6 | |
| 7 | const execute = vi.hoisted(() => vi.fn()); |
| 8 | const startEffects = vi.hoisted(() => vi.fn()); |
| 9 | vi.mock("node:child_process", () => ({ execFile: execute })); |
| 10 | vi.mock("./NativeCaptureFeedback.ts", () => ({ startNativeCaptureFeedback: startEffects })); |
| 11 | vi.mock("electron", () => ({ |
| 12 | nativeImage: { |
| 13 | createFromBuffer: () => ({ isEmpty: () => false, getSize: () => ({ width: 2, height: 1 }) }), |
| 14 | }, |
| 15 | })); |
| 16 | import { |
| 17 | KdeCaptureSetup, |
| 18 | captureKdeWindow, |
| 19 | isKdeCaptureSession, |
| 20 | kdeCaptureDesktopEntry, |
| 21 | kdeCapturePaths, |
| 22 | } from "./KdeSnapShot.ts"; |
| 23 | |
| 24 | let directory: string; |
| 25 | let paths: { bundle: string; dataHome: string }; |
| 26 | let setup: KdeCaptureSetup; |
| 27 | let denial = false; |
| 28 | const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]); |
| 29 | const 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 | |
| 38 | beforeEach(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 | }); |
| 72 | afterEach(async () => { |
| 73 | await NodeFSP.rm(directory, { recursive: true, force: true }); |
| 74 | }); |
| 75 | |
| 76 | it("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 | |
| 83 | it("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 | |
| 89 | it("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 | |
| 118 | it("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 | |
| 129 | it("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 | |
| 142 | it("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 | |
| 158 | it("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 | |
| 170 | it("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 | |
| 177 | it("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 | |
| 189 | it("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 | |
| 204 | it("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 | |
| 229 | it("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 | }); |