1// @effect-diagnostics nodeBuiltinImport:off -- GNOME's per-user installation is a native adapter boundary.
2// @effect-diagnostics globalTimers:off -- Bound calls into the user's desktop session.
3import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5import { Message, sessionBus, type MessageBus, type MessageLike } from "dbus-next";
6import * as Schema from "effect/Schema";
7import type { DesktopCaptureExtensionState } from "@t3tools/contracts";
8
9import { GNOME_CAPTURE_FILES, GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts";
10export { isGnomeCaptureSession } from "./linuxCaptureSession.ts";
11
12const SHELL = "org.gnome.Shell";
13const SHELL_PATH = "/org/gnome/Shell";
14const EXTENSIONS = "org.gnome.Shell.Extensions";
15const NumberValue = Schema.Struct({ value: Schema.Number });
16const decodeInfo = Schema.decodeUnknownSync(
17 Schema.Struct({
18 state: Schema.optional(NumberValue),
19 version: Schema.optional(NumberValue),
20 error: Schema.optional(Schema.Struct({ value: Schema.String })),
21 }),
22);
23const decodeProperties = Schema.decodeUnknownSync(
24 Schema.Struct({
25 ShellVersion: Schema.Struct({ value: Schema.String }),
26 UserExtensionsEnabled: Schema.Struct({ value: Schema.Boolean }),
27 }),
28);
29const Metadata = Schema.Struct({
30 uuid: Schema.Literal(GNOME_CAPTURE_UUID),
31 version: Schema.Number,
32 "shell-version": Schema.Array(Schema.String),
33});
34const decodeMetadata = Schema.decodeUnknownSync(Schema.fromJsonString(Metadata));
35
36type SetupPaths = { readonly bundle: string; readonly dataHome: string };
37
38/** Copies only the shipped extension, offline. Replaced versions are kept for recovery. */
39export async function installGnomeCaptureBundle({ bundle, dataHome }: SetupPaths) {
40 const metadata = decodeMetadata(
41 await NodeFSP.readFile(NodePath.join(bundle, "metadata.json"), "utf8"),
42 );
43 const parent = NodePath.join(dataHome, "gnome-shell", "extensions");
44 const target = NodePath.join(parent, GNOME_CAPTURE_UUID);
45 await NodeFSP.mkdir(parent, { recursive: true });
46 const existing = await NodeFSP.lstat(target).catch((error: NodeJS.ErrnoException) => {
47 if (error.code !== "ENOENT") throw error;
48 return undefined;
49 });
50 if (existing && (!existing.isDirectory() || existing.isSymbolicLink()))
51 throw new Error(
52 "The extension installation is not a regular directory. Manage it in GNOME Extensions instead.",
53 );
54 if (existing) {
55 const installed = decodeMetadata(
56 await NodeFSP.readFile(NodePath.join(target, "metadata.json"), "utf8"),
57 );
58 if (installed.version > metadata.version)
59 throw new Error("A newer extension is installed. Update The Knife instead of replacing it.");
60 }
61 const staged = await NodeFSP.mkdtemp(NodePath.join(parent, ".t3-capture-install-"));
62 let backup: string | undefined;
63 try {
64 for (const name of GNOME_CAPTURE_FILES) {
65 await NodeFSP.copyFile(NodePath.join(bundle, name), NodePath.join(staged, name));
66 await NodeFSP.chmod(NodePath.join(staged, name), 0o644);
67 }
68 await NodeFSP.chmod(staged, 0o755);
69 if (existing) {
70 const backupParent = NodePath.join(dataHome, "t3code", "extension-backups");
71 await NodeFSP.mkdir(backupParent, { recursive: true });
72 backup = NodePath.join(
73 await NodeFSP.mkdtemp(NodePath.join(backupParent, "capture-")),
74 GNOME_CAPTURE_UUID,
75 );
76 await NodeFSP.rename(target, backup);
77 }
78 try {
79 await NodeFSP.rename(staged, target);
80 } catch (error) {
81 if (backup) await NodeFSP.rename(backup, target);
82 throw error;
83 }
84 } finally {
85 await NodeFSP.rm(staged, { recursive: true, force: true });
86 }
87}
88
89/** A short-lived, read-only probe unless the user explicitly invokes an action. */
90export class GnomeCaptureSetup {
91 private readonly disconnected: Promise<never>;
92 private readonly paths: SetupPaths;
93 private readonly bus: MessageBus;
94 private failure: Error | undefined;
95 constructor(paths: SetupPaths, bus: MessageBus = sessionBus()) {
96 this.paths = paths;
97 this.bus = bus;
98 this.disconnected = new Promise((_, reject) =>
99 bus.on("error", (error: Error) => {
100 this.failure = error;
101 reject(error);
102 }),
103 );
104 void this.disconnected.catch(() => undefined);
105 }
106
107 close() {
108 this.bus.disconnect();
109 }
110
111 private async call(message: Omit<MessageLike, "destination" | "path">) {
112 if (this.failure) throw this.failure;
113 let timer: ReturnType<typeof setTimeout> | undefined;
114 try {
115 const reply = await Promise.race([
116 this.bus.call(new Message({ destination: SHELL, path: SHELL_PATH, ...message })),
117 this.disconnected,
118 new Promise<never>((_, reject) => {
119 timer = setTimeout(
120 () =>
121 reject(
122 new Error(
123 "GNOME did not respond. Sign in to a GNOME Wayland session and try again.",
124 ),
125 ),
126 5_000,
127 );
128 }),
129 ]);
130 if (!reply) throw new Error("GNOME returned no setup information.");
131 return reply.body[0] as unknown;
132 } finally {
133 clearTimeout(timer);
134 }
135 }
136
137 async state(): Promise<DesktopCaptureExtensionState> {
138 try {
139 const [properties, info, bundled, installed] = await Promise.all([
140 this.call({
141 interface: "org.freedesktop.DBus.Properties",
142 member: "GetAll",
143 signature: "s",
144 body: [EXTENSIONS],
145 }).then(decodeProperties),
146 this.call({
147 interface: EXTENSIONS,
148 member: "GetExtensionInfo",
149 signature: "s",
150 body: [GNOME_CAPTURE_UUID],
151 }).then(decodeInfo),
152 NodeFSP.readFile(NodePath.join(this.paths.bundle, "metadata.json"), "utf8").then(
153 decodeMetadata,
154 ),
155 NodeFSP.readFile(
156 NodePath.join(
157 this.paths.dataHome,
158 "gnome-shell",
159 "extensions",
160 GNOME_CAPTURE_UUID,
161 "metadata.json",
162 ),
163 "utf8",
164 )
165 .then(decodeMetadata)
166 .catch((error: NodeJS.ErrnoException) => {
167 if (error.code !== "ENOENT") throw error;
168 return undefined;
169 }),
170 ]);
171 const major = properties.ShellVersion.value.split(".")[0]!;
172 if (!bundled["shell-version"].includes(major))
173 return {
174 status: "unsupported",
175 message: `The bundled extension supports GNOME ${bundled["shell-version"].join(", ")}. This session runs GNOME ${major}.`,
176 };
177 if (!info.state && !installed)
178 return {
179 status: "not-installed",
180 message:
181 "Install the bundled extension to capture the active window without a picker. No download or administrator password is needed.",
182 };
183 if (installed && (!info.state || (info.version && installed.version > info.version.value)))
184 return {
185 status: "restart-required",
186 message:
187 "Installed. Save your work, sign out of GNOME and sign back in, then return here to enable the extension. Restarting The Knife alone is not enough.",
188 };
189 if ((installed?.version ?? info.version?.value ?? 0) < bundled.version)
190 return {
191 status: "update-required",
192 message:
193 "A newer extension is bundled with this app. Install it, then sign out and back in to load the update.",
194 };
195 if (!properties.UserExtensionsEnabled.value)
196 return {
197 status: "extensions-disabled",
198 message:
199 "GNOME has disabled user extensions. Turn on Extensions in the GNOME Extensions app, then check again. The Knife will not enable your other extensions for you.",
200 };
201 if (info.state?.value === 1)
202 return {
203 status: "enabled",
204 message: "The Knife extension is running. Active-window snapshots are available.",
205 };
206 if (info.state?.value === 3 || info.state?.value === 4)
207 return {
208 status: "error",
209 message:
210 info.error?.value ||
211 "GNOME could not load the extension. Check GNOME Extensions for details, or sign out and back in.",
212 };
213 return {
214 status: "disabled",
215 message:
216 "Enable The Knife’s extension to allow active-window snapshots. You can disable it here at any time.",
217 };
218 } catch (error) {
219 return {
220 status: "error",
221 message: error instanceof Error ? error.message : "Could not check GNOME extension setup.",
222 };
223 }
224 }
225
226 async perform(action: "install-extension" | "enable-extension" | "disable-extension") {
227 const state = await this.state();
228 if (action === "install-extension") {
229 if (state.status !== "not-installed" && state.status !== "update-required")
230 throw new Error(state.message);
231 await installGnomeCaptureBundle(this.paths);
232 // GNOME discovers a newly installed local extension at the next login.
233 return;
234 }
235 if (action === "enable-extension" && state.status !== "disabled")
236 throw new Error(state.message);
237 const result = await this.call({
238 interface: EXTENSIONS,
239 member: action === "enable-extension" ? "EnableExtension" : "DisableExtension",
240 signature: "s",
241 body: [GNOME_CAPTURE_UUID],
242 });
243 if (result !== true)
244 throw new Error("GNOME has not loaded the extension. Sign out and back in, then try again.");
245 }
246}