import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { createReadStream } from "node:fs"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { execFileOutput } from "../process/ExecFileOutput.js"; const execFileAsync = promisify(execFile); const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/"; const MAX_PACKAGE_BYTES = 100_000_000; const SUPPORTED_HOPPER_SHA256 = new Set([ "0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded", "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13", ]); interface LinuxHopperRelease { readonly filename: string; readonly file_length: string; readonly file_hash: string; } const SUPPORTED_RELEASES: Readonly< Record > = { deb: { filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.deb`, file_length: "35755772", file_hash: "e4f79dff602648a8ff4a88b773875b6bfac0dc65", }, rpm: { filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.rpm`, file_length: "32264475", file_hash: "b9a65f2f583a386d440d4401d9140aef3bf305de", }, arch: { filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.pkg.tar.xz`, file_length: "29405968", file_hash: "92d780b61cab38eda95dc4112dee4fbeb0920f46", }, }; export type LinuxPackageFamily = "deb" | "rpm" | "arch"; /** Exact stable external effects disclosed before a Linux Hopper installation. */ export interface LinuxHopperInstallDisclosure { readonly downloadUrl: string; readonly expectedBytes: number; readonly expectedSha1: string; readonly commands: readonly string[]; } /** Describe the release and package-manager commands without downloading or writing. */ export const linuxHopperInstallDisclosure = ( family: LinuxPackageFamily, isRoot: boolean, ): LinuxHopperInstallDisclosure => { const release = SUPPORTED_RELEASES[family]; return { downloadUrl: release.filename, expectedBytes: Number(release.file_length), expectedSha1: release.file_hash, commands: linuxPackageManagerCommands( family, ``, isRoot, ).map(({ executable, args }) => `${executable} ${args.join(" ")}`), }; }; interface LinuxDistributionIdentity { readonly id: string; readonly versionId?: string; } /** Parsed Linux distribution information used for support and package selection. */ export type LinuxDistribution = LinuxDistributionIdentity & ( | { readonly packageFamily: LinuxPackageFamily; readonly supported: true; } | { readonly packageFamily: LinuxPackageFamily | undefined; readonly supported: false; } ); /** Download response kept independent of Node's global fetch implementation. */ export interface LinuxHopperDownload { readonly ok: boolean; readonly bytes: Uint8Array; } /** External effects required to install the official Linux package. */ export interface LinuxHopperInstallHost { distribution(): Promise; download( url: string, options: { readonly signal?: AbortSignal }, ): Promise; createTemporaryDirectory(): Promise; writeArchive(path: string, bytes: Uint8Array): Promise; installPackage(family: LinuxPackageFamily, archive: string): Promise; launcherStatus(path: string): Promise; cleanup(path: string): Promise; } /** Structured outcome for every expected Linux installation failure. */ export type LinuxHopperInstallResult = | { readonly status: "installed"; readonly launcherPath: string } | { readonly status: "failed"; readonly reason: | "unsupported_host" | "release_metadata" | "download" | "integrity" | "authorization_or_package_manager" | "launcher_missing" | "runtime_dependencies" | "unsupported_hopper_build" | "cancelled"; }; export type LinuxHopperLauncherStatus = | "ready" | "missing" | "runtime_dependencies" | "unsupported_hopper_build"; /** Parse an os-release document without executing its shell syntax. */ export const parseLinuxDistribution = (text: string): LinuxDistribution => { const fields = new Map(); for (const line of text.split("\n")) { const match = /^([A-Z_]+)=(.*)$/u.exec(line.trim()); if (match === null) continue; const key = match[1]; const encoded = match[2]; if (key === undefined || encoded === undefined) continue; const value = encoded.startsWith('"') && encoded.endsWith('"') ? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") : encoded; fields.set(key, value); } const id = (fields.get("ID") ?? "unknown").toLowerCase(); const versionId = fields.get("VERSION_ID"); const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE")); const supported = packageFamily !== undefined && (id === "arch" || id === "cachyos" || (id === "ubuntu" && versionAtLeast(versionId, 24)) || (id === "fedora" && versionAtLeast(versionId, 41))); const identity = { id, ...(versionId === undefined ? {} : { versionId }), }; return supported ? { ...identity, packageFamily, supported: true } : { ...identity, packageFamily, supported: false }; }; /** Read and classify the current Linux distribution. */ export const readLinuxDistribution = async (): Promise< LinuxDistribution | undefined > => { try { return parseLinuxDistribution(await readFile("/etc/os-release", "utf8")); } catch (cause: unknown) { // best-effort cleanup: optional host probing; unknown distribution means unsupported. void cause; return undefined; } }; /** Download, verify, install, and read back the official Hopper Linux package. */ export const installLinuxHopper = async ( host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(), options: { readonly signal?: AbortSignal; readonly releases?: Readonly< Record >; } = {}, ): Promise => { if (options.signal?.aborted === true) return { status: "failed", reason: "cancelled" }; const distribution = await host.distribution(); if (distribution?.supported !== true) return { status: "failed", reason: "unsupported_host" }; let temporary: string | undefined; try { const release = (options.releases ?? SUPPORTED_RELEASES)[ distribution.packageFamily ]; const archiveDownload = await host.download(release.filename, options); if (!archiveDownload.ok) return { status: "failed", reason: "download" }; if (!packageIntegrityMatches(archiveDownload.bytes, release)) return { status: "failed", reason: "integrity" }; temporary = await host.createTemporaryDirectory(); const archive = join(temporary, `hopper.${distribution.packageFamily}`); await host.writeArchive(archive, archiveDownload.bytes); if (!(await host.installPackage(distribution.packageFamily, archive))) return { status: "failed", reason: "authorization_or_package_manager" }; const launcherPath = "/opt/hopper/bin/Hopper"; const launcherStatus = await host.launcherStatus(launcherPath); if (launcherStatus !== "ready") return { status: "failed", reason: launcherStatus === "missing" ? "launcher_missing" : launcherStatus, }; return { status: "installed", launcherPath }; } catch (cause: unknown) { if (isAbortCause(cause)) return { status: "failed", reason: "cancelled" }; return { status: "failed", reason: "download" }; } finally { if (temporary !== undefined) await host.cleanup(temporary); } }; /** Canonical launcher path for a legacy user-local Linux Hopper installation. */ export const linuxHopperLauncherPath = (home: string): string => join(home, ".local/share/rea/hopper/bin/Hopper"); const systemLinuxHopperInstallHost = (): LinuxHopperInstallHost => ({ distribution: readLinuxDistribution, async download(url, options) { const response = await fetch(url, { headers: { accept: "application/json", "user-agent": "rea-installer" }, ...(options.signal === undefined ? {} : { signal: options.signal }), }); return { ok: response.ok, bytes: new Uint8Array(await response.arrayBuffer()), }; }, createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")), writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }), installPackage: installSystemPackage, async launcherStatus(path) { try { await access(path); const linked = await execFileOutput("ldd", [path]); if (!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`)) return "runtime_dependencies"; return (await linuxHopperBinarySupported(path)) ? "ready" : "unsupported_hopper_build"; } catch (cause: unknown) { // best-effort cleanup: optional launcher probing; unreadable means missing. void cause; return "missing"; } }, cleanup: (path) => rm(path, { recursive: true, force: true }), }); /** Match the Linux Hopper build validated by the packaged demo-dialog adapter. */ export const linuxHopperBinarySupported = async ( path: string, ): Promise => { try { const hash = createHash("sha256"); for await (const chunk of createReadStream(path)) hash.update(chunk); return linuxHopperLauncherDigestSupported(hash.digest("hex")); } catch (cause: unknown) { // best-effort cleanup: optional build probing; unreadable means unsupported. void cause; return false; } }; /** Match a launcher extracted from one of the pinned official Linux packages. */ export const linuxHopperLauncherDigestSupported = (digest: string): boolean => SUPPORTED_HOPPER_SHA256.has(digest); /** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */ export const linuxSharedLibrariesAvailable = (output: string): boolean => !/(?:=>\s+not found|error while loading shared libraries)/iu.test(output); const installSystemPackage = async ( family: LinuxPackageFamily, archive: string, ): Promise => { try { for (const command of linuxPackageManagerCommands( family, archive, process.getuid?.() === 0, )) await execFileAsync(command.executable, command.args); return true; } catch (cause: unknown) { // Package-manager failure is reported by the false return. void cause; return false; } }; /** Select the native package manager and authorization boundary without shell evaluation. */ export const linuxPackageManagerCommands = ( family: LinuxPackageFamily, archive: string, isRoot: boolean, ): readonly { readonly executable: string; readonly args: readonly string[]; }[] => { const runtimeDependencies = family === "deb" ? ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"] : family === "rpm" ? [ "xorg-x11-server-Xvfb", "xorg-x11-xauth", "python3", "libX11", "libXtst", ] : ["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"]; const commands = family === "deb" ? [["apt-get", "install", "-y", archive, ...runtimeDependencies]] : family === "rpm" ? [["dnf", "install", "-y", archive, ...runtimeDependencies]] : [ ["pacman", "-S", "--needed", "--noconfirm", ...runtimeDependencies], ["pacman", "-U", "--noconfirm", archive], ]; return commands.map((command) => { const executable = isRoot ? command[0] : "pkexec"; if (executable === undefined) throw new Error("package command is empty"); return { executable, args: isRoot ? command.slice(1) : command }; }); }; const packageIntegrityMatches = ( bytes: Uint8Array, release: LinuxHopperRelease, ): boolean => bytes.byteLength === Number(release.file_length) && bytes.byteLength <= MAX_PACKAGE_BYTES && createHash("sha1").update(bytes).digest("hex") === release.file_hash; const packageFamilyFor = ( id: string, idLike: string | undefined, ): LinuxPackageFamily | undefined => { const identities = new Set([ id, ...(idLike?.toLowerCase().split(/\s+/u) ?? []), ]); if (identities.has("ubuntu") || identities.has("debian")) return "deb"; if (identities.has("fedora") || identities.has("rhel")) return "rpm"; if (identities.has("arch")) return "arch"; return undefined; }; const versionAtLeast = ( version: string | undefined, minimum: number, ): boolean => version !== undefined && Number.parseInt(version.split(".")[0] ?? "0", 10) >= minimum; const isAbortCause = (cause: unknown): boolean => cause instanceof Error && cause.name === "AbortError";