1// @effect-diagnostics nodeBuiltinImport:off -- KDE authorizes a dedicated, installed native executable.
2import * as NodeCrypto from "node:crypto";
3import * as NodeChildProcess from "node:child_process";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
7import * as NodeURL from "node:url";
8import * as Schema from "effect/Schema";
9import type { DesktopCaptureHelperState } from "@t3tools/contracts";
10
11import { escapeDesktopEntryExecArgument } from "../app/DesktopLinuxUrlHandler.ts";
12import type { LinuxWindowSnapshot } from "./LinuxSnapShot.ts";
13import { readPortalPng } from "./linuxCaptureSession.ts";
14import { startNativeCaptureFeedback } from "./NativeCaptureFeedback.ts";
15export { isKdeCaptureSession } from "./linuxCaptureSession.ts";
16
17export const KDE_CAPTURE_EXECUTABLE = "t3-kde-snap-shot";
18const DESKTOP_FILE = "com.t3tools.T3Code.KdeCapture.desktop";
19const MARKER = "X-T3Code-Capture-Helper=true";
20const decodeCapabilities = Schema.decodeUnknownSync(
21 Schema.fromJsonString(Schema.Struct({ feedbackAvailable: Schema.optional(Schema.Boolean) })),
22);
23export type KdeCapturePaths = { readonly bundle: string; readonly dataHome: string };
24
25export function kdeCapturePaths(paths: KdeCapturePaths) {
26 return {
27 executable: NodePath.join(paths.dataHome, "t3code", "kde-capture", KDE_CAPTURE_EXECUTABLE),
28 desktop: NodePath.join(paths.dataHome, "applications", DESKTOP_FILE),
29 };
30}
31
32export function kdeCaptureDesktopEntry(executable: string): string {
33 return [
34 "[Desktop Entry]",
35 "Type=Application",
36 "Name=The Knife SnapShots",
37 "NoDisplay=true",
38 `Exec=${escapeDesktopEntryExecArgument(executable)} check`,
39 // KService reads this custom property as a KConfig list, not an XDG ';' list.
40 "X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2",
41 MARKER,
42 "",
43 ].join("\n");
44}
45
46function run(executable: string, args: string[], signal?: AbortSignal): Promise<string> {
47 return new Promise((resolve, reject) => {
48 NodeChildProcess.execFile(
49 executable,
50 args,
51 {
52 timeout: 20_000,
53 maxBuffer: 128 * 1024,
54 encoding: "utf8",
55 ...(signal ? { signal } : {}),
56 },
57 (error, stdout, stderr) => {
58 if (!error) return resolve(stdout);
59 if (executable === "kbuildsycoca6") {
60 reject(
61 new Error(
62 "KDE couldn't register the capture helper. Make sure KDE's service tools (kbuildsycoca6) are installed, then reinstall the helper.",
63 ),
64 );
65 return;
66 }
67 if (stderr.includes("NoAuthorized")) {
68 reject(
69 new Error(
70 "KDE hasn't granted capture access. Reinstall the capture helper in setup, then try again.",
71 ),
72 );
73 } else
74 reject(
75 new Error(
76 stderr.trim() ||
77 "The KDE capture helper did not respond. Reopen capture setup and check access.",
78 ),
79 );
80 },
81 );
82 });
83}
84
85async function regularFile(path: string): Promise<Buffer | undefined> {
86 const stat = await NodeFSP.lstat(path).catch((error: NodeJS.ErrnoException) => {
87 if (error.code !== "ENOENT") throw error;
88 return undefined;
89 });
90 if (!stat) return undefined;
91 if (!stat.isFile() || stat.isSymbolicLink())
92 throw new Error(
93 "Capture helper files must be regular files. Remove the conflicting link before trying again.",
94 );
95 return NodeFSP.readFile(path);
96}
97
98/** No install on launch: writes happen only after the user chooses Install helper. */
99export class KdeCaptureSetup {
100 private readonly paths: KdeCapturePaths;
101 constructor(paths: KdeCapturePaths) {
102 this.paths = paths;
103 }
104
105 async state(): Promise<DesktopCaptureHelperState> {
106 try {
107 const { executable, desktop } = kdeCapturePaths(this.paths);
108 const [installed, entry] = await Promise.all([regularFile(executable), regularFile(desktop)]);
109 if (!installed || !entry)
110 return {
111 status: "not-installed",
112 message:
113 "Install the bundled helper to capture the window you're using without a picker.",
114 };
115 const bundle = await regularFile(this.paths.bundle);
116 if (!bundle)
117 return {
118 status: "error",
119 message: "The capture helper is missing from this build. Update or reinstall The Knife.",
120 };
121 if (!installed.equals(bundle) || entry.toString() !== kdeCaptureDesktopEntry(executable))
122 return {
123 status: "update-required",
124 message: "Update the bundled capture helper to continue.",
125 };
126 const capabilities = decodeCapabilities(await run(executable, ["check"]));
127 return {
128 status: "ready",
129 message: "KDE capture access is ready. Next, choose your shortcut.",
130 feedbackAvailable: capabilities.feedbackAvailable ?? false,
131 };
132 } catch (error) {
133 return {
134 status: "error",
135 message: error instanceof Error ? error.message : "Couldn't check KDE capture access.",
136 };
137 }
138 }
139
140 async perform(action: "install-kde-helper" | "remove-kde-helper") {
141 const { executable, desktop } = kdeCapturePaths(this.paths);
142 const entry = await regularFile(desktop);
143 if (entry && !entry.toString().split("\n").includes(MARKER))
144 throw new Error(
145 "Another desktop entry uses the capture helper's name. Rename it before continuing.",
146 );
147 // Never overwrite/follow a symlink, including the installation directory itself.
148 const directory = NodePath.dirname(executable);
149 const existing = await NodeFSP.lstat(directory).catch((error: NodeJS.ErrnoException) => {
150 if (error.code !== "ENOENT") throw error;
151 return undefined;
152 });
153 if (existing && (!existing.isDirectory() || existing.isSymbolicLink()))
154 throw new Error("The capture helper directory is not a regular directory.");
155 await regularFile(executable);
156 if (action === "remove-kde-helper") {
157 if (entry) await NodeFSP.unlink(desktop);
158 await NodeFSP.unlink(executable).catch((error: NodeJS.ErrnoException) => {
159 if (error.code !== "ENOENT") throw error;
160 });
161 } else {
162 const bundle = await regularFile(this.paths.bundle);
163 if (!bundle)
164 throw new Error(
165 "The capture helper is missing from this build. Update or reinstall The Knife.",
166 );
167 await NodeFSP.mkdir(directory, { recursive: true });
168 await NodeFSP.mkdir(NodePath.dirname(desktop), { recursive: true });
169 const staging = await NodeFSP.mkdtemp(NodePath.join(directory, ".install-"));
170 const stagedDesktop = `${desktop}.${NodeCrypto.randomUUID()}.tmp`;
171 try {
172 const staged = NodePath.join(staging, KDE_CAPTURE_EXECUTABLE);
173 await NodeFSP.writeFile(staged, bundle, { mode: 0o755 });
174 await NodeFSP.rename(staged, executable);
175 await NodeFSP.writeFile(stagedDesktop, kdeCaptureDesktopEntry(executable), {
176 mode: 0o644,
177 flag: "wx",
178 });
179 await NodeFSP.rename(stagedDesktop, desktop);
180 } finally {
181 await NodeFSP.rm(stagedDesktop, { force: true });
182 await NodeFSP.rm(staging, { recursive: true, force: true });
183 }
184 }
185 // KService must see the new desktop entry before KWin checks the executable.
186 await run("kbuildsycoca6", ["--noincremental"]);
187 // AppImages change XDG search paths, and KService caches are also locale-specific.
188 // Refresh Plasma's cache in its session environment, not just the app's cache.
189 // Non-systemd sessions use the direct refresh and atomic directory notification above.
190 await run("systemd-run", [
191 "--user",
192 "--quiet",
193 "--wait",
194 "--collect",
195 "--pipe",
196 "--service-type=exec",
197 "kbuildsycoca6",
198 "--noincremental",
199 ]).catch(() => undefined);
200 }
201}
202
203const Dimension = Schema.Int.check(Schema.isGreaterThan(0));
204const Bounds = Schema.Struct({ x: Schema.Int, y: Schema.Int, width: Dimension, height: Dimension });
205const Window = Schema.Struct({
206 title: Schema.String,
207 appName: Schema.String,
208 appIdentifier: Schema.String,
209 processId: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
210 bounds: Bounds,
211 clientBounds: Bounds,
212});
213const decodeCapture = Schema.decodeUnknownSync(
214 Schema.fromJsonString(Schema.Struct({ window: Schema.NullOr(Window) })),
215);
216
217export async function captureKdeWindow(
218 paths: KdeCapturePaths,
219 options?: { readonly flash: boolean; readonly animate: boolean },
220): Promise<LinuxWindowSnapshot> {
221 const state = await new KdeCaptureSetup(paths).state();
222 if (state.status !== "ready")
223 throw new Error(`${state.message} Open Settings → SnapShots to continue setup.`);
224 const { executable } = kdeCapturePaths(paths);
225 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-kde-capture-"));
226 let retained = false;
227 const cleanup = () => NodeFSP.rm(directory, { recursive: true, force: true });
228 try {
229 const result = decodeCapture(await run(executable, ["capture", directory]));
230 const png = await readPortalPng(
231 NodeURL.pathToFileURL(NodePath.join(directory, "capture.png")).href,
232 );
233 const feedback = new AbortController();
234 const effects =
235 state.feedbackAvailable && result.window && options && (options.flash || options.animate)
236 ? await startNativeCaptureFeedback(executable, directory, {
237 bounds: result.window.bounds,
238 pid: process.pid,
239 ...options,
240 }).catch(() => undefined)
241 : undefined;
242 if (effects) {
243 retained = true;
244 void effects.closed.then(cleanup).catch(() => undefined);
245 }
246 let targetTitle = "";
247 const close = () => {
248 effects?.close();
249 feedback.abort();
250 };
251 return {
252 png,
253 ...(result.window ? { window: result.window } : {}),
254 feedback: {
255 animationStarted: effects?.animationStarted ?? false,
256 activate: async (title) => {
257 targetTitle = title;
258 const activation = await NodeFSP.mkdtemp(
259 NodePath.join(NodeOS.tmpdir(), "t3-kde-activate-"),
260 );
261 try {
262 await run(
263 executable,
264 ["activate", activation, String(process.pid), title],
265 feedback.signal,
266 );
267 } finally {
268 await NodeFSP.rm(activation, { recursive: true, force: true });
269 }
270 },
271 animateTo: async (frame) => {
272 await effects?.animateTo(targetTitle, frame);
273 },
274 complete: async () => {
275 await effects?.complete();
276 feedback.abort();
277 await cleanup();
278 },
279 close,
280 },
281 };
282 } finally {
283 if (!retained) await cleanup();
284 }
285}