Open History to see the last commit
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 { CaptureSetupRequiredError, 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
32function kdeCaptureDesktopEntry(executable: string): string {
33 return [
34 "[Desktop Entry]",
35 "Type=Application",
36 "Name=T3 Code 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
98export class KdeCaptureSetup {
99 private readonly paths: KdeCapturePaths;
100 constructor(paths: KdeCapturePaths) {
101 this.paths = paths;
102 }
103
104 async state(): Promise<DesktopCaptureHelperState> {
105 try {
106 const { executable, desktop } = kdeCapturePaths(this.paths);
107 const [installed, entry] = await Promise.all([regularFile(executable), regularFile(desktop)]);
108 if (!installed || !entry)
109 return {
110 status: "not-installed",
111 message:
112 "Install the bundled helper to capture the window you're using without a picker.",
113 };
114 const bundle = await regularFile(this.paths.bundle);
115 if (!bundle)
116 return {
117 status: "error",
118 message: "The capture helper is missing from this build. Update or reinstall T3 Code.",
119 };
120 if (!installed.equals(bundle) || entry.toString() !== kdeCaptureDesktopEntry(executable))
121 return {
122 status: "update-required",
123 message: "Update the bundled capture helper to continue.",
124 };
125 const capabilities = decodeCapabilities(await run(executable, ["check"]));
126 return {
127 status: "ready",
128 message: "KDE capture access is ready. Next, choose your shortcut.",
129 feedbackAvailable: capabilities.feedbackAvailable ?? false,
130 };
131 } catch (error) {
132 return {
133 status: "error",
134 message: error instanceof Error ? error.message : "Couldn't check KDE capture access.",
135 };
136 }
137 }
138
139 async perform(action: "install-kde-helper" | "remove-kde-helper") {
140 const { executable, desktop } = kdeCapturePaths(this.paths);
141 const entry = await regularFile(desktop);
142 if (entry && !entry.toString().split("\n").includes(MARKER))
143 throw new Error(
144 "Another desktop entry uses the capture helper's name. Rename it before continuing.",
145 );
146 const directory = NodePath.dirname(executable);
147 const existing = await NodeFSP.lstat(directory).catch((error: NodeJS.ErrnoException) => {
148 if (error.code !== "ENOENT") throw error;
149 return undefined;
150 });
151 if (existing && (!existing.isDirectory() || existing.isSymbolicLink()))
152 throw new Error("The capture helper directory is not a regular directory.");
153 await regularFile(executable);
154 if (action === "remove-kde-helper") {
155 if (entry) await NodeFSP.unlink(desktop);
156 await NodeFSP.unlink(executable).catch((error: NodeJS.ErrnoException) => {
157 if (error.code !== "ENOENT") throw error;
158 });
159 } else {
160 const bundle = await regularFile(this.paths.bundle);
161 if (!bundle)
162 throw new Error(
163 "The capture helper is missing from this build. Update or reinstall T3 Code.",
164 );
165 await NodeFSP.mkdir(directory, { recursive: true });
166 await NodeFSP.mkdir(NodePath.dirname(desktop), { recursive: true });
167 const staging = await NodeFSP.mkdtemp(NodePath.join(directory, ".install-"));
168 const stagedDesktop = `${desktop}.${NodeCrypto.randomUUID()}.tmp`;
169 try {
170 const staged = NodePath.join(staging, KDE_CAPTURE_EXECUTABLE);
171 await NodeFSP.writeFile(staged, bundle, { mode: 0o755 });
172 await NodeFSP.rename(staged, executable);
173 await NodeFSP.writeFile(stagedDesktop, kdeCaptureDesktopEntry(executable), {
174 mode: 0o644,
175 flag: "wx",
176 });
177 await NodeFSP.rename(stagedDesktop, desktop);
178 } finally {
179 await NodeFSP.rm(stagedDesktop, { force: true });
180 await NodeFSP.rm(staging, { recursive: true, force: true });
181 }
182 }
183 // KService must see the new desktop entry before KWin checks the executable.
184 await run("kbuildsycoca6", ["--noincremental"]);
185 // AppImage search paths and locale affect KService caches; refresh in Plasma's session environment.
186 await run("systemd-run", [
187 "--user",
188 "--quiet",
189 "--wait",
190 "--collect",
191 "--pipe",
192 "--service-type=exec",
193 "kbuildsycoca6",
194 "--noincremental",
195 ]).catch(() => undefined);
196 }
197}
198
199const Dimension = Schema.Int.check(Schema.isGreaterThan(0));
200const Bounds = Schema.Struct({ x: Schema.Int, y: Schema.Int, width: Dimension, height: Dimension });
201const Window = Schema.Struct({
202 title: Schema.String,
203 appName: Schema.String,
204 appIdentifier: Schema.String,
205 processId: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
206 bounds: Bounds,
207 clientBounds: Bounds,
208});
209const decodeCapture = Schema.decodeUnknownSync(
210 Schema.fromJsonString(Schema.Struct({ window: Schema.NullOr(Window) })),
211);
212
213export async function captureKdeWindow(
214 paths: KdeCapturePaths,
215 options?: { readonly flash: boolean; readonly animate: boolean },
216): Promise<LinuxWindowSnapshot> {
217 const state = await new KdeCaptureSetup(paths).state();
218 if (state.status !== "ready") throw new CaptureSetupRequiredError({ state: state.message });
219 const { executable } = kdeCapturePaths(paths);
220 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-kde-capture-"));
221 let retained = false;
222 const cleanup = () => NodeFSP.rm(directory, { recursive: true, force: true });
223 try {
224 const result = decodeCapture(await run(executable, ["capture", directory]));
225 const png = await readPortalPng(
226 NodeURL.pathToFileURL(NodePath.join(directory, "capture.png")).href,
227 );
228 const feedback = new AbortController();
229 const effects =
230 state.feedbackAvailable && result.window && options && (options.flash || options.animate)
231 ? await startNativeCaptureFeedback(executable, directory, {
232 bounds: result.window.bounds,
233 pid: process.pid,
234 ...options,
235 }).catch(() => undefined)
236 : undefined;
237 if (effects) {
238 retained = true;
239 void effects.closed.then(cleanup).catch(() => undefined);
240 }
241 let targetTitle = "";
242 const close = () => {
243 effects?.close();
244 feedback.abort();
245 };
246 return {
247 png,
248 ...(result.window ? { window: result.window } : {}),
249 feedback: {
250 animationStarted: effects?.animationStarted ?? false,
251 activate: async (title) => {
252 targetTitle = title;
253 const activation = await NodeFSP.mkdtemp(
254 NodePath.join(NodeOS.tmpdir(), "t3-kde-activate-"),
255 );
256 try {
257 await run(
258 executable,
259 ["activate", activation, String(process.pid), title],
260 feedback.signal,
261 );
262 } finally {
263 await NodeFSP.rm(activation, { recursive: true, force: true });
264 }
265 },
266 animateTo: async (frame) => {
267 await effects?.animateTo(targetTitle, frame);
268 },
269 complete: async () => {
270 await effects?.complete();
271 feedback.abort();
272 await cleanup();
273 },
274 close,
275 },
276 };
277 } finally {
278 if (!retained) await cleanup();
279 }
280}