// @effect-diagnostics nodeBuiltinImport:off -- Real temporary files exercise the native portal URI boundary. import * as NodeEvents from "node:events"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import { DBusError, Message, MessageType, RequestNameReply, Variant, type MessageBus, } from "dbus-next"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; const { connect, resize, imageSize } = vi.hoisted(() => ({ connect: vi.fn(), resize: vi.fn(() => ({ toPNG: () => Buffer.from("resized") })), imageSize: { width: 800, height: 600 }, })); vi.mock("dbus-next", async (original) => ({ ...(await original()), sessionBus: connect, })); vi.mock("electron", () => ({ nativeImage: { createFromBuffer: () => ({ isEmpty: () => false, getSize: () => imageSize, resize, }), }, })); import { captureLinuxWindow, getLinuxCaptureSupport, readPortalPng, resizeLinuxCapture, } from "./LinuxSnapShot.ts"; const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]); const appId = "com.t3tools.T3Code"; const metadata = { title: "Editor", appName: "Text Editor", appIdentifier: "org.gnome.TextEditor.desktop", processId: 42, bounds: { x: -100, y: 20, width: 800, height: 600 }, }; class FakeBus extends NodeEvents.EventEmitter { calls: Message[] = []; portalVersion = 3; targets: number | undefined = 8; extensionVersion: number | undefined; kdeVersion: number | undefined = 5; kdeError: Error | undefined; status = 0; uri = "file:///missing.png"; registryError: Error | undefined; screenshotError: Error | undefined; extensionError: Error | undefined; activationError: Error | undefined; animation: Promise | undefined; sendResponse = true; differentHandle = false; wrongSender = false; requestName = vi.fn(async () => RequestNameReply.PRIMARY_OWNER); disconnect = vi.fn(); send = vi.fn(); signal(path: string, body: unknown[], sender = ":1.2") { this.emit( "message", new Message({ type: MessageType.SIGNAL, sender, path, interface: "org.freedesktop.portal.Request", member: "Response", body, }), ); } async call(message: Message) { this.calls.push(message); const reply = (body: unknown[]) => new Message({ type: MessageType.METHOD_RETURN, replySerial: "1", destination: ":1.23", body, }); switch (message.member) { case "Register": if (this.registryError) throw this.registryError; return reply([]); case "GetAll": if (message.destination === "org.kde.KWin.ScreenShot2") { if (this.kdeError) throw this.kdeError; if (this.kdeVersion === undefined) throw new DBusError("org.freedesktop.DBus.Error.ServiceUnknown", "Absent"); return reply([{ Version: new Variant("u", this.kdeVersion) }]); } if (message.destination === "org.freedesktop.portal.Desktop") return reply([ { version: new Variant("u", this.portalVersion), ...(this.targets === undefined ? {} : { AvailableTargets: new Variant("u", this.targets) }), }, ]); if (this.extensionVersion === undefined) throw new DBusError("org.freedesktop.DBus.Error.ServiceUnknown", "Absent"); return reply([{ Version: new Variant("u", this.extensionVersion) }]); case "GetNameOwner": return reply([":1.2"]); case "AddMatch": return reply([]); case "Screenshot": { if (this.screenshotError) throw this.screenshotError; const options = message.body[1] as Record>; const handle = `/org/freedesktop/portal/desktop/request/1_23/${this.differentHandle ? "actual" : options.handle_token?.value}`; if (this.sendResponse) this.signal( handle, [this.status, { uri: new Variant("s", this.uri) }], this.wrongSender ? ":1.99" : ":1.2", ); return reply([handle]); } case "Capture": if (this.extensionError) throw this.extensionError; return reply([png, JSON.stringify(metadata)]); case "CaptureWithFeedback": return reply([png, JSON.stringify(metadata), message.body[1]]); case "Activate": if (this.activationError) throw this.activationError; return reply([]); case "Animate": await this.animation; return reply([]); default: throw new Error(`Unexpected method ${message.member}`); } } } let bus: FakeBus; let directory: string; beforeEach(async () => { bus = new FakeBus(); connect.mockImplementation(() => bus as unknown as MessageBus); directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-portal-test-")); bus.uri = NodeURL.pathToFileURL(NodePath.join(directory, "image.png")).href; await NodeFSP.writeFile(NodePath.join(directory, "image.png"), png); imageSize.width = 800; imageSize.height = 600; vi.stubEnv("FLATPAK_ID", ""); vi.stubEnv("SNAP", ""); vi.stubEnv("NIRI_SOCKET", ""); vi.stubEnv("XDG_CURRENT_DESKTOP", "GNOME"); }); it("uses native KWin only in an unsandboxed KDE session", async () => { vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE"); expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("kde"); vi.stubEnv("FLATPAK_ID", "com.example.App"); expect((await getLinuxCaptureSupport(appId)).linuxBackend).not.toBe("kde"); }); it("redetects the desktop without using another session's extension or stale Niri socket", async () => { bus.portalVersion = 2; bus.extensionVersion = 2; vi.stubEnv("NIRI_SOCKET", "/previous-session/niri.sock"); expect(await getLinuxCaptureSupport(appId)).toEqual({ linuxBackend: "gnome-extension", linuxFeedbackAvailable: true, }); vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE"); expect(await getLinuxCaptureSupport(appId)).toEqual({ linuxBackend: "kde", linuxFeedbackAvailable: false, }); bus.kdeVersion = undefined; expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("picker"); vi.stubEnv("XDG_CURRENT_DESKTOP", "GNOME"); expect(await getLinuxCaptureSupport(appId)).toEqual({ linuxBackend: "gnome-extension", linuxFeedbackAvailable: true, }); }); it("uses the standard path when KWin's native capability is missing, but not when denied", async () => { vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE"); bus.kdeVersion = undefined; expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("screenshot-portal"); bus.kdeVersion = 1; bus.portalVersion = 2; expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("picker"); bus.kdeError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied"); await expect(getLinuxCaptureSupport(appId)).rejects.toThrow("Denied"); }); it("does not fall through to the picker when KDE needs helper setup", async () => { vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE"); await expect(captureLinuxWindow(appId)).rejects.toThrow("KDE capture setup"); expect(bus.calls.some((call) => call.member === "Screenshot")).toBe(false); expect(bus.disconnect).toHaveBeenCalledOnce(); }); it("selects Hyprland without probing a different desktop or falling back on missing setup", async () => { vi.stubEnv("XDG_CURRENT_DESKTOP", "Hyprland"); expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("hyprland"); await expect(captureLinuxWindow(appId)).rejects.toThrow("Hyprland capture setup"); expect(bus.calls).toEqual([]); }); afterEach(async () => { vi.useRealTimers(); vi.unstubAllEnvs(); await NodeFSP.rm(directory, { recursive: true }); }); it.each([ [2, 8, 1, "gnome-extension"], [3, undefined, 1, "gnome-extension"], [3, 7, undefined, "picker"], [3, 8, 1, "screenshot-portal"], [4, 15, undefined, "screenshot-portal"], [2, undefined, 2, "gnome-extension"], [2, undefined, 99, "picker"], ] as const)( "selects capabilities v%s / targets %s / extension %s", async (version, targets, extension, expected) => { bus.portalVersion = version; bus.targets = targets; bus.extensionVersion = extension; expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe(expected); expect(bus.calls[0]?.member).toBe("Register"); expect(bus.disconnect).toHaveBeenCalledOnce(); }, ); it.each([1, 2])( "reports feedback separately from capture support for extension v%s", async (version) => { bus.portalVersion = 2; bus.extensionVersion = version; expect(await getLinuxCaptureSupport(appId)).toEqual({ linuxBackend: "gnome-extension", linuxFeedbackAvailable: version === 2, }); }, ); it("retains the authenticated connection until the compositor flight has landed", async () => { bus.portalVersion = 2; bus.extensionVersion = 2; const snapshot = await captureLinuxWindow(appId, { flash: false, animate: true }); const feedback = snapshot!.feedback!; expect(snapshot).toMatchObject({ png, window: metadata, feedback: { animationStarted: true } }); expect(bus.disconnect).not.toHaveBeenCalled(); await feedback.activate("T3 Code"); const landed = Promise.withResolvers(); bus.animation = landed.promise; const flight = feedback.animateTo({ x: 0.1, y: 0.8, width: 0.2, height: 0.1 }); const complete = feedback.complete(); expect(bus.disconnect).not.toHaveBeenCalled(); landed.resolve(); await Promise.all([flight, complete]); expect( bus.calls .filter((call) => ["CaptureWithFeedback", "Activate", "Animate"].includes(call.member)) .map((call) => call.body), ).toEqual([[false, true], ["T3 Code"], [0.1, 0.8, 0.2, 0.1]]); expect(bus.disconnect).toHaveBeenCalledOnce(); feedback.close(); expect(bus.disconnect).toHaveBeenCalledOnce(); }); it("keeps capture working with v1 and does not request unavailable effects", async () => { bus.portalVersion = 2; bus.extensionVersion = 1; expect(await captureLinuxWindow(appId, { flash: true, animate: true })).toEqual({ png, window: metadata, }); expect(bus.calls.some((call) => call.member === "CaptureWithFeedback")).toBe(false); expect(bus.disconnect).toHaveBeenCalledOnce(); }); it("can activate without animation, and expires a renderer-abandoned capture", async () => { vi.useFakeTimers(); bus.portalVersion = 2; bus.extensionVersion = 2; const snapshot = await captureLinuxWindow(appId, { flash: false, animate: false }); expect(snapshot!.feedback!.animationStarted).toBe(false); bus.activationError = new Error("No window"); await expect(snapshot!.feedback!.activate("T3 Code")).rejects.toThrow("No window"); expect(snapshot!.png).toEqual(png); await vi.advanceTimersByTimeAsync(15_000); expect(bus.disconnect).toHaveBeenCalledOnce(); }); it.each([false, true])( "accepts an early Response with a changed request path: %s", async (differentHandle) => { bus.differentHandle = differentHandle; expect(await captureLinuxWindow(appId)).toEqual({ png }); const screenshot = bus.calls.find((call) => call.member === "Screenshot"); expect(screenshot?.body[1]).toMatchObject({ target: { signature: "u", value: 8 }, interactive: { value: false }, }); expect(bus.calls.findIndex((call) => call.member === "AddMatch")).toBeLessThan( bus.calls.indexOf(screenshot!), ); expect(bus.listenerCount("message")).toBe(0); expect(bus.disconnect).toHaveBeenCalledOnce(); }, ); it.each([1, 2])( "does not try the extension or picker after portal rejection %s", async (status) => { bus.extensionVersion = 1; bus.status = status; await expect(captureLinuxWindow(appId)).rejects.toThrow( status === 1 ? "cancelled" : "did not allow", ); expect(bus.calls.some((call) => call.member === "Capture")).toBe(false); expect(bus.disconnect).toHaveBeenCalledOnce(); }, ); it("does not downgrade a failed screenshot request", async () => { bus.screenshotError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied"); await expect(captureLinuxWindow(appId)).rejects.toThrow("Denied"); expect(bus.send).toHaveBeenCalledWith(expect.objectContaining({ member: "Close" })); expect(bus.disconnect).toHaveBeenCalledOnce(); }); it.each([false, true])( "times out and closes requests, ignoring forged responses: %s", async (wrongSender) => { vi.useFakeTimers(); bus.sendResponse = wrongSender; bus.wrongSender = wrongSender; const capture = expect(captureLinuxWindow(appId)).rejects.toThrow("timed out"); await vi.advanceTimersByTimeAsync(120_001); await capture; expect(bus.send).toHaveBeenCalledWith(expect.objectContaining({ member: "Close" })); expect(bus.listenerCount("message")).toBe(0); expect(bus.disconnect).toHaveBeenCalledOnce(); }, ); it("uses the GNOME extension and preserves its window identity", async () => { bus.portalVersion = 2; bus.extensionVersion = 1; expect(await captureLinuxWindow(appId)).toEqual({ png, window: metadata }); expect(bus.requestName).toHaveBeenCalledWith(`${appId}.SnapShot`, expect.any(Number)); expect(bus.calls.some((call) => call.member === "Screenshot")).toBe(false); }); it("fails instead of opening a picker after an extension denial", async () => { bus.portalVersion = 2; bus.extensionVersion = 1; bus.extensionError = new Error("Locked session"); await expect(captureLinuxWindow(appId)).rejects.toThrow("Locked session"); expect(bus.disconnect).toHaveBeenCalledOnce(); }); it("allows the picker only when both automatic backends are absent", async () => { bus.portalVersion = 2; expect(await captureLinuxWindow(appId)).toBeUndefined(); expect(bus.calls.some((call) => call.member === "Screenshot" || call.member === "Capture")).toBe( false, ); }); it("tolerates an older portal without Registry, but not registration denial", async () => { bus.registryError = new DBusError("org.freedesktop.DBus.Error.UnknownMethod", "Old portal"); expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("screenshot-portal"); bus.registryError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied identity"); await expect(getLinuxCaptureSupport(appId)).rejects.toThrow("Denied identity"); }); it("does not register a host identity from inside a sandbox", async () => { vi.stubEnv("FLATPAK_ID", appId); await getLinuxCaptureSupport(appId); expect(bus.calls.some((call) => call.member === "Register")).toBe(false); }); it("reads only local PNG files without removing the portal file", async () => { expect(await readPortalPng(bus.uri)).toEqual(png); expect(await readPortalPng(bus.uri)).toEqual(png); await expect(readPortalPng("https://example.com/image.png")).rejects.toThrow(); await expect(readPortalPng("file://remote/image.png")).rejects.toThrow(); await NodeFSP.writeFile(NodePath.join(directory, "image.png"), "not a PNG"); await expect(readPortalPng(bus.uri)).rejects.toThrow("Invalid"); }); it("bounds large images without distorting their aspect ratio", () => { imageSize.width = 4000; imageSize.height = 2000; resizeLinuxCapture(png); expect(resize).toHaveBeenLastCalledWith({ width: 2560, height: 1280, quality: "best" }); });