Skip to content
Source
Sign inSign up

HyprlandSnapShot.ts

228 lines8.6 KB

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 { CaptureSetupRequiredError, 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);
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}`;
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 };
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 });
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);
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 T3 Code.",
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 }
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 setup = new HyprlandCaptureSetup(paths);
171 let state = await setup.state();
172 if (state.status === "update-required") {
173 await setup.perform("install-hyprland-helper").catch((cause: unknown) => {
174 throw new CaptureSetupRequiredError({ state: state.message, cause });
175 });
176 state = await setup.state();
177 }
178 if (state.status !== "ready") throw new CaptureSetupRequiredError({ state: state.message });
179 const executable = hyprlandCaptureExecutable(paths);
180 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-hyprland-capture-"));
181 const cleanup = () => NodeFSP.rm(directory, { recursive: true, force: true });
182 let retained = false;
183 try {
184 const result = decodeCapture(await run(executable, ["capture", directory]));
185 const png = await readPortalPng(
186 NodeURL.pathToFileURL(NodePath.join(directory, "capture.png")).href,
187 );
188 const effects =
189 state.feedbackAvailable && result.window && options && (options.flash || options.animate)
190 ? await startNativeCaptureFeedback(executable, directory, {
191 bounds: result.window.bounds,
192 pid: process.pid,
193 ...options,
194 }).catch(() => undefined)
195 : undefined;
196 if (effects) {
197 retained = true;
198 void effects.closed.then(cleanup).catch(() => undefined);
199 }
200 const activation = new AbortController();
201 let targetTitle = "";
202 return {
203 png,
204 ...(result.window ? { window: result.window } : {}),
205 feedback: {
206 animationStarted: effects?.animationStarted ?? false,
207 activate: async (title) => {
208 targetTitle = title;
209 await run(executable, ["activate", String(process.pid), title], activation.signal);
210 },
211 animateTo: async (frame) => {
212 await effects?.animateTo(targetTitle, frame);
213 },
214 complete: async () => {
215 await effects?.complete();
216 activation.abort();
217 await cleanup();
218 },
219 close: () => {
220 effects?.close();
221 activation.abort();
222 },
223 },
224 };
225 } finally {
226 if (!retained) await cleanup();
227 }