1// @effect-diagnostics nodeBuiltinImport:off -- Bundled Wayland helper installed only by explicit setup.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFSP from "node:fs/promises";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import * as NodeURL from "node:url";
7import * as Schema from "effect/Schema";
8import type { DesktopCaptureHelperState } from "@t3tools/contracts";
9import type { LinuxWindowSnapshot } from "./LinuxSnapShot.ts";
10import { readPortalPng } from "./linuxCaptureSession.ts";
11import { startNativeCaptureFeedback } from "./NativeCaptureFeedback.ts";
12import { HYPRLAND_CAPTURE_ACTION } from "./linuxCaptureSession.ts";
13export { isHyprlandCaptureSession } from "./linuxCaptureSession.ts";
14
15export const HYPRLAND_CAPTURE_EXECUTABLE = "t3-hyprland-snap-shot";
16export type HyprlandCapturePaths = { readonly bundle: string; readonly dataHome: string };
17export function hyprlandCaptureExecutable(paths: HyprlandCapturePaths) {
18 return NodePath.join(paths.dataHome, "t3code", "hyprland-capture", HYPRLAND_CAPTURE_EXECUTABLE);
19}
20
21function hyprlandCaptureBinding(appId: string, lua: boolean): string {
22 if (!/^[A-Za-z0-9_.-]+$/.test(appId)) throw new Error("Invalid capture application ID.");
23 const action = `${appId}:${HYPRLAND_CAPTURE_ACTION}`;
24 return lua
25 ? `hl.bind("CTRL + SHIFT + 2", hl.dsp.global("${action}"))`
26 : `bind = CTRL SHIFT, 2, global, ${action}`;
27}
28
29/** Omarchy owns its defaults; instructions always point at a user-owned config. */
30export async function hyprlandCaptureShortcut(
31 appId: string,
32 home: string,
33 configHome = process.env.XDG_CONFIG_HOME || NodePath.join(home, ".config"),
34) {
35 const directory = NodePath.join(configHome, "hypr");
36 const exists = async (name: string) =>
37 NodeFSP.access(NodePath.join(directory, name)).then(
38 () => true,
39 () => false,
40 );
41 const lua = await exists("hyprland.lua");
42 const bindings = lua ? "bindings.lua" : "bindings.conf";
43 const config = (await exists(bindings)) ? bindings : lua ? "hyprland.lua" : "hyprland.conf";
44 return {
45 shortcutBinding: hyprlandCaptureBinding(appId, lua),
46 shortcutConfigPath: NodePath.join(directory, config),
47 };
48}
49
50function run(executable: string, args: string[], signal?: AbortSignal): Promise<string> {
51 return new Promise((resolve, reject) => {
52 NodeChildProcess.execFile(
53 executable,
54 args,
55 { timeout: 20_000, maxBuffer: 128 * 1024, encoding: "utf8", ...(signal ? { signal } : {}) },
56 (error, stdout, stderr) => {
57 if (error)
58 reject(
59 new Error(
60 stderr.trim() ||
61 "Hyprland capture did not respond. Check capture setup and try again.",
62 ),
63 );
64 else resolve(stdout);
65 },
66 );
67 });
68}
69async function regularFile(path: string): Promise<Buffer | undefined> {
70 const stat = await NodeFSP.lstat(path).catch((error: NodeJS.ErrnoException) => {
71 if (error.code !== "ENOENT") throw error;
72 return undefined;
73 });
74 if (!stat) return undefined;
75 if (!stat.isFile() || stat.isSymbolicLink())
76 throw new Error("The capture helper must be a regular file, not a link.");
77 return NodeFSP.readFile(path);
78}
79const decodeCapabilities = Schema.decodeUnknownSync(
80 Schema.fromJsonString(Schema.Struct({ feedbackAvailable: Schema.Boolean })),
81);
82
83export class HyprlandCaptureSetup {
84 private readonly paths: HyprlandCapturePaths;
85 constructor(paths: HyprlandCapturePaths) {
86 this.paths = paths;
87 }
88 async state(): Promise<DesktopCaptureHelperState> {
89 try {
90 const installed = await regularFile(hyprlandCaptureExecutable(this.paths));
91 if (!installed)
92 return {
93 status: "not-installed",
94 message: "Install the bundled helper to capture the window you're using.",
95 };
96 const bundle = await regularFile(this.paths.bundle);
97 if (!bundle)
98 throw new Error(
99 "The Hyprland capture helper is missing from this build. Update or reinstall The Knife.",
100 );
101 if (!installed.equals(bundle))
102 return {
103 status: "update-required",
104 message: "Update the bundled capture helper to continue.",
105 };
106 return {
107 status: "ready",
108 message:
109 "Helper ready. Hyprland may ask for screen-sharing permission on your first capture.",
110 ...decodeCapabilities(await run(hyprlandCaptureExecutable(this.paths), ["check"])),
111 };
112 } catch (error) {
113 return {
114 status: "error",
115 message: error instanceof Error ? error.message : "Couldn't check Hyprland capture access.",
116 };
117 }
118 }
119 async perform(action: "install-hyprland-helper" | "remove-hyprland-helper") {
120 const executable = hyprlandCaptureExecutable(this.paths);
121 const directory = NodePath.dirname(executable);
122 const stat = await NodeFSP.lstat(directory).catch((error: NodeJS.ErrnoException) => {
123 if (error.code !== "ENOENT") throw error;
124 return undefined;
125 });
126 if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
127 throw new Error("The capture helper directory must not be a link.");
128 await regularFile(executable);
129 if (action === "remove-hyprland-helper") {
130 await NodeFSP.rm(executable, { force: true });
131 return;
132 }
133 const bundle = await regularFile(this.paths.bundle);
134 if (!bundle) throw new Error("The Hyprland capture helper is missing from this build.");
135 await NodeFSP.mkdir(directory, { recursive: true });
136 const staging = await NodeFSP.mkdtemp(NodePath.join(directory, ".install-"));
137 try {
138 const staged = NodePath.join(staging, HYPRLAND_CAPTURE_EXECUTABLE);
139 await NodeFSP.writeFile(staged, bundle, { mode: 0o755, flag: "wx" });
140 await NodeFSP.rename(staged, executable);
141 } finally {
142 await NodeFSP.rm(staging, { recursive: true, force: true });
143 }
144 }
145}
146
147const Dimension = Schema.Int.check(Schema.isGreaterThan(0));
148const Bounds = Schema.Struct({ x: Schema.Int, y: Schema.Int, width: Dimension, height: Dimension });
149const decodeCapture = Schema.decodeUnknownSync(
150 Schema.fromJsonString(
151 Schema.Struct({
152 window: Schema.NullOr(
153 Schema.Struct({
154 title: Schema.String,
155 appName: Schema.String,
156 appIdentifier: Schema.String,
157 processId: Dimension,
158 bounds: Bounds,
159 clientBounds: Bounds,
160 }),
161 ),
162 }),
163 ),
164);
165
166export async function captureHyprlandWindow(
167 paths: HyprlandCapturePaths,
168 options?: { readonly flash: boolean; readonly animate: boolean },
169): Promise<LinuxWindowSnapshot> {
170 const state = await new HyprlandCaptureSetup(paths).state();
171 if (state.status !== "ready")
172 throw new Error(`${state.message} Open Settings → SnapShots to continue setup.`);
173 const executable = hyprlandCaptureExecutable(paths);
174 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-hyprland-capture-"));
175 const cleanup = () => NodeFSP.rm(directory, { recursive: true, force: true });
176 let retained = false;
177 try {
178 const result = decodeCapture(await run(executable, ["capture", directory]));
179 const png = await readPortalPng(
180 NodeURL.pathToFileURL(NodePath.join(directory, "capture.png")).href,
181 );
182 const effects =
183 state.feedbackAvailable && result.window && options && (options.flash || options.animate)
184 ? await startNativeCaptureFeedback(executable, directory, {
185 bounds: result.window.bounds,
186 pid: process.pid,
187 ...options,
188 }).catch(() => undefined)
189 : undefined;
190 if (effects) {
191 retained = true;
192 void effects.closed.then(cleanup).catch(() => undefined);
193 }
194 const activation = new AbortController();
195 let targetTitle = "";
196 return {
197 png,
198 ...(result.window ? { window: result.window } : {}),
199 feedback: {
200 animationStarted: effects?.animationStarted ?? false,
201 activate: async (title) => {
202 targetTitle = title;
203 await run(executable, ["activate", String(process.pid), title], activation.signal);
204 },
205 animateTo: async (frame) => {
206 await effects?.animateTo(targetTitle, frame);
207 },
208 complete: async () => {
209 await effects?.complete();
210 activation.abort();
211 await cleanup();
212 },
213 close: () => {
214 effects?.close();
215 activation.abort();
216 },
217 },
218 };
219 } finally {
220 if (!retained) await cleanup();
221 }
222}