1// @effect-diagnostics nodeBuiltinImport:off -- Reads portal-owned screenshot files at the native boundary.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeURL from "node:url";
4import type { SnapShotKeyChord } from "@t3tools/contracts";
5import { nativeImage } from "electron";
6
7// Linux helpers that need no D-Bus. Keep this module free of dbus-next so the main
8// process can answer "which desktop is this" and read a PNG without loading it.
9
10export const HYPRLAND_CAPTURE_ACTION = "capture-window";
11export const NIRI_CAPTURE_PATH = "/com/t3tools/SnapShot";
12export const NIRI_CAPTURE_INTERFACE = "com.t3tools.SnapShot";
13
14function isDesktopSession(env: NodeJS.ProcessEnv, desktop: string): boolean {
15 return (
16 !env.FLATPAK_ID &&
17 !env.SNAP &&
18 Boolean(env.XDG_CURRENT_DESKTOP?.split(":").some((name) => name.toLowerCase() === desktop))
19 );
20}
21
22export function isGnomeCaptureSession(env: NodeJS.ProcessEnv): boolean {
23 return isDesktopSession(env, "gnome");
24}
25
26export function isKdeCaptureSession(env = process.env): boolean {
27 return isDesktopSession(env, "kde");
28}
29
30export function isHyprlandCaptureSession(env = process.env): boolean {
31 return isDesktopSession(env, "hyprland");
32}
33
34export function niriCaptureBinding(appId: string): string {
35 return `Ctrl+Shift+2 repeat=false { spawn "gdbus" "call" "--session" "--dest" "${appId}.SnapShot" "--object-path" "${NIRI_CAPTURE_PATH}" "--method" "${NIRI_CAPTURE_INTERFACE}.Capture"; }`;
36}
37
38const KEY_NAMES: Readonly<Record<string, string>> = {
39 " ": "space",
40 escape: "Escape",
41 esc: "Escape",
42 enter: "Return",
43 tab: "Tab",
44 backspace: "BackSpace",
45 delete: "Delete",
46 insert: "Insert",
47 home: "Home",
48 end: "End",
49 pageup: "Page_Up",
50 pagedown: "Page_Down",
51 arrowup: "Up",
52 arrowdown: "Down",
53 arrowleft: "Left",
54 arrowright: "Right",
55 up: "Up",
56 down: "Down",
57 left: "Left",
58 right: "Right",
59 "+": "plus",
60 "-": "minus",
61 "=": "equal",
62 ",": "comma",
63 ".": "period",
64 "/": "slash",
65 ";": "semicolon",
66 "'": "apostrophe",
67 "[": "bracketleft",
68 "]": "bracketright",
69 "\\": "backslash",
70 "`": "grave",
71 "!": "exclam",
72 "@": "at",
73 "#": "numbersign",
74 $: "dollar",
75 "%": "percent",
76 "^": "asciicircum",
77 "&": "ampersand",
78 "*": "asterisk",
79 "(": "parenleft",
80 ")": "parenright",
81 _: "underscore",
82 ":": "colon",
83 '"': "quotedbl",
84 "{": "braceleft",
85 "}": "braceright",
86 "|": "bar",
87 "<": "less",
88 ">": "greater",
89 "?": "question",
90 "~": "asciitilde",
91};
92
93export function portalShortcutTrigger(shortcut: SnapShotKeyChord): string {
94 const key = shortcut.key.toLowerCase();
95 const keysym =
96 KEY_NAMES[key] ??
97 (/^[a-z0-9]$/.test(key) || /^f([1-9]|1\d|2[0-4])$/.test(key) ? key.toUpperCase() : undefined);
98 if (!keysym)
99 throw new Error("This key isn't supported as a Wayland capture shortcut. Choose another key.");
100 return [
101 shortcut.ctrlKey || shortcut.modKey ? "CTRL" : null,
102 shortcut.altKey ? "ALT" : null,
103 shortcut.shiftKey ? "SHIFT" : null,
104 shortcut.metaKey ? "LOGO" : null,
105 keysym,
106 ]
107 .filter(Boolean)
108 .join("+");
109}
110
111const PNG_HEADER = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
112const MAX_PNG_BYTES = 32 * 1024 * 1024;
113
114export function resizeLinuxCapture(png: Buffer): Buffer {
115 if (png.length > MAX_PNG_BYTES || !png.subarray(0, 8).equals(PNG_HEADER)) {
116 throw new Error("Invalid or oversized window screenshot.");
117 }
118 const image = nativeImage.createFromBuffer(png);
119 if (image.isEmpty()) throw new Error("The window screenshot is empty.");
120 const { width, height } = image.getSize();
121 const scale = Math.min(2_560 / width, 1_600 / height, 1);
122 return scale < 1
123 ? image
124 .resize({
125 width: Math.max(1, Math.round(width * scale)),
126 height: Math.max(1, Math.round(height * scale)),
127 quality: "best",
128 })
129 .toPNG()
130 : png;
131}
132
133export async function readPortalPng(uri: string): Promise<Buffer> {
134 // fileURLToPath rejects network schemes/hosts. Never delete a portal-owned file.
135 const file = await NodeFSP.open(NodeURL.fileURLToPath(uri), "r");
136 try {
137 const stat = await file.stat();
138 if (!stat.isFile() || stat.size > MAX_PNG_BYTES) throw new Error("Invalid screenshot file.");
139 const buffer = Buffer.alloc(stat.size);
140 let offset = 0;
141 while (offset < buffer.length) {
142 const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset);
143 if (!bytesRead) throw new Error("Incomplete screenshot file.");
144 offset += bytesRead;
145 }
146 return resizeLinuxCapture(buffer);
147 } finally {
148 await file.close();
149 }
150}