import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { access, mkdir, mkdtemp, readdir, rename, rm, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { z } from "zod"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { downloadHopperPackage, HOPPER_DOWNLOAD_BYTES, } from "./HopperDownload.js"; const execFileAsync = promisify(execFile); const RELEASES_URL = "https://www.hopperapp.com/include/files-api.php?request=releases&public=true"; const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/"; const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4"; /** Stable network and command effects disclosed before macOS Hopper setup. */ export const macHopperInstallDisclosure = { networkOrigins: [RELEASES_URL, DOWNLOAD_PREFIX] as const, commands: [ "hdiutil attach -readonly -nobrowse -mountpoint ", "codesign --verify --deep --strict ", "ditto ", "open ", ] as const, }; /** Result of installing the official Hopper macOS application. */ export type MacHopperInstallResult = | { readonly status: "installed"; readonly launcherPath: string; readonly cleanupFailure?: string; } | { readonly status: "failed"; readonly reason: | "release_metadata" | "download" | "integrity" | "mount" | "bundle" | "destination_exists" | "copy" | "launcher_missing"; readonly cleanupFailure?: string; }; /** External effects used by the bounded macOS Hopper installer. */ export interface MacHopperInstallHost { download( url: string, ): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }>; createTemporaryDirectory(): Promise; createMountDirectory(path: string): Promise; writePackage(path: string, bytes: Uint8Array): Promise; mount(packagePath: string, mountPath: string): Promise; unmount(mountPath: string): Promise; appBundle(mountPath: string): Promise; validBundle(appPath: string): Promise; destinationExists(path: string): Promise; installBundle( source: string, destination: string, stage: string, replaceExisting: boolean, ): Promise; launcherReady(path: string): Promise; openApplication(path: string): Promise; cleanup(path: string): Promise; /** Retain the cleanup capability until the owning setup host retries it. */ retainCleanup?(cleanup: () => Promise): void; destination(): string; } /** Download, verify, and install Hopper into the current user's Applications directory. */ export const installMacHopper = async ( options: { readonly replaceExisting?: boolean } = {}, host: MacHopperInstallHost = systemMacHopperInstallHost( homeDirectoryFromEnvironment(process.env, process.platform), process.env, ), ): Promise => { let temporary: string | undefined; let mounted = false; let result: MacHopperInstallResult; try { result = await (async (): Promise => { const metadata = await host.download(RELEASES_URL); if (!metadata.ok) return { status: "failed", reason: "release_metadata" }; const parsed = releasesSchema.safeParse(parseJson(metadata.bytes)); if (!parsed.success) return { status: "failed", reason: "release_metadata" }; const release = parsed.data["OS X"]; if (!release.filename.startsWith(DOWNLOAD_PREFIX)) return { status: "failed", reason: "release_metadata" }; const archive = await host.download(release.filename); if (!archive.ok) return { status: "failed", reason: "download" }; if (!integrityMatches(archive.bytes, release)) return { status: "failed", reason: "integrity" }; const destination = host.destination(); const destinationExists = await host.destinationExists(destination); if (destinationExists && options.replaceExisting !== true) return { status: "failed", reason: "destination_exists" }; temporary = await host.createTemporaryDirectory(); const packagePath = join(temporary, "hopper.dmg"); const mountPath = join(temporary, "mounted"); await host.createMountDirectory(mountPath); await host.writePackage(packagePath, archive.bytes); if (!(await host.mount(packagePath, mountPath))) return { status: "failed", reason: "mount" }; mounted = true; const source = await host.appBundle(mountPath); if (source === undefined || !(await host.validBundle(source))) return { status: "failed", reason: "bundle" }; const stage = `${destination}.rea-stage`; if ( !(await host.installBundle( source, destination, stage, destinationExists, )) ) return { status: "failed", reason: "copy" }; const launcherPath = join(destination, "Contents/MacOS/hopper"); if (!(await host.launcherReady(launcherPath))) return { status: "failed", reason: "launcher_missing" }; await host.openApplication(destination); return { status: "installed", launcherPath }; })(); } catch (cause: unknown) { // Install-stage failures share the download reason; the stage is cleaned below. void cause; result = { status: "failed", reason: "download" }; } let cleanupFailure: string | undefined; if (temporary !== undefined) { const temporaryPath = temporary; const mountPath = join(temporary, "mounted"); const cleanup = async (): Promise => { if (mounted) { await host.unmount(mountPath); mounted = false; } await host.cleanup(temporaryPath); }; try { await cleanup(); } catch (cause: unknown) { cleanupFailure = `Hopper DMG cleanup failed: ${failureMessage(cause)}`; host.retainCleanup?.(cleanup); } } return cleanupFailure === undefined ? result : { ...result, cleanupFailure }; }; /** Compose installer paths and subprocesses from one selected caller context. */ export const systemMacHopperInstallHost = ( homeDirectory: string, environment: Readonly, ): MacHopperInstallHost & { close(): Promise } => { const pendingCleanup = new Set<() => Promise>(); return { download: downloadHopperPackage, createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")), createMountDirectory: (path) => mkdir(path), writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }), async mount(packagePath, mountPath) { return commandSucceeds( "hdiutil", [ "attach", "-readonly", "-nobrowse", "-mountpoint", mountPath, packagePath, ], environment, ); }, async unmount(mountPath) { if ( !(await commandSucceeds( "hdiutil", ["detach", mountPath, "-force"], environment, )) ) throw new Error( `hdiutil detach failed for ${JSON.stringify(mountPath)}`, ); }, async appBundle(mountPath) { const entries = await readdir(mountPath, { withFileTypes: true }); const app = entries.find( (entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name), ); return app === undefined ? undefined : join(mountPath, app.name); }, async validBundle(appPath) { if ( !(await commandSucceeds( "codesign", ["--verify", "--deep", "--strict", appPath], environment, )) ) return false; try { const identifier = ( await execFileAsync( "/usr/libexec/PlistBuddy", [ "-c", "Print :CFBundleIdentifier", join(appPath, "Contents/Info.plist"), ], { env: environment }, ) ).stdout.trim(); return identifier === BUNDLE_IDENTIFIER; } catch (cause: unknown) { // best-effort cleanup: optional bundle probing; unreadable means invalid. void cause; return false; } }, async destinationExists(path) { try { await access(path); return true; } catch (cause: unknown) { // best-effort cleanup: optional existence probing; absence means missing. void cause; return false; } }, async installBundle(source, destination, stage, replaceExisting) { const previous = `${stage}-previous`; try { await mkdir(join(homeDirectory, "Applications"), { recursive: true }); await rm(stage, { recursive: true, force: true }); await rm(previous, { recursive: true, force: true }); await execFileAsync("ditto", [source, stage], { env: environment }); await access(join(stage, "Contents/MacOS/hopper")); if (replaceExisting) await rename(destination, previous); await rename(stage, destination); if (replaceExisting) await rm(previous, { recursive: true, force: true }); return true; } catch (cause: unknown) { // Install failure rolls back below; the false return reports it. void cause; await rm(stage, { recursive: true, force: true }); if (replaceExisting) { try { await rename(previous, destination); } catch (rollbackCause: unknown) { // The original destination was not moved, or rollback is impossible. void rollbackCause; } } return false; } }, async launcherReady(path) { try { await access(path); return true; } catch (cause: unknown) { // best-effort cleanup: optional launcher probing; absence means not ready. void cause; return false; } }, async openApplication(path) { await execFileAsync("open", [path], { env: environment }); }, cleanup: (path) => rm(path, { recursive: true, force: true }), retainCleanup: (cleanup) => pendingCleanup.add(cleanup), async close() { let failure: string | undefined; for (const cleanup of pendingCleanup) { try { await cleanup(); pendingCleanup.delete(cleanup); } catch (cause: unknown) { failure ??= `Hopper DMG cleanup remains incomplete: ${failureMessage(cause)}`; } } return failure; }, destination: () => join(homeDirectory, "Applications/Hopper Disassembler.app"), }; }; const parseJson = (bytes: Uint8Array): unknown => JSON.parse(new TextDecoder().decode(bytes)); const integrityMatches = ( bytes: Uint8Array, release: z.infer, ): boolean => bytes.byteLength === Number(release.file_length) && bytes.byteLength <= HOPPER_DOWNLOAD_BYTES && createHash("sha1").update(bytes).digest("hex") === release.file_hash; const commandSucceeds = async ( command: string, args: readonly string[], environment: Readonly, ): Promise => { try { await execFileAsync(command, args, { env: environment }); return true; } catch (cause: unknown) { // best-effort cleanup: optional command probing; failure means unavailable. void cause; return false; } }; const failureMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); const releaseSchema = z.object({ filename: z.string().url(), file_length: z.string().regex(/^\d+$/u), file_hash: z.string().regex(/^[a-f0-9]{40}$/u), }); const releasesSchema = z.object({ "OS X": releaseSchema });