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 | {
99 preview: DesktopCaptureConfigPreview;
100 target: Target;
101 files: Snapshot[];
102 missing: string[];
103 checkHyprlandKeys: boolean;
104 }
105 | undefined;
106 private applying = false;
107 private readonly home: string;
108 private readonly tools: typeof defaultTools;
109 constructor(home: string, tools = defaultTools) {
110 this.home = home;
111 this.tools = tools;
112 }
113
114 private async checkHyprlandKeys(appId: string, shortcut: string) {
115 const keys = captureConfigKeys(shortcut);
116 const bindings = await this.tools.hyprlandBindings().catch(() => {
117 throw new Error(
118 "Couldn't check Hyprland's current shortcuts. Try again from your Hyprland session.",
119 );
120 });
121 if (
122 bindings.some(
123 (binding) =>
124 binding.modmask === keys.mask &&
125 binding.key.toUpperCase() === keys.key &&
126 !(binding.dispatcher === "global" && binding.arg === `${appId}:capture-window`),
127 )
128 )
129 throw new Error(`${keys.label} is already used by Hyprland. Choose another shortcut.`);
130 }
131
132 async preview(
133 target: Target,
134 request: DesktopCaptureConfigRequest,
135 ): Promise<DesktopCaptureConfigPreview> {
136 if (this.applying) throw new Error("Wait for the current config change to finish.");
137 this.pending = undefined;
138 const root = await readSnapshot(target.path).catch(() => {
139 throw new Error("Couldn't read your settings file. Choose a different file in Advanced.");
140 });
141 if (root.stat.uid !== process.getuid?.() || /\/omarchy\/default\//.test(root.resolvedPath))
142 throw new Error("Choose your own config, not system or Omarchy defaults.");
143 const format: CaptureConfigFormat =
144 target.desktop === "niri"
145 ? "niri"
146 : target.path.endsWith(".lua")
147 ? "hyprland-lua"
148 : "hyprland";
149 if (
150 !target.path.endsWith(
151 format === "niri" ? ".kdl" : format === "hyprland-lua" ? ".lua" : ".conf",
152 )
153 )
154 throw new Error("Choose a .kdl Niri config or a .conf/.lua Hyprland config.");
155 const edit = editCaptureConfig(
156 root.text,
157 format,
158 target.appId,
159 request.operation,
160 request.shortcut,
161 );
162 // Lua bindings appear as opaque callbacks, including our own unchanged shortcut.
163 const checkHyprlandKeys =
164 request.operation === "install" &&
165 (format === "hyprland" || (format === "hyprland-lua" && edit.after !== root.text));
166 const files = [root];
167 const missing: string[] = [];
168 if (target.desktop === "niri") {
169 const visit = async (file: Snapshot, depth: number) => {
170 if (
171 depth > 10 ||
172 files.length > MAX_FILES ||
173 files.reduce((size, item) => size + item.bytes.length, 0) > MAX_BYTES
174 )
175 throw new Error("This config has too many included files. Use manual setup in Advanced.");
176 for (const include of niriConfigIncludes(file.text)) {
177 if (include.path.includes("$") || /[*?[\]]/.test(include.path))
178 throw new Error("This config uses a dynamic include. Use manual setup in Advanced.");
179 const path = include.path.startsWith("~/")
180 ? NodePath.join(this.home, include.path.slice(2))
181 : NodePath.resolve(NodePath.dirname(file.path), include.path);
182 let child: Snapshot;
183 try {
184 child = await readSnapshot(path);
185 } catch (error) {
186 if (include.optional && (error as NodeJS.ErrnoException).code === "ENOENT") {
187 missing.push(path);
188 continue;
189 }
190 throw new Error(
191 "Couldn't read an included Niri config. Check its location in Advanced.",
192 { cause: error },
193 );
194 }
195 if (files.some((item) => item.resolvedPath === child.resolvedPath)) continue;
196 files.push(child);
197 if (
198 request.operation === "install" &&
199 niriConfigConflict(child.text, target.appId, edit.shortcut)
200 )
201 throw new Error(
202 `${edit.shortcut} is already used in ${child.path}. Choose another shortcut.`,
203 );
204 await visit(child, depth + 1);
205 }
206 };
207 await visit(root, 0);
208 } else if (checkHyprlandKeys) {
209 await this.checkHyprlandKeys(target.appId, edit.shortcut);
210 }
211 const preview = {
212 id: NodeCrypto.randomUUID(),
213 path: root.path,
214 resolvedPath: root.resolvedPath,
215 before: root.text,
216 after: edit.after,
217 shortcut: edit.shortcut,
218 operation: request.operation,
219 };
220 this.pending = { preview, target, files, missing, checkHyprlandKeys };
221 return preview;
222 }
223
224 async apply(id: string, desktop: Target["desktop"]): Promise<DesktopCaptureConfigApplied> {
225 const pending = this.pending;
226 if (
227 this.applying ||
228 !pending ||
229 pending.preview.id !== id ||
230 pending.target.desktop !== desktop
231 )
232 throw new Error("This preview expired. Review changes again before saving.");
233 this.pending = undefined;
234 this.applying = true;
235 const { preview, files } = pending;
236 const root = files[0]!;
237 const directory = NodePath.dirname(root.resolvedPath);
238 const temporary = NodePath.join(directory, `.t3-capture-${NodeCrypto.randomUUID()}.tmp`);
239 let staged = false;
240 try {
241 const unchanged = async () => {
242 for (const path of pending.missing) {
243 if (
244 await NodeFSP.lstat(path).then(
245 () => true,
246 (error: NodeJS.ErrnoException) => error.code !== "ENOENT",
247 )
248 )
249 throw new Error(
250 "Your config changed since this preview. Review changes again before saving.",
251 );
252 }
253 for (const original of files) {
254 const current = await readSnapshot(original.path).catch(() => undefined);
255 if (
256 !current ||
257 current.resolvedPath !== original.resolvedPath ||
258 current.stat.ino !== original.stat.ino ||
259 current.stat.mode !== original.stat.mode ||
260 !current.bytes.equals(original.bytes)
261 )
262 throw new Error(
263 "Your config changed since this preview. Nothing was saved. Review changes again before saving.",
264 );
265 }
266 };
267 await unchanged();
268 if (pending.checkHyprlandKeys)
269 await this.checkHyprlandKeys(pending.target.appId, preview.shortcut);
270 if (preview.before === preview.after) return { backupPath: null, warning: null };
271 await NodeFSP.writeFile(temporary, preview.after, {
272 flag: "wx",
273 mode: root.stat.mode & 0o777,
274 });
275 staged = true;
276 await NodeFSP.chmod(temporary, root.stat.mode & 0o777);
277 if (desktop === "niri") {
278 // Niri resolves includes against the selected path, not a dotfile symlink's target.
279 const validationPath = NodePath.join(
280 NodePath.dirname(root.path),
281 NodePath.basename(temporary),
282 );
283 if (NodePath.resolve(validationPath) === NodePath.resolve(temporary)) {
284 await this.tools.validateNiri(temporary);
285 } else {
286 await NodeFSP.symlink(temporary, validationPath);
287 try {
288 await this.tools.validateNiri(validationPath);
289 } finally {
290 await NodeFSP.unlink(validationPath);
291 }
292 }
293 }
294 await unchanged();
295 const backupPath = `${root.resolvedPath}.t3-capture-backup-${NodeCrypto.randomUUID()}`;
296 await NodeFSP.writeFile(backupPath, root.bytes, { flag: "wx", mode: 0o600 });
297 await NodeFSP.rename(temporary, root.resolvedPath);
298 staged = false;
299 let warning: string | null = null;
300 if (desktop === "hyprland") {
301 try {
302 await this.tools.reloadHyprland();
303 } catch {
304 warning =
305 "Config saved, but Hyprland couldn't reload it cleanly. Check hyprctl configerrors, then run hyprctl reload.";
306 }
307 }
308 return { backupPath, warning };
309 } finally {
310 if (staged) await NodeFSP.unlink(temporary).catch(() => undefined);
311 this.applying = false;
312 }
313 }
314}