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