import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { GitHubRelease } from './release-assets'; import { findReleaseAsset } from './release-assets'; type StatLike = { isFile: () => boolean; mode?: number; }; export type LauncherUpdateStatus = | 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'not-found' | 'missing-asset'; export interface LauncherUpdateResult { status: LauncherUpdateStatus; path?: string; command?: string; message?: string; } export interface LauncherUpdateFileSystem { readFile: (targetPath: string) => Promise; stat: (targetPath: string) => Promise; access: (targetPath: string) => Promise; writeFile: (targetPath: string, data: Buffer) => Promise; chmod: (targetPath: string, mode: number) => Promise; rename: (fromPath: string, toPath: string) => Promise; unlink: (targetPath: string) => Promise; } export function looksLikeSubminerLauncher(content: Buffer | string): boolean { const text = Buffer.isBuffer(content) ? content.toString('utf8') : content; return ( text.includes('SubMiner launcher') || text.includes('Launch MPV with SubMiner') || text.includes('SUBMINER_APPIMAGE_PATH') || text.includes('SubMiner.app') || text.includes('SubMiner.AppImage') ); } export function buildProtectedLauncherUpdateCommand( assetUrl: string, launcherPath: string, ): string { return `sudo curl -fSL ${shellQuote(assetUrl)} -o ${shellQuote(launcherPath)} && sudo chmod +x ${shellQuote(launcherPath)}`; } function sha256(data: Buffer): string { return createHash('sha256').update(data).digest('hex'); } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } function defaultFs(): LauncherUpdateFileSystem { return { readFile: (targetPath) => fs.promises.readFile(targetPath), stat: (targetPath) => fs.promises.stat(targetPath), access: async (targetPath) => { await fs.promises.access(targetPath, fs.constants.W_OK); }, writeFile: (targetPath, data) => fs.promises.writeFile(targetPath, data), chmod: (targetPath, mode) => fs.promises.chmod(targetPath, mode), rename: (fromPath, toPath) => fs.promises.rename(fromPath, toPath), unlink: async (targetPath) => { await fs.promises.unlink(targetPath).catch(() => undefined); }, }; } export async function updateLauncherAtPath(options: { launcherPath: string; assetUrl: string; expectedSha256: string; download: () => Promise; fs?: LauncherUpdateFileSystem; }): Promise { const fsDeps = options.fs ?? defaultFs(); let stat: StatLike; try { stat = await fsDeps.stat(options.launcherPath); } catch { return { status: 'not-found', path: options.launcherPath }; } if (!stat.isFile()) { return { status: 'skipped', path: options.launcherPath, message: 'Launcher is not a file.' }; } const existing = await fsDeps.readFile(options.launcherPath); if (!looksLikeSubminerLauncher(existing)) { return { status: 'skipped', path: options.launcherPath, message: 'Existing executable does not look like a SubMiner launcher.', }; } try { await fsDeps.access(options.launcherPath); } catch { return { status: 'protected', path: options.launcherPath, command: buildProtectedLauncherUpdateCommand(options.assetUrl, options.launcherPath), }; } const data = await options.download(); const actualSha256 = sha256(data); if (actualSha256 !== options.expectedSha256.toLowerCase()) { return { status: 'hash-mismatch', path: options.launcherPath, message: `Expected ${options.expectedSha256}, got ${actualSha256}.`, }; } const tempPath = path.join(path.dirname(options.launcherPath), '.subminer.update'); try { await fsDeps.writeFile(tempPath, data); await fsDeps.chmod(tempPath, stat.mode ? stat.mode & 0o777 : 0o755); await fsDeps.rename(tempPath, options.launcherPath); return { status: 'updated', path: options.launcherPath }; } catch (error) { await fsDeps.unlink(tempPath); throw error; } } export function detectLauncherCandidates(options: { platform: NodeJS.Platform; homeDir: string; explicitPath?: string; }): string[] { const candidates: string[] = []; if (options.explicitPath) candidates.push(options.explicitPath); if (options.platform === 'darwin') { candidates.push('/usr/local/bin/subminer', '/opt/homebrew/bin/subminer'); candidates.push(path.join(options.homeDir, '.local/bin/subminer')); } else if (options.platform === 'linux') { candidates.push(path.join(options.homeDir, '.local/bin/subminer')); candidates.push('/usr/local/bin/subminer', '/usr/bin/subminer'); } return [...new Set(candidates)]; } export async function updateLauncherFromRelease(options: { release: GitHubRelease | null; sha256Sums: Map; launcherPath?: string; platform?: NodeJS.Platform; homeDir?: string; downloadAsset: (url: string) => Promise; exists?: (targetPath: string) => boolean; }): Promise { if (!options.release) return { status: 'missing-asset', message: 'No release found.' }; const asset = findReleaseAsset(options.release, 'subminer'); if (!asset) return { status: 'missing-asset', message: 'Release has no subminer asset.' }; const expectedSha256 = options.sha256Sums.get('subminer'); if (!expectedSha256) { return { status: 'missing-asset', message: 'SHA256SUMS.txt has no subminer entry.' }; } const exists = options.exists ?? fs.existsSync; const candidates = detectLauncherCandidates({ platform: options.platform ?? process.platform, homeDir: options.homeDir ?? os.homedir(), explicitPath: options.launcherPath, }); const targetPath = candidates.find((candidate) => exists(candidate)); if (!targetPath) return { status: 'not-found', message: 'No installed launcher detected.' }; return await updateLauncherAtPath({ launcherPath: targetPath, assetUrl: asset.browser_download_url, expectedSha256, download: () => options.downloadAsset(asset.browser_download_url), }); }