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