// @effect-diagnostics nodeBuiltinImport:off -- GNOME's per-user installation is a native adapter boundary.// @effect-diagnostics globalTimers:off -- Bound calls into the user's desktop session.import * as NodeFSP from "node:fs/promises";import * as NodePath from "node:path";import { Message, sessionBus, type MessageBus, type MessageLike } from "dbus-next";import * as Schema from "effect/Schema";import type { DesktopCaptureExtensionState } from "@t3tools/contracts"; import { GNOME_CAPTURE_FILES, GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts";export { isGnomeCaptureSession } from "./linuxCaptureSession.ts"; const SHELL = "org.gnome.Shell";const SHELL_PATH = "/org/gnome/Shell";const EXTENSIONS = "org.gnome.Shell.Extensions";const NumberValue = Schema.Struct({ value: Schema.Number });const decodeInfo = Schema.decodeUnknownSync( Schema.Struct({ state: Schema.optional(NumberValue), version: Schema.optional(NumberValue), error: Schema.optional(Schema.Struct({ value: Schema.String })), }),);const decodeProperties = Schema.decodeUnknownSync( Schema.Struct({ ShellVersion: Schema.Struct({ value: Schema.String }), UserExtensionsEnabled: Schema.Struct({ value: Schema.Boolean }), }),);const Metadata = Schema.Struct({ uuid: Schema.Literal(GNOME_CAPTURE_UUID), version: Schema.Number, "shell-version": Schema.Array(Schema.String),});const decodeMetadata = Schema.decodeUnknownSync(Schema.fromJsonString(Metadata)); type SetupPaths = { readonly bundle: string; readonly dataHome: string }; /** Copies only the shipped extension, offline. Replaced versions are kept for recovery. */export async function installGnomeCaptureBundle({ bundle, dataHome }: SetupPaths) { const metadata = decodeMetadata( await NodeFSP.readFile(NodePath.join(bundle, "metadata.json"), "utf8"), ); const parent = NodePath.join(dataHome, "gnome-shell", "extensions"); const target = NodePath.join(parent, GNOME_CAPTURE_UUID); await NodeFSP.mkdir(parent, { recursive: true }); const existing = await NodeFSP.lstat(target).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; return undefined; }); if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) throw new Error( "The extension installation is not a regular directory. Manage it in GNOME Extensions instead.", ); if (existing) { const installed = decodeMetadata( await NodeFSP.readFile(NodePath.join(target, "metadata.json"), "utf8"), ); if (installed.version > metadata.version) throw new Error("A newer extension is installed. Update T3 Code instead of replacing it."); } const staged = await NodeFSP.mkdtemp(NodePath.join(parent, ".t3-capture-install-")); let backup: string | undefined; try { for (const name of GNOME_CAPTURE_FILES) { await NodeFSP.copyFile(NodePath.join(bundle, name), NodePath.join(staged, name)); await NodeFSP.chmod(NodePath.join(staged, name), 0o644); } await NodeFSP.chmod(staged, 0o755); if (existing) { const backupParent = NodePath.join(dataHome, "t3code", "extension-backups"); await NodeFSP.mkdir(backupParent, { recursive: true }); backup = NodePath.join( await NodeFSP.mkdtemp(NodePath.join(backupParent, "capture-")), GNOME_CAPTURE_UUID, ); await NodeFSP.rename(target, backup); } try { await NodeFSP.rename(staged, target); } catch (error) { if (backup) await NodeFSP.rename(backup, target); throw error; } } finally { await NodeFSP.rm(staged, { recursive: true, force: true }); }} /** A short-lived, read-only probe unless the user explicitly invokes an action. */export class GnomeCaptureSetup { private readonly disconnected: Promise<never>; private readonly paths: SetupPaths; private readonly bus: MessageBus; private failure: Error | undefined; constructor(paths: SetupPaths, bus: MessageBus = sessionBus()) { this.paths = paths; this.bus = bus; this.disconnected = new Promise((_, reject) => bus.on("error", (error: Error) => { this.failure = error; reject(error); }), ); void this.disconnected.catch(() => undefined); } close() { this.bus.disconnect(); } private async call(message: Omit<MessageLike, "destination" | "path">) { if (this.failure) throw this.failure; let timer: ReturnType<typeof setTimeout> | undefined; try { const reply = await Promise.race([ this.bus.call(new Message({ destination: SHELL, path: SHELL_PATH, ...message })), this.disconnected, new Promise<never>((_, reject) => { timer = setTimeout( () => reject( new Error( "GNOME did not respond. Sign in to a GNOME Wayland session and try again.", ), ), 5_000, ); }), ]); if (!reply) throw new Error("GNOME returned no setup information."); return reply.body[0] as unknown; } finally { clearTimeout(timer); } } async state(): Promise<DesktopCaptureExtensionState> { try { const [properties, info, bundled, installed] = await Promise.all([ this.call({ interface: "org.freedesktop.DBus.Properties", member: "GetAll", signature: "s", body: [EXTENSIONS], }).then(decodeProperties), this.call({ interface: EXTENSIONS, member: "GetExtensionInfo", signature: "s", body: [GNOME_CAPTURE_UUID], }).then(decodeInfo), NodeFSP.readFile(NodePath.join(this.paths.bundle, "metadata.json"), "utf8").then( decodeMetadata, ), NodeFSP.readFile( NodePath.join( this.paths.dataHome, "gnome-shell", "extensions", GNOME_CAPTURE_UUID, "metadata.json", ), "utf8", ) .then(decodeMetadata) .catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; return undefined; }), ]); const major = properties.ShellVersion.value.split(".")[0]!; if (!bundled["shell-version"].includes(major)) return { status: "unsupported", message: `The bundled extension supports GNOME ${bundled["shell-version"].join(", ")}. This session runs GNOME ${major}.`, }; if (!info.state && !installed) return { status: "not-installed", message: "Install the bundled extension to capture the active window without a picker. No download or administrator password is needed.", }; if (installed && (!info.state || (info.version && installed.version > info.version.value))) return { status: "restart-required", message: "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.", }; if ((installed?.version ?? info.version?.value ?? 0) < bundled.version) return { status: "update-required", message: "A newer extension is bundled with this app. Install it, then sign out and back in to load the update.", }; if (!properties.UserExtensionsEnabled.value) return { status: "extensions-disabled", message: "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.", }; if (info.state?.value === 1) return { status: "enabled", message: "The T3 Code extension is running. Active-window snapshots are available.", }; if (info.state?.value === 3 || info.state?.value === 4) return { status: "error", message: info.error?.value || "GNOME could not load the extension. Check GNOME Extensions for details, or sign out and back in.", }; return { status: "disabled", message: "Enable the T3 Code extension to allow active-window snapshots. You can disable it here at any time.", }; } catch (error) { return { status: "error", message: error instanceof Error ? error.message : "Could not check GNOME extension setup.", }; } } async perform(action: "install-extension" | "enable-extension" | "disable-extension") { const state = await this.state(); if (action === "install-extension") { if (state.status !== "not-installed" && state.status !== "update-required") throw new Error(state.message); await installGnomeCaptureBundle(this.paths); // GNOME discovers a newly installed local extension at the next login. return; } if (action === "enable-extension" && state.status !== "disabled") throw new Error(state.message); const result = await this.call({ interface: EXTENSIONS, member: action === "enable-extension" ? "EnableExtension" : "DisableExtension", signature: "s", body: [GNOME_CAPTURE_UUID], }); if (result !== true) throw new Error("GNOME has not loaded the extension. Sign out and back in, then try again."); }}
// @effect-diagnostics nodeBuiltinImport:off -- GNOME's per-user installation is a native adapter boundary.
// @effect-diagnostics globalTimers:off -- Bound calls into the user's desktop session.
import * as NodeFSP from "node:fs/promises";
import * as NodePath from "node:path";
import { Message, sessionBus, type MessageBus, type MessageLike } from "dbus-next";
import * as Schema from "effect/Schema";
import type { DesktopCaptureExtensionState } from "@t3tools/contracts";
import { GNOME_CAPTURE_FILES, GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts";
export { isGnomeCaptureSession } from "./linuxCaptureSession.ts";
const SHELL = "org.gnome.Shell";
const SHELL_PATH = "/org/gnome/Shell";
const EXTENSIONS = "org.gnome.Shell.Extensions";
const NumberValue = Schema.Struct({ value: Schema.Number });
const decodeInfo = Schema.decodeUnknownSync(
Schema.Struct({
state: Schema.optional(NumberValue),
version: Schema.optional(NumberValue),
error: Schema.optional(Schema.Struct({ value: Schema.String })),
}),
);
const decodeProperties = Schema.decodeUnknownSync(
Schema.Struct({
ShellVersion: Schema.Struct({ value: Schema.String }),
UserExtensionsEnabled: Schema.Struct({ value: Schema.Boolean }),
}),
);
const Metadata = Schema.Struct({
uuid: Schema.Literal(GNOME_CAPTURE_UUID),
version: Schema.Number,
"shell-version": Schema.Array(Schema.String),
});
const decodeMetadata = Schema.decodeUnknownSync(Schema.fromJsonString(Metadata));
type SetupPaths = { readonly bundle: string; readonly dataHome: string };
/** Copies only the shipped extension, offline. Replaced versions are kept for recovery. */
export async function installGnomeCaptureBundle({ bundle, dataHome }: SetupPaths) {
const metadata = decodeMetadata(
await NodeFSP.readFile(NodePath.join(bundle, "metadata.json"), "utf8"),
);
const parent = NodePath.join(dataHome, "gnome-shell", "extensions");
const target = NodePath.join(parent, GNOME_CAPTURE_UUID);
await NodeFSP.mkdir(parent, { recursive: true });
const existing = await NodeFSP.lstat(target).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
return undefined;
});
if (existing && (!existing.isDirectory() || existing.isSymbolicLink()))
throw new Error(
"The extension installation is not a regular directory. Manage it in GNOME Extensions instead.",
);
if (existing) {
const installed = decodeMetadata(
await NodeFSP.readFile(NodePath.join(target, "metadata.json"), "utf8"),
);
if (installed.version > metadata.version)
throw new Error("A newer extension is installed. Update T3 Code instead of replacing it.");
}
const staged = await NodeFSP.mkdtemp(NodePath.join(parent, ".t3-capture-install-"));
let backup: string | undefined;
try {
for (const name of GNOME_CAPTURE_FILES) {
await NodeFSP.copyFile(NodePath.join(bundle, name), NodePath.join(staged, name));
await NodeFSP.chmod(NodePath.join(staged, name), 0o644);
}
await NodeFSP.chmod(staged, 0o755);
if (existing) {
const backupParent = NodePath.join(dataHome, "t3code", "extension-backups");
await NodeFSP.mkdir(backupParent, { recursive: true });
backup = NodePath.join(
await NodeFSP.mkdtemp(NodePath.join(backupParent, "capture-")),
GNOME_CAPTURE_UUID,
);
await NodeFSP.rename(target, backup);
}
try {
await NodeFSP.rename(staged, target);
} catch (error) {
if (backup) await NodeFSP.rename(backup, target);
throw error;
}
} finally {
await NodeFSP.rm(staged, { recursive: true, force: true });
}
}
/** A short-lived, read-only probe unless the user explicitly invokes an action. */
export class GnomeCaptureSetup {
private readonly disconnected: Promise<never>;
private readonly paths: SetupPaths;
private readonly bus: MessageBus;
private failure: Error | undefined;
constructor(paths: SetupPaths, bus: MessageBus = sessionBus()) {
this.paths = paths;
this.bus = bus;
this.disconnected = new Promise((_, reject) =>
bus.on("error", (error: Error) => {
this.failure = error;
reject(error);
}),
);
void this.disconnected.catch(() => undefined);
}
close() {
this.bus.disconnect();
}
private async call(message: Omit<MessageLike, "destination" | "path">) {
if (this.failure) throw this.failure;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const reply = await Promise.race([
this.bus.call(new Message({ destination: SHELL, path: SHELL_PATH, ...message })),
this.disconnected,
new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
"GNOME did not respond. Sign in to a GNOME Wayland session and try again.",
),
),
5_000,
);
}),
]);
if (!reply) throw new Error("GNOME returned no setup information.");
return reply.body[0] as unknown;
} finally {
clearTimeout(timer);
}
}
async state(): Promise<DesktopCaptureExtensionState> {
try {
const [properties, info, bundled, installed] = await Promise.all([
this.call({
interface: "org.freedesktop.DBus.Properties",
member: "GetAll",
signature: "s",
body: [EXTENSIONS],
}).then(decodeProperties),
this.call({
interface: EXTENSIONS,
member: "GetExtensionInfo",
signature: "s",
body: [GNOME_CAPTURE_UUID],
}).then(decodeInfo),
NodeFSP.readFile(NodePath.join(this.paths.bundle, "metadata.json"), "utf8").then(
decodeMetadata,
),
NodeFSP.readFile(
NodePath.join(
this.paths.dataHome,
"gnome-shell",
"extensions",
GNOME_CAPTURE_UUID,
"metadata.json",
),
"utf8",
)
.then(decodeMetadata)
.catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
return undefined;
}),
]);
const major = properties.ShellVersion.value.split(".")[0]!;
if (!bundled["shell-version"].includes(major))
return {
status: "unsupported",
message: `The bundled extension supports GNOME ${bundled["shell-version"].join(", ")}. This session runs GNOME ${major}.`,
};
if (!info.state && !installed)
return {
status: "not-installed",
message:
"Install the bundled extension to capture the active window without a picker. No download or administrator password is needed.",
};
if (installed && (!info.state || (info.version && installed.version > info.version.value)))
return {
status: "restart-required",
message:
"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.",
};
if ((installed?.version ?? info.version?.value ?? 0) < bundled.version)
return {
status: "update-required",
message:
"A newer extension is bundled with this app. Install it, then sign out and back in to load the update.",
};
if (!properties.UserExtensionsEnabled.value)
return {
status: "extensions-disabled",
message:
"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.",
};
if (info.state?.value === 1)
return {
status: "enabled",
message: "The T3 Code extension is running. Active-window snapshots are available.",
};
if (info.state?.value === 3 || info.state?.value === 4)
return {
status: "error",
message:
info.error?.value ||
"GNOME could not load the extension. Check GNOME Extensions for details, or sign out and back in.",
};
return {
status: "disabled",
message:
"Enable the T3 Code extension to allow active-window snapshots. You can disable it here at any time.",
};
} catch (error) {
return {
status: "error",
message: error instanceof Error ? error.message : "Could not check GNOME extension setup.",
};
}
}
async perform(action: "install-extension" | "enable-extension" | "disable-extension") {
const state = await this.state();
if (action === "install-extension") {
if (state.status !== "not-installed" && state.status !== "update-required")
throw new Error(state.message);
await installGnomeCaptureBundle(this.paths);
// GNOME discovers a newly installed local extension at the next login.
return;
}
if (action === "enable-extension" && state.status !== "disabled")
throw new Error(state.message);
const result = await this.call({
interface: EXTENSIONS,
member: action === "enable-extension" ? "EnableExtension" : "DisableExtension",
signature: "s",
body: [GNOME_CAPTURE_UUID],
});
if (result !== true)
throw new Error("GNOME has not loaded the extension. Sign out and back in, then try again.");
}
}