| 1 | // @effect-diagnostics nodeBuiltinImport:off -- Native, consent-gated compositor config I/O. |
| 2 | import * as NodeFSP from "node:fs/promises"; |
| 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | import * as NodeCrypto from "node:crypto"; |
| 6 | import * as NodeChildProcess from "node:child_process"; |
| 7 | import * as NodeUtil from "node:util"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import type { |
| 10 | DesktopCaptureConfigApplied, |
| 11 | DesktopCaptureConfigPreview, |
| 12 | DesktopCaptureConfigRequest, |
| 13 | } from "@t3tools/contracts"; |
| 14 | import { |
| 15 | captureConfigKeys, |
| 16 | editCaptureConfig, |
| 17 | niriConfigConflict, |
| 18 | niriConfigIncludes, |
| 19 | type CaptureConfigFormat, |
| 20 | } from "./captureConfigEdit.ts"; |
| 21 | |
| 22 | const execute = (file: string, args: string[], options: { timeout: number; maxBuffer: number }) => |
| 23 | NodeUtil.promisify(NodeChildProcess.execFile)(file, args, options); |
| 24 | const MAX_BYTES = 1024 * 1024; |
| 25 | const MAX_FILES = 64; |
| 26 | type Target = { desktop: "niri" | "hyprland"; path: string; appId: string }; |
| 27 | type Snapshot = Awaited<ReturnType<typeof readSnapshot>>; |
| 28 | const 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 | |
| 41 | export 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 | |
| 48 | async 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 | |
| 68 | const 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. */ |
| 96 | export 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 | } |