// @effect-diagnostics globalTimers:off -- D-Bus deadlines run at a native async boundary. // @effect-diagnostics nodeBuiltinImport:off -- Read the portal file through one bounded file descriptor at the native adapter boundary. import * as NodeCrypto from "node:crypto"; import { DBusError, Message, MessageFlag, MessageType, NameFlag, RequestNameReply, Variant, sessionBus, type MessageBus, type MessageLike, } from "dbus-next"; import * as Schema from "effect/Schema"; import { isKdeCaptureSession, type KdeCapturePaths } from "./KdeSnapShot.ts"; import { isGnomeCaptureSession, readPortalPng, resizeLinuxCapture } from "./linuxCaptureSession.ts"; export { readPortalPng, resizeLinuxCapture } from "./linuxCaptureSession.ts"; import { isHyprlandCaptureSession, type HyprlandCapturePaths } from "./HyprlandSnapShot.ts"; const PORTAL = "org.freedesktop.portal.Desktop"; const PORTAL_PATH = "/org/freedesktop/portal/desktop"; const SCREENSHOT = "org.freedesktop.portal.Screenshot"; const REQUEST = "org.freedesktop.portal.Request"; const EXTENSION = "org.gnome.Shell.Extensions.T3SnapShot"; const EXTENSION_PATH = "/org/gnome/Shell/Extensions/T3SnapShot"; const DBUS = "org.freedesktop.DBus"; const DBUS_PATH = "/org/freedesktop/DBus"; const UInt = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 0xffff_ffff })); const UIntVariant = Schema.Struct({ signature: Schema.Literal("u"), value: UInt }); const StringVariant = Schema.Struct({ signature: Schema.Literal("s"), value: Schema.String }); const PortalProperties = Schema.Struct({ version: UIntVariant, AvailableTargets: Schema.optional(UIntVariant), }); const WindowMetadata = Schema.Struct({ title: Schema.String, appName: Schema.String, appIdentifier: Schema.String, processId: UInt, bounds: Schema.Struct({ x: Schema.Int, y: Schema.Int, width: Schema.Int.check(Schema.isGreaterThan(0)), height: Schema.Int.check(Schema.isGreaterThan(0)), }), }); export type LinuxWindowMetadata = typeof WindowMetadata.Type & { readonly clientBounds?: typeof WindowMetadata.Type.bounds; readonly accessibilityBoundsReliable?: boolean; }; const decodeProperties = Schema.decodeUnknownSync(PortalProperties); const decodeExtension = Schema.decodeUnknownSync(Schema.Struct({ Version: UIntVariant })); const decodeKde = Schema.decodeUnknownSync(Schema.Struct({ Version: UIntVariant })); const decodeString = Schema.decodeUnknownSync(Schema.String); const decodeBoolean = Schema.decodeUnknownSync(Schema.Boolean); const decodeResponse = Schema.decodeUnknownSync( Schema.Tuple([UInt, Schema.Record(Schema.String, Schema.Unknown)]), ); const decodeUri = Schema.decodeUnknownSync(StringVariant); const decodeWindow = Schema.decodeUnknownSync(Schema.fromJsonString(WindowMetadata)); export type LinuxCaptureBackend = | "screenshot-portal" | "gnome-extension" | "niri" | "kde" | "hyprland" | "picker"; export type LinuxWindowSnapshot = { readonly png: Buffer; readonly window?: LinuxWindowMetadata; readonly feedback?: LinuxCaptureFeedback; }; export type LinuxCaptureFeedback = { readonly animationStarted: boolean; readonly activate: (title: string) => Promise; readonly animateTo: (frame: { x: number; y: number; width: number; height: number; }) => Promise; readonly complete: () => Promise; readonly close: () => void; }; type FeedbackOptions = { readonly flash: boolean; readonly animate: boolean }; function unavailable(error: unknown): boolean { return ( error instanceof DBusError && ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod"].some( (name) => error.type === `${DBUS}.Error.${name}`, ) ); } /** One connection per operation: portal registration and request ownership stay together. */ export class LinuxCaptureConnection { private readonly bus: MessageBus; private readonly disconnected: Promise; private uniqueName = ""; private extensionVersion = 0; private closed = false; private feedbackTimer: ReturnType | undefined; get feedbackAvailable() { return this.extensionVersion === 2; } constructor(bus = sessionBus()) { this.bus = bus; this.disconnected = new Promise((_, reject) => { bus.on("error", reject); }); // A disconnect between calls must not become an unhandled rejection. void this.disconnected.catch(() => undefined); } close() { if (this.closed) return; this.closed = true; clearTimeout(this.feedbackTimer); this.bus.disconnect(); } private async wait(pending: Promise, timeoutMs = 5_000): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ pending, this.disconnected, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Snapshot timed out.")), timeoutMs); }), ]); } finally { clearTimeout(timer); } } private async call(message: MessageLike) { const reply = await this.wait(this.bus.call(new Message(message))); if (!reply) throw new Error("Missing D-Bus reply."); this.uniqueName = reply.destination; return reply; } private async properties(destination: string, path: string, iface: string) { const reply = await this.call({ destination, path, interface: "org.freedesktop.DBus.Properties", member: "GetAll", signature: "s", body: [iface], }); return reply.body[0] as unknown; } async backend(appId: string): Promise { if (isKdeCaptureSession()) { const kde = await this.properties( "org.kde.KWin.ScreenShot2", "/org/kde/KWin/ScreenShot2", "org.kde.KWin.ScreenShot2", ).catch((error: unknown) => { if (!unavailable(error)) throw error; return undefined; }); if (kde !== undefined && decodeKde(kde).Version.value >= 2) return "kde"; } // Sandboxed apps already have an identity. Host apps register before any portal calls. if (!process.env.FLATPAK_ID && !process.env.SNAP) { await this.call({ destination: PORTAL, path: PORTAL_PATH, interface: "org.freedesktop.host.portal.Registry", member: "Register", signature: "sa{sv}", body: [appId, {}], }).catch((error: unknown) => { if (!unavailable(error)) throw error; }); } const properties = await this.properties(PORTAL, PORTAL_PATH, SCREENSHOT).catch( (error: unknown) => { if (!unavailable(error)) throw error; return undefined; }, ); if (properties !== undefined) { const parsed = decodeProperties(properties); if (parsed.version.value >= 3 && ((parsed.AvailableTargets?.value ?? 0) & 8) !== 0) { return "screenshot-portal"; } } if (!isGnomeCaptureSession(process.env)) return "picker"; const extension = await this.properties(EXTENSION, EXTENSION_PATH, EXTENSION).catch( (error: unknown) => { if (!unavailable(error)) throw error; return undefined; }, ); if (extension !== undefined) { const parsed = decodeExtension(extension); this.extensionVersion = parsed.Version.value; if (this.extensionVersion === 1 || this.extensionVersion === 2) return "gnome-extension"; } return "picker"; } async capturePortal(): Promise { const owner = await this.call({ destination: DBUS, path: DBUS_PATH, interface: DBUS, member: "GetNameOwner", signature: "s", body: [PORTAL], }); const sender = decodeString(owner.body[0]); const token = `t3_${NodeCrypto.randomUUID().replaceAll("-", "")}`; const namespace = `${PORTAL_PATH}/request/${this.uniqueName.slice(1).replaceAll(".", "_")}/`; let handle = namespace + token; let completed = false; // Subscribe before Screenshot: the response may precede its method reply. const responses = new Map(); let deliver: ((body: unknown) => void) | undefined; const listener = (message: Message) => { if ( message.type !== MessageType.SIGNAL || message.sender !== sender || message.interface !== REQUEST || message.member !== "Response" || !message.path.startsWith(namespace) ) return; if (deliver && message.path === handle) deliver(message.body); else if (responses.size < 8) responses.set(message.path, message.body); }; this.bus.on("message", listener); try { await this.call({ destination: DBUS, path: DBUS_PATH, interface: DBUS, member: "AddMatch", signature: "s", body: [ `type='signal',sender='${sender}',interface='${REQUEST}',member='Response',path_namespace='${namespace.slice(0, -1)}'`, ], }); const reply = await this.call({ destination: sender, path: PORTAL_PATH, interface: SCREENSHOT, member: "Screenshot", signature: "sa{sv}", body: [ "", { handle_token: new Variant("s", token), interactive: new Variant("b", false), modal: new Variant("b", false), target: new Variant("u", 8), }, ], }); handle = decodeString(reply.body[0]); if (!handle.startsWith(namespace)) throw new Error("Invalid screenshot request handle."); const body = await this.wait( new Promise((resolve) => { deliver = resolve; if (responses.has(handle)) resolve(responses.get(handle)); }), 120_000, ); const [status, results] = decodeResponse(body); completed = true; if (status === 1) throw new Error("Snapshot was cancelled."); if (status !== 0) throw new Error("Your desktop did not allow the snapshot."); const uri = decodeUri(results.uri).value; return { png: await readPortalPng(uri) }; } finally { this.bus.removeListener("message", listener); if (!completed) { this.bus.send( new Message({ destination: sender, path: handle, interface: REQUEST, member: "Close", flags: MessageFlag.NO_REPLY_EXPECTED, }), ); } // Closing this dedicated connection also removes the signal match. } } async captureExtension(appId: string, options?: FeedbackOptions): Promise { const result = await this.wait( this.bus.requestName(`${appId}.SnapShot`, NameFlag.DO_NOT_QUEUE), ); if (result !== RequestNameReply.PRIMARY_OWNER) { throw new Error("Another T3 Code instance is capturing a window. Try again."); } const withFeedback = this.feedbackAvailable && options !== undefined; const reply = await this.call({ destination: EXTENSION, path: EXTENSION_PATH, interface: EXTENSION, member: withFeedback ? "CaptureWithFeedback" : "Capture", ...(withFeedback ? { signature: "bb", body: [options.flash, options.animate] } : {}), }); const bytes: unknown = reply.body[0]; if (!(bytes instanceof Uint8Array)) throw new Error("Invalid extension screenshot."); const window = decodeWindow(reply.body[1]); const png = resizeLinuxCapture(Buffer.from(bytes)); if (!withFeedback) return { png, window }; const animationStarted = decodeBoolean(reply.body[2]); // Keep the same authenticated sender alive through activation and the compositor flight. this.feedbackTimer = setTimeout(() => this.close(), 15_000); let flight: Promise | undefined; const feedback: LinuxCaptureFeedback = { animationStarted, activate: async (title) => { await this.feedbackCall("Activate", "s", [title]); }, animateTo: (frame) => { flight ??= this.feedbackCall("Animate", "dddd", [ frame.x, frame.y, frame.width, frame.height, ]); return flight; }, complete: async () => { try { await flight; } finally { this.close(); } }, close: () => this.close(), }; return { png, window, feedback }; } private async feedbackCall(member: string, signature: string, body: unknown[]): Promise { if (this.closed) return; await this.call({ destination: EXTENSION, path: EXTENSION_PATH, interface: EXTENSION, member, signature, body, }); } } export async function getLinuxCaptureSupport(appId: string) { if (isHyprlandCaptureSession()) return { linuxBackend: "hyprland" as const, linuxFeedbackAvailable: false }; const { niriSocketPath, checkNiriCaptureSupport } = await import("./NiriSnapShot.ts"); const niri = niriSocketPath(); if (niri) { await checkNiriCaptureSupport(niri); return { linuxBackend: "niri" as const, linuxFeedbackAvailable: false }; } const connection = new LinuxCaptureConnection(); try { const linuxBackend = await connection.backend(appId); return { linuxBackend, linuxFeedbackAvailable: connection.feedbackAvailable }; } finally { connection.close(); } } /** Undefined means capability missing, not denied: only that case may open the picker. */ export async function captureLinuxWindow( appId: string, options?: FeedbackOptions, kdePaths?: KdeCapturePaths, hyprlandPaths?: HyprlandCapturePaths, ): Promise { if (isHyprlandCaptureSession()) { if (!hyprlandPaths) throw new Error("Hyprland capture setup is unavailable in this build."); const { captureHyprlandWindow } = await import("./HyprlandSnapShot.ts"); return captureHyprlandWindow(hyprlandPaths, options); } const { niriSocketPath, captureNiriWindow } = await import("./NiriSnapShot.ts"); const niri = niriSocketPath(); if (niri) return captureNiriWindow(niri); const connection = new LinuxCaptureConnection(); let retained = false; try { switch (await connection.backend(appId)) { case "kde": { if (!kdePaths) throw new Error("KDE capture setup is unavailable in this build."); const { captureKdeWindow } = await import("./KdeSnapShot.ts"); return await captureKdeWindow(kdePaths, options); } case "screenshot-portal": return await connection.capturePortal(); case "gnome-extension": { const snapshot = await connection.captureExtension(appId, options); retained = snapshot.feedback !== undefined; return snapshot; } case "picker": return undefined; } } finally { if (!retained) connection.close(); } }