mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
189 lines
6.1 KiB
TypeScript
189 lines
6.1 KiB
TypeScript
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<Buffer | string>;
|
|
stat: (targetPath: string) => Promise<StatLike>;
|
|
access: (targetPath: string) => Promise<void>;
|
|
writeFile: (targetPath: string, data: Buffer) => Promise<void>;
|
|
chmod: (targetPath: string, mode: number) => Promise<void>;
|
|
rename: (fromPath: string, toPath: string) => Promise<void>;
|
|
unlink: (targetPath: string) => Promise<void>;
|
|
}
|
|
|
|
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<Buffer>;
|
|
fs?: LauncherUpdateFileSystem;
|
|
}): Promise<LauncherUpdateResult> {
|
|
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<string, string>;
|
|
launcherPath?: string;
|
|
platform?: NodeJS.Platform;
|
|
homeDir?: string;
|
|
downloadAsset: (url: string) => Promise<Buffer>;
|
|
exists?: (targetPath: string) => boolean;
|
|
}): Promise<LauncherUpdateResult> {
|
|
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),
|
|
});
|
|
}
|