| 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. |
| 3 | import * as NodeFSP from "node:fs/promises"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | import { Message, sessionBus, type MessageBus, type MessageLike } from "dbus-next"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | import type { DesktopCaptureExtensionState } from "@t3tools/contracts"; |
| 8 | |
| 9 | import { GNOME_CAPTURE_FILES, GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts"; |
| 10 | export { isGnomeCaptureSession } from "./linuxCaptureSession.ts"; |
| 11 | |
| 12 | const SHELL = "org.gnome.Shell"; |
| 13 | const SHELL_PATH = "/org/gnome/Shell"; |
| 14 | const EXTENSIONS = "org.gnome.Shell.Extensions"; |
| 15 | const NumberValue = Schema.Struct({ value: Schema.Number }); |
| 16 | const 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 | ); |
| 23 | const decodeProperties = Schema.decodeUnknownSync( |
| 24 | Schema.Struct({ |
| 25 | ShellVersion: Schema.Struct({ value: Schema.String }), |
| 26 | UserExtensionsEnabled: Schema.Struct({ value: Schema.Boolean }), |
| 27 | }), |
| 28 | ); |
| 29 | const Metadata = Schema.Struct({ |
| 30 | uuid: Schema.Literal(GNOME_CAPTURE_UUID), |
| 31 | version: Schema.Number, |
| 32 | "shell-version": Schema.Array(Schema.String), |
| 33 | }); |
| 34 | const decodeMetadata = Schema.decodeUnknownSync(Schema.fromJsonString(Metadata)); |
| 35 | |
| 36 | type SetupPaths = { readonly bundle: string; readonly dataHome: string }; |
| 37 | |
| 38 | /** Copies only the shipped extension, offline. Replaced versions are kept for recovery. */ |
| 39 | export 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. */ |
| 90 | export 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 | } |