1// @effect-diagnostics nodeBuiltinImport:off -- Native, consent-gated compositor config I/O.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5import * as NodeCrypto from "node:crypto";
6import * as NodeChildProcess from "node:child_process";
7import * as NodeUtil from "node:util";
8import * as Schema from "effect/Schema";
9import type {
10 DesktopCaptureConfigApplied,
11 DesktopCaptureConfigPreview,
12 DesktopCaptureConfigRequest,
13} from "@t3tools/contracts";
14import {
15 captureConfigKeys,
16 editCaptureConfig,
17 niriConfigConflict,
18 niriConfigIncludes,
19 type CaptureConfigFormat,
20} from "./captureConfigEdit.ts";
21
22const execute = (file: string, args: string[], options: { timeout: number; maxBuffer: number }) =>
23 NodeUtil.promisify(NodeChildProcess.execFile)(file, args, options);
24const MAX_BYTES = 1024 * 1024;
25const MAX_FILES = 64;
26type Target = { desktop: "niri" | "hyprland"; path: string; appId: string };
27type Snapshot = Awaited<ReturnType<typeof readSnapshot>>;
28const decodeHyprlandBindings = Schema.decodeUnknownSync(
29 Schema.fromJsonString(
30 Schema.Array(
31 Schema.Struct({
32 modmask: Schema.Number,
33 key: Schema.String,
34 dispatcher: Schema.String,
35 arg: Schema.String,
36 }),
37 ),
38 ),
39);
40
41export function niriCaptureConfigPath(home: string, env = process.env) {
42 return (
43 env.NIRI_CONFIG ||
44 NodePath.join(env.XDG_CONFIG_HOME || NodePath.join(home, ".config"), "niri", "config.kdl")
45 );
46}
47
48async function readSnapshot(path: string) {
49 const resolvedPath = await NodeFSP.realpath(path);
50 const file = await NodeFSP.open(
51 resolvedPath,
52 NodeFS.constants.O_RDONLY | NodeFS.constants.O_NONBLOCK | NodeFS.constants.O_NOFOLLOW,
53 );
54 try {
55 const stat = await file.stat();
56 if (!stat.isFile() || stat.size > MAX_BYTES)
57 throw new Error("Choose a config file smaller than 1 MB.");
58 const bytes = await file.readFile();
59 if (bytes.length > MAX_BYTES) throw new Error("Choose a config file smaller than 1 MB.");
60 const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
61 if (text.includes("\0")) throw new Error("Choose a text config file.");
62 return { path, resolvedPath, text, bytes, stat };
63 } finally {
64 await file.close();
65 }
66}
67
68const defaultTools = {
69 validateNiri: async (path: string) => {
70 try {
71 await execute("niri", ["validate", "--config", path], {
72 timeout: 5000,
73 maxBuffer: 64 * 1024,
74 });
75 } catch {
76 throw new Error(
77 "Niri couldn't validate the proposed config. Nothing was changed. Check your config in Advanced.",
78 );
79 }
80 },
81 hyprlandBindings: async () =>
82 decodeHyprlandBindings(
83 (await execute("hyprctl", ["-j", "binds"], { timeout: 5000, maxBuffer: MAX_BYTES })).stdout,
84 ),
85 reloadHyprland: async () => {
86 await execute("hyprctl", ["reload"], { timeout: 5000, maxBuffer: 64 * 1024 });
87 const errors = await execute("hyprctl", ["configerrors"], {
88 timeout: 5000,
89 maxBuffer: 64 * 1024,
90 });
91 if (errors.stdout.trim()) throw new Error("Hyprland reported config errors.");
92 },
93};
94
95/** The renderer can apply only the exact proposal retained by the desktop process. */
96export class CaptureShortcutConfig {
97 private pending:
98 | { preview: DesktopCaptureConfigPreview; target: Target; files: Snapshot[]; missing: string[] }
99 | undefined;
100 private applying = false;
101 private readonly home: string;
102 private readonly tools: typeof defaultTools;
103 constructor(home: string, tools = defaultTools) {
104 this.home = home;
105 this.tools = tools;
106 }
107
108 private async checkHyprlandKeys(appId: string, shortcut: string) {
109 const keys = captureConfigKeys(shortcut);
110 const bindings = await this.tools.hyprlandBindings().catch(() => {
111 throw new Error(
112 "Couldn't check Hyprland's current shortcuts. Try again from your Hyprland session.",
113 );
114 });
115 if (
116 bindings.some(
117 (binding) =>
118 binding.modmask === keys.mask &&
119 binding.key.toUpperCase() === keys.key &&
120 !(binding.dispatcher === "global" && binding.arg === `${appId}:capture-window`),
121 )
122 )
123 throw new Error(`${keys.label} is already used by Hyprland. Choose another shortcut.`);
124 }
125
126 async preview(
127 target: Target,
128 request: DesktopCaptureConfigRequest,
129 ): Promise<DesktopCaptureConfigPreview> {
130 if (this.applying) throw new Error("Wait for the current config change to finish.");
131 this.pending = undefined;
132 const root = await readSnapshot(target.path).catch(() => {
133 throw new Error("Couldn't read your settings file. Choose a different file in Advanced.");
134 });
135 if (root.stat.uid !== process.getuid?.() || /\/omarchy\/default\//.test(root.resolvedPath))
136 throw new Error("Choose your own config, not system or Omarchy defaults.");
137 const format: CaptureConfigFormat =
138 target.desktop === "niri"
139 ? "niri"
140 : target.path.endsWith(".lua")
141 ? "hyprland-lua"
142 : "hyprland";
143 if (
144 !target.path.endsWith(
145 format === "niri" ? ".kdl" : format === "hyprland-lua" ? ".lua" : ".conf",
146 )
147 )
148 throw new Error("Choose a .kdl Niri config or a .conf/.lua Hyprland config.");
149 const edit = editCaptureConfig(
150 root.text,
151 format,
152 target.appId,
153 request.operation,
154 request.shortcut,
155 );
156 const files = [root];
157 const missing: string[] = [];
158 if (target.desktop === "niri") {
159 const visit = async (file: Snapshot, depth: number) => {
160 if (
161 depth > 10 ||
162 files.length > MAX_FILES ||
163 files.reduce((size, item) => size + item.bytes.length, 0) > MAX_BYTES
164 )
165 throw new Error("This config has too many included files. Use manual setup in Advanced.");
166 for (const include of niriConfigIncludes(file.text)) {
167 if (include.path.includes("$") || /[*?[\]]/.test(include.path))
168 throw new Error("This config uses a dynamic include. Use manual setup in Advanced.");
169 const path = include.path.startsWith("~/")
170 ? NodePath.join(this.home, include.path.slice(2))
171 : NodePath.resolve(NodePath.dirname(file.path), include.path);
172 let child: Snapshot;
173 try {
174 child = await readSnapshot(path);
175 } catch (error) {
176 if (include.optional && (error as NodeJS.ErrnoException).code === "ENOENT") {
177 missing.push(path);
178 continue;
179 }
180 throw new Error(
181 "Couldn't read an included Niri config. Check its location in Advanced.",
182 { cause: error },
183 );
184 }
185 if (files.some((item) => item.resolvedPath === child.resolvedPath)) continue;
186 files.push(child);
187 if (
188 request.operation === "install" &&
189 niriConfigConflict(child.text, target.appId, edit.shortcut)
190 )
191 throw new Error(
192 `${edit.shortcut} is already used in ${child.path}. Choose another shortcut.`,
193 );
194 await visit(child, depth + 1);
195 }
196 };
197 await visit(root, 0);
198 } else if (request.operation === "install") {
199 await this.checkHyprlandKeys(target.appId, edit.shortcut);
200 }
201 const preview = {
202 id: NodeCrypto.randomUUID(),
203 path: root.path,
204 resolvedPath: root.resolvedPath,
205 before: root.text,
206 after: edit.after,
207 shortcut: edit.shortcut,
208 operation: request.operation,
209 };
210 this.pending = { preview, target, files, missing };
211 return preview;
212 }
213
214 async apply(id: string, desktop: Target["desktop"]): Promise<DesktopCaptureConfigApplied> {
215 const pending = this.pending;
216 if (
217 this.applying ||
218 !pending ||
219 pending.preview.id !== id ||
220 pending.target.desktop !== desktop
221 )
222 throw new Error("This preview expired. Review changes again before saving.");
223 this.pending = undefined;
224 this.applying = true;
225 const { preview, files } = pending;
226 const root = files[0]!;
227 const directory = NodePath.dirname(root.resolvedPath);
228 const temporary = NodePath.join(directory, `.t3-capture-${NodeCrypto.randomUUID()}.tmp`);
229 let staged = false;
230 try {
231 const unchanged = async () => {
232 for (const path of pending.missing) {
233 if (
234 await NodeFSP.lstat(path).then(
235 () => true,
236 (error: NodeJS.ErrnoException) => error.code !== "ENOENT",
237 )
238 )
239 throw new Error(
240 "Your config changed since this preview. Review changes again before saving.",
241 );
242 }
243 for (const original of files) {
244 const current = await readSnapshot(original.path).catch(() => undefined);
245 if (
246 !current ||
247 current.resolvedPath !== original.resolvedPath ||
248 current.stat.ino !== original.stat.ino ||
249 current.stat.mode !== original.stat.mode ||
250 !current.bytes.equals(original.bytes)
251 )
252 throw new Error(
253 "Your config changed since this preview. Nothing was saved. Review changes again before saving.",
254 );
255 }
256 };
257 await unchanged();
258 if (desktop === "hyprland" && preview.operation === "install")
259 await this.checkHyprlandKeys(pending.target.appId, preview.shortcut);
260 if (preview.before === preview.after) return { backupPath: null, warning: null };
261 await NodeFSP.writeFile(temporary, preview.after, {
262 flag: "wx",
263 mode: root.stat.mode & 0o777,
264 });
265 staged = true;
266 await NodeFSP.chmod(temporary, root.stat.mode & 0o777);
267 if (desktop === "niri") {
268 // Niri resolves includes against the selected path, not a dotfile symlink's target.
269 const validationPath = NodePath.join(
270 NodePath.dirname(root.path),
271 NodePath.basename(temporary),
272 );
273 if (NodePath.resolve(validationPath) === NodePath.resolve(temporary)) {
274 await this.tools.validateNiri(temporary);
275 } else {
276 await NodeFSP.symlink(temporary, validationPath);
277 try {
278 await this.tools.validateNiri(validationPath);
279 } finally {
280 await NodeFSP.unlink(validationPath);
281 }
282 }
283 }
284 await unchanged();
285 const backupPath = `${root.resolvedPath}.t3-capture-backup-${NodeCrypto.randomUUID()}`;
286 await NodeFSP.writeFile(backupPath, root.bytes, { flag: "wx", mode: 0o600 });
287 await NodeFSP.rename(temporary, root.resolvedPath);
288 staged = false;
289 let warning: string | null = null;
290 if (desktop === "hyprland") {
291 try {
292 await this.tools.reloadHyprland();
293 } catch {
294 warning =
295 "Config saved, but Hyprland couldn't reload it cleanly. Check hyprctl configerrors, then run hyprctl reload.";
296 }
297 }
298 return { backupPath, warning };
299 } finally {
300 if (staged) await NodeFSP.unlink(temporary).catch(() => undefined);
301 this.applying = false;
302 }
303 }
304}