mirror of
https://github.com/mileswolfallen2/OmniEmu2.0.git
synced 2026-09-08 11:23:17 +00:00
677 lines
23 KiB
TypeScript
677 lines
23 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, writeFileSync } from 'fs';
|
|
import { join, extname } from 'path';
|
|
import { app, shell } from 'electron';
|
|
import { exec } from 'child_process';
|
|
import { DecompProject, DecompState, Platform } from '../shared/types';
|
|
import { getPlatform } from './platform';
|
|
import { settings } from './settings';
|
|
|
|
const platform = getPlatform();
|
|
|
|
export const knownDecomps: DecompProject[] = [
|
|
// ── N64 Ports ──────────────────────────────────────────────
|
|
{
|
|
id: 'soh',
|
|
name: 'Ship of Harkinian',
|
|
description: 'The Legend of Zelda: Ocarina of Time — 60 FPS, 4K, randomizer, mods',
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/HarbourMasters/Shipwright',
|
|
githubRepo: 'HarbourMasters/Shipwright',
|
|
executablePath: {
|
|
win32: 'soh.exe',
|
|
darwin: 'soh.app/Contents/MacOS/soh',
|
|
linux: 'soh',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['60 FPS game logic', '4K rendering', 'Built-in randomizer', 'Mod loader', 'Gyro / motion aiming', 'HD texture replacement'],
|
|
},
|
|
{
|
|
id: '2s2h',
|
|
name: '2Ship2Harkinian',
|
|
description: "The Legend of Zelda: Majora's Mask — 60 FPS, widescreen, randomizer mods",
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/HarbourMasters/2ship2harkinian',
|
|
githubRepo: 'HarbourMasters/2ship2harkinian',
|
|
executablePath: {
|
|
win32: '2s2h.exe',
|
|
darwin: '2s2h.app/Contents/MacOS/2s2h',
|
|
linux: '2s2h',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['60 FPS game logic', 'Widescreen & 4K', 'Randomizer mod support', 'Shared engine with SoH'],
|
|
},
|
|
{
|
|
id: 'sm64pc',
|
|
name: 'Super Mario 64',
|
|
description: 'SM64 PC Port — uncapped FPS, 4K, mod loader, analog camera',
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/sm64pc/sm64ex',
|
|
githubRepo: 'sm64pc/sm64ex',
|
|
executablePath: {
|
|
win32: 'build\\release\\sm64.pex64.exe',
|
|
darwin: 'build/release/sm64.fat.macos',
|
|
linux: 'build/release/sm64.pex64',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['Uncapped framerate', '4K & ultrawide', 'Mod loader', 'Analog camera', 'Controller remapping'],
|
|
},
|
|
{
|
|
id: 'starship',
|
|
name: 'Star Fox 64',
|
|
description: 'Starship — widescreen, gyro aiming, rumble, HD textures',
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/HarbourMasters/Starship',
|
|
githubRepo: 'HarbourMasters/Starship',
|
|
executablePath: {
|
|
win32: 'starship.exe',
|
|
darwin: 'starship.app/Contents/MacOS/starship',
|
|
linux: 'starship',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['Widescreen & ultrawide', 'Gyro aiming', 'HD texture packs', 'Force feedback / rumble'],
|
|
},
|
|
{
|
|
id: 'banjo-recomp',
|
|
name: 'Banjo-Kazooie',
|
|
description: 'Banjo: Recompiled — dual-stick camera, ultrawide, instant loading',
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/Wiseguy/Banjo-Recompiled',
|
|
githubRepo: 'Wiseguy/Banjo-Recompiled',
|
|
executablePath: {
|
|
win32: 'Banjo.exe',
|
|
darwin: '',
|
|
linux: 'banjo',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['Dual-stick free camera', 'Ultrawide support', 'Near-instant loading', 'High-resolution rendering'],
|
|
},
|
|
{
|
|
id: 'perfect-dark',
|
|
name: 'Perfect Dark',
|
|
description: 'Perfect Dark PC — high-res, modern dual-stick controls, bots',
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/perfect-dark-decomp/perfect-dark',
|
|
githubRepo: 'perfect-dark-decomp/perfect-dark',
|
|
executablePath: {
|
|
win32: 'pd.exe',
|
|
darwin: '',
|
|
linux: 'perfect-dark',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['High-resolution output', 'Modern dual-stick controls', 'Bot support', 'Widescreen', 'Improved framerate'],
|
|
},
|
|
{
|
|
id: 'mk64recomp',
|
|
name: 'Mario Kart 64',
|
|
description: 'SpaghettiKart — widescreen, higher FPS, online multiplayer',
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/n64decomp/mk64',
|
|
githubRepo: 'n64decomp/mk64',
|
|
executablePath: {
|
|
win32: 'mk64.exe',
|
|
darwin: '',
|
|
linux: 'mk64',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['Widescreen & ultrawide', 'Higher framerate', 'Online multiplayer via mods', 'Custom track loading'],
|
|
},
|
|
{
|
|
id: 'bm64recomp',
|
|
name: 'Bomberman 64',
|
|
description: 'BM64Recomp — native PC rendering, widescreen, configurable controls',
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/Bomberman64Recomp/BM64Recomp',
|
|
githubRepo: 'Bomberman64Recomp/BM64Recomp',
|
|
executablePath: {
|
|
win32: 'BM64.exe',
|
|
darwin: '',
|
|
linux: 'bm64',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['Native PC rendering', 'Widescreen support', 'Configurable controls'],
|
|
},
|
|
{
|
|
id: 'bmherorecomp',
|
|
name: 'Bomberman Hero',
|
|
description: 'BMHeroRecomp — native PC recompilation with modern controls',
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/BombermanHeroRecomp/BMHeroRecomp',
|
|
githubRepo: 'BombermanHeroRecomp/BMHeroRecomp',
|
|
executablePath: {
|
|
win32: 'BMHero.exe',
|
|
darwin: '',
|
|
linux: 'bmhero',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['Native PC rendering', 'Widescreen support', 'Companion to BM64Recomp'],
|
|
},
|
|
{
|
|
id: 'banjo-tooie-recomp',
|
|
name: 'Banjo-Tooie',
|
|
description: 'Banjo-Tooie Recomp — native PC with widescreen and higher framerates',
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/banjo-tooie-recomp/banjo-tooie-recomp',
|
|
githubRepo: 'banjo-tooie-recomp/banjo-tooie-recomp',
|
|
executablePath: {
|
|
win32: 'BanjoTooie.exe',
|
|
darwin: '',
|
|
linux: 'banjo-tooie',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['Native PC executable', 'Widescreen support', 'Higher framerates', 'Controller remapping'],
|
|
},
|
|
{
|
|
id: 'dk64recomp',
|
|
name: 'Donkey Kong 64',
|
|
description: "DK64 Recomp — Rare's DK64 with native rendering and modern controls",
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/DK64Recomp/DK64Recomp',
|
|
githubRepo: 'DK64Recomp/DK64Recomp',
|
|
executablePath: {
|
|
win32: 'DK64.exe',
|
|
darwin: '',
|
|
linux: 'dk64',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['Native rendering', 'High resolution', 'Widescreen support', 'Modern controller support'],
|
|
},
|
|
{
|
|
id: 'kirby64recomp',
|
|
name: 'Kirby 64: The Crystal Shards',
|
|
description: 'Kirby64 Recomp — native PC with higher resolutions and widescreen',
|
|
platform: 'N64',
|
|
githubUrl: 'https://github.com/Kirby64Recomp/Kirby64Recomp',
|
|
githubRepo: 'Kirby64Recomp/Kirby64Recomp',
|
|
executablePath: {
|
|
win32: 'Kirby64.exe',
|
|
darwin: '',
|
|
linux: 'kirby64',
|
|
},
|
|
romExtensions: ['.z64', '.n64', '.v64'],
|
|
requiresRom: true,
|
|
features: ['Native rendering', 'Higher resolutions', 'Widescreen support', 'Controller remapping'],
|
|
},
|
|
// ── GameCube Ports ─────────────────────────────────────────
|
|
{
|
|
id: 'dusklight',
|
|
name: 'Zelda: Twilight Princess',
|
|
description: 'Dusklight — native 60 FPS, 4K, HD textures, mod loader',
|
|
platform: 'GameCube',
|
|
githubUrl: 'https://github.com/TwilitRealm/dusklight',
|
|
githubRepo: 'TwilitRealm/dusklight',
|
|
executablePath: {
|
|
win32: 'dusklight.exe',
|
|
darwin: '',
|
|
linux: 'dusklight',
|
|
},
|
|
romExtensions: ['.iso', '.gcm', '.rvz', '.gcz'],
|
|
requiresRom: true,
|
|
features: ['Native 60 FPS', '4K rendering', 'HD texture packs', 'Mod loader', 'Widescreen & ultrawide'],
|
|
},
|
|
// ── SNES Ports ─────────────────────────────────────────────
|
|
{
|
|
id: 'zelda3',
|
|
name: 'Zelda: A Link to the Past',
|
|
description: 'zelda3 — clean decompilation, widescreen, higher resolution',
|
|
platform: 'SNES',
|
|
githubUrl: 'https://github.com/snesrev/zelda3',
|
|
githubRepo: 'snesrev/zelda3',
|
|
executablePath: {
|
|
win32: 'zelda3.exe',
|
|
darwin: 'zelda3',
|
|
linux: 'zelda3',
|
|
},
|
|
romExtensions: ['.sfc', '.smc'],
|
|
requiresRom: true,
|
|
features: ['Clean annotated codebase', 'Widescreen support', 'Higher resolution rendering', 'Mod-friendly structure'],
|
|
},
|
|
// ── GBA Ports ──────────────────────────────────────────────
|
|
{
|
|
id: 'tmcpicori',
|
|
name: 'Zelda: The Minish Cap',
|
|
description: 'Project Picori — native PC port with higher resolution',
|
|
platform: 'GBA',
|
|
githubUrl: 'https://github.com/999sian/tmc',
|
|
githubRepo: '999sian/tmc',
|
|
executablePath: {
|
|
win32: 'tmc.exe',
|
|
darwin: '',
|
|
linux: 'tmc',
|
|
},
|
|
romExtensions: ['.gba'],
|
|
requiresRom: true,
|
|
features: ['Native PC rendering', 'Higher internal resolution', 'Preservation-focused build'],
|
|
},
|
|
// ── PS2 Ports ──────────────────────────────────────────────
|
|
{
|
|
id: 'opengoal',
|
|
name: 'Jak and Daxter',
|
|
description: 'OpenGOAL — full trilogy (Jak 1, 2, 3) with modding language',
|
|
platform: 'PS2',
|
|
githubUrl: 'https://github.com/open-goal/jak-project',
|
|
githubRepo: 'open-goal/jak-project',
|
|
executablePath: {
|
|
win32: 'gk.exe',
|
|
darwin: 'gk.app/Contents/MacOS/gk',
|
|
linux: 'gk',
|
|
},
|
|
romExtensions: ['.iso'],
|
|
requiresRom: true,
|
|
features: ['Full trilogy (Jak 1, 2, 3)', 'OpenGOAL modding language', 'Widescreen & 4K', 'Launcher with auto-updates'],
|
|
},
|
|
// ── Xbox 360 / PS3 Ports ──────────────────────────────────
|
|
{
|
|
id: 'unleashedrecomp',
|
|
name: 'Sonic Unleashed',
|
|
description: 'UnleashedRecomp — Hedgehog Engine RT, 60 FPS+, ultrawide',
|
|
platform: 'Xbox 360 / PS3',
|
|
githubUrl: 'https://github.com/hedge-dev/UnleashedRecomp',
|
|
githubRepo: 'hedge-dev/UnleashedRecomp',
|
|
executablePath: {
|
|
win32: 'UnleashedRecomp.exe',
|
|
darwin: '',
|
|
linux: '',
|
|
},
|
|
romExtensions: ['.iso'],
|
|
requiresRom: true,
|
|
features: ['Hedgehog Engine path tracing', '60 FPS+ with fixed logic', 'Ultrawide support', 'Mod support via HMM'],
|
|
},
|
|
];
|
|
|
|
// ── State Management ──────────────────────────────────────────
|
|
|
|
function getDecompDir(id: string): string {
|
|
return join(app.getPath('userData'), 'emulators', id);
|
|
}
|
|
|
|
function getMarkerFile(id: string): string {
|
|
return join(getDecompDir(id), '.installed');
|
|
}
|
|
|
|
function getRomConfigFile(): string {
|
|
return join(app.getPath('userData'), 'decomps', 'roms.json');
|
|
}
|
|
|
|
function readRomPaths(): Record<string, string> {
|
|
try {
|
|
return JSON.parse(readFileSync(getRomConfigFile(), 'utf-8'));
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeRomPaths(paths: Record<string, string>): void {
|
|
const dir = join(app.getPath('userData'), 'decomps');
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
writeFileSync(getRomConfigFile(), JSON.stringify(paths, null, 2), 'utf-8');
|
|
}
|
|
|
|
function detectDecompVersion(installDir: string): string | undefined {
|
|
const versionFile = join(installDir, '.version');
|
|
try {
|
|
return readFileSync(versionFile, 'utf-8').trim();
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function findDecompExecutable(config: DecompProject): string | undefined {
|
|
const installDir = getDecompDir(config.id);
|
|
const markerPath = getMarkerFile(config.id);
|
|
|
|
// Check marker file first
|
|
try {
|
|
const execPath = readFileSync(markerPath, 'utf-8').trim();
|
|
if (execPath && existsSync(execPath)) return execPath;
|
|
} catch { /* no marker */ }
|
|
|
|
// Try platform-specific default path
|
|
const defaultExec = config.executablePath[platform];
|
|
if (defaultExec) {
|
|
const fullPath = join(installDir, defaultExec);
|
|
if (existsSync(fullPath)) return fullPath;
|
|
}
|
|
|
|
// Fallback: look for any executable in the install dir
|
|
try {
|
|
const files = readdirSync(installDir);
|
|
const execExts = platform === 'win32' ? ['.exe'] : ['', '.app'];
|
|
for (const file of files) {
|
|
if (execExts.some(ext => file.endsWith(ext))) {
|
|
return join(installDir, file);
|
|
}
|
|
}
|
|
} catch { /* empty dir */ }
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function findDecompRom(config: DecompProject): string | undefined {
|
|
const romPaths = readRomPaths();
|
|
if (romPaths[config.id] && existsSync(romPaths[config.id])) {
|
|
return romPaths[config.id];
|
|
}
|
|
|
|
// Search common ROM directories
|
|
const s = settings.get();
|
|
const romDirs = [
|
|
s.romsDirectory,
|
|
join(app.getPath('userData'), 'roms'),
|
|
].filter(Boolean) as string[];
|
|
|
|
for (const romDir of romDirs) {
|
|
const platformDir = join(romDir, config.platform.toLowerCase().replace(/\s+/g, '-'));
|
|
if (!existsSync(platformDir)) continue;
|
|
|
|
try {
|
|
const files = readdirSync(platformDir);
|
|
for (const file of files) {
|
|
if (config.romExtensions.includes(extname(file).toLowerCase())) {
|
|
return join(platformDir, file);
|
|
}
|
|
}
|
|
} catch { /* skip */ }
|
|
|
|
// Also check the root rom directory
|
|
try {
|
|
const files = readdirSync(romDir);
|
|
for (const file of files) {
|
|
if (config.romExtensions.includes(extname(file).toLowerCase())) {
|
|
return join(romDir, file);
|
|
}
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export function checkDecomp(id: string): DecompState {
|
|
const config = knownDecomps.find(d => d.id === id);
|
|
if (!config) {
|
|
return {
|
|
installed: false,
|
|
hasRom: false,
|
|
config: {
|
|
id, name: id, description: '', platform: '',
|
|
githubUrl: '', githubRepo: '', executablePath: { win32: '', darwin: '', linux: '' },
|
|
romExtensions: [], requiresRom: true, features: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
const execPath = findDecompExecutable(config);
|
|
const version = execPath ? detectDecompVersion(getDecompDir(config.id)) : undefined;
|
|
const romPath = findDecompRom(config);
|
|
|
|
return {
|
|
installed: !!execPath,
|
|
version,
|
|
path: execPath,
|
|
config,
|
|
hasRom: !!romPath,
|
|
romPath: romPath || undefined,
|
|
};
|
|
}
|
|
|
|
export function getAllDecompStates(): DecompState[] {
|
|
const s = settings.get();
|
|
if (!s.decompProjects) return [];
|
|
return knownDecomps.map(d => checkDecomp(d.id));
|
|
}
|
|
|
|
export function setDecompRomPath(id: string, romPath: string): boolean {
|
|
const romPaths = readRomPaths();
|
|
romPaths[id] = romPath;
|
|
writeRomPaths(romPaths);
|
|
return true;
|
|
}
|
|
|
|
export function launchDecomp(id: string): boolean {
|
|
const state = checkDecomp(id);
|
|
if (!state.installed || !state.path) return false;
|
|
|
|
const child = exec(`"${state.path}"`, { cwd: getDecompDir(id) });
|
|
if (child) child.unref();
|
|
|
|
return true;
|
|
}
|
|
|
|
export function uninstallDecomp(id: string): boolean {
|
|
const installDir = getDecompDir(id);
|
|
let removed = false;
|
|
|
|
if (existsSync(installDir)) {
|
|
rmSync(installDir, { recursive: true, force: true });
|
|
removed = true;
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
export function openDecompWebsite(id: string): boolean {
|
|
const config = knownDecomps.find(d => d.id === id);
|
|
if (config?.githubUrl) {
|
|
shell.openExternal(config.githubUrl);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── GitHub Release Download ──────────────────────────────────
|
|
|
|
interface GitHubRelease {
|
|
tag_name: string;
|
|
assets: GitHubAsset[];
|
|
}
|
|
|
|
interface GitHubAsset {
|
|
name: string;
|
|
browser_download_url: string;
|
|
size: number;
|
|
}
|
|
|
|
async function fetchLatestRelease(githubRepo: string): Promise<GitHubRelease | null> {
|
|
try {
|
|
const url = `https://api.github.com/repos/${githubRepo}/releases/latest`;
|
|
const response = await fetch(url, {
|
|
headers: { 'Accept': 'application/vnd.github.v3+json' },
|
|
});
|
|
if (!response.ok) return null;
|
|
return await response.json() as GitHubRelease;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function pickBestAsset(assets: GitHubAsset[], config: DecompProject): GitHubAsset | undefined {
|
|
const platformHints: Record<Platform, string[]> = {
|
|
win32: ['windows', 'win64', 'win32', 'x64', '.exe', '.zip'],
|
|
darwin: ['macos', 'mac', 'osx', 'darwin', '.dmg', '.app'],
|
|
linux: ['linux', 'ubuntu', 'appimage', 'x86_64', 'amd64'],
|
|
};
|
|
|
|
const hints = platformHints[platform];
|
|
|
|
// Score each asset
|
|
const scored = assets.map(asset => {
|
|
const name = asset.name.toLowerCase();
|
|
let score = 0;
|
|
for (const hint of hints) {
|
|
if (name.includes(hint)) score += 10;
|
|
}
|
|
// Prefer zip/tar over exe installers
|
|
if (name.endsWith('.zip') || name.endsWith('.tar.gz')) score += 5;
|
|
// Penalize source code archives
|
|
if (name.includes('source')) score -= 20;
|
|
if (name.endsWith('.tar.gz') && name.includes('src')) score -= 20;
|
|
return { asset, score };
|
|
});
|
|
|
|
scored.sort((a, b) => b.score - a.score);
|
|
return scored[0]?.score > 0 ? scored[0].asset : undefined;
|
|
}
|
|
|
|
export interface DecompInstallProgress {
|
|
decompId: string;
|
|
stage: 'downloading' | 'extracting' | 'configuring' | 'done' | 'error';
|
|
percent: number;
|
|
message: string;
|
|
error?: string;
|
|
}
|
|
|
|
export async function installDecomp(
|
|
id: string,
|
|
onProgress: (p: DecompInstallProgress) => void,
|
|
): Promise<DecompState> {
|
|
const config = knownDecomps.find(d => d.id === id);
|
|
if (!config) throw new Error(`Unknown decomp: ${id}`);
|
|
|
|
const installDir = getDecompDir(id);
|
|
if (!existsSync(installDir)) {
|
|
mkdirSync(installDir, { recursive: true });
|
|
}
|
|
|
|
try {
|
|
onProgress({ decompId: id, stage: 'downloading', percent: 0, message: 'Fetching latest release...' });
|
|
|
|
const release = await fetchLatestRelease(config.githubRepo);
|
|
if (!release) throw new Error('Could not fetch latest release from GitHub');
|
|
|
|
const asset = pickBestAsset(release.assets, config);
|
|
if (!asset) throw new Error('No compatible release asset found for your platform');
|
|
|
|
onProgress({ decompId: id, stage: 'downloading', percent: 10, message: `Downloading ${asset.name}...` });
|
|
|
|
// Download to temp file
|
|
const tmpDir = join(app.getPath('userData'), 'downloads');
|
|
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
|
|
const tmpFile = join(tmpDir, `${id}-${Date.now()}-${asset.name}`);
|
|
|
|
await downloadFile(asset.browser_download_url, tmpFile, (pct) => {
|
|
onProgress({ decompId: id, stage: 'downloading', percent: 10 + pct * 0.6, message: `Downloading ${asset.name}...` });
|
|
});
|
|
|
|
onProgress({ decompId: id, stage: 'extracting', percent: 70, message: 'Extracting...' });
|
|
|
|
// Detect format and extract
|
|
const isZip = asset.name.endsWith('.zip');
|
|
const isTar = asset.name.endsWith('.tar.gz') || asset.name.endsWith('.tgz');
|
|
const is7z = asset.name.endsWith('.7z');
|
|
|
|
if (isZip || isTar || is7z) {
|
|
await extractArchive(tmpFile, installDir, asset.name, onProgress, id);
|
|
} else {
|
|
// It might be a standalone executable
|
|
const { copyFileSync } = require('fs');
|
|
const targetPath = join(installDir, asset.name);
|
|
copyFileSync(tmpFile, targetPath);
|
|
writeFileSync(join(installDir, '.installed'), targetPath, 'utf-8');
|
|
}
|
|
|
|
// Clean up temp file
|
|
try { rmSync(tmpFile, { force: true }); } catch { /* ignore */ }
|
|
|
|
// Write version marker
|
|
writeFileSync(join(installDir, '.version'), release.tag_name, 'utf-8');
|
|
|
|
// Write .installed marker with the executable path
|
|
const execPath = findDecompExecutable(config);
|
|
if (execPath) {
|
|
writeFileSync(join(installDir, '.installed'), execPath, 'utf-8');
|
|
}
|
|
|
|
onProgress({ decompId: id, stage: 'done', percent: 100, message: `Installed ${config.name}` });
|
|
|
|
return checkDecomp(id);
|
|
} catch (err: any) {
|
|
onProgress({ decompId: id, stage: 'error', percent: 0, message: 'Installation failed', error: err.message });
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function downloadFile(url: string, dest: string, onProgress: (pct: number) => void): Promise<void> {
|
|
const response = await fetch(url);
|
|
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
|
|
|
const total = Number(response.headers.get('content-length')) || 0;
|
|
const reader = response.body?.getReader();
|
|
if (!reader) throw new Error('No response body');
|
|
|
|
const { createWriteStream } = require('fs');
|
|
const stream = createWriteStream(dest);
|
|
let received = 0;
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
stream.write(value);
|
|
received += value.length;
|
|
if (total > 0) onProgress(Math.round((received / total) * 100));
|
|
}
|
|
|
|
stream.end();
|
|
await new Promise<void>((resolve) => stream.on('finish', resolve));
|
|
}
|
|
|
|
async function extractArchive(
|
|
archivePath: string,
|
|
destDir: string,
|
|
archiveName: string,
|
|
onProgress: (p: DecompInstallProgress) => void,
|
|
decompId: string,
|
|
): Promise<void> {
|
|
const { execSync } = require('child_process');
|
|
const platform = process.platform;
|
|
|
|
try {
|
|
if (archiveName.endsWith('.zip')) {
|
|
onProgress({ decompId, stage: 'extracting', percent: 75, message: 'Extracting zip archive...' });
|
|
if (platform === 'win32') {
|
|
// Windows: use PowerShell Expand-Archive, fallback to 7z
|
|
try {
|
|
execSync(`powershell -NoProfile -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, { timeout: 300000 });
|
|
} catch {
|
|
// Fallback: try 7z for zip
|
|
try {
|
|
const sevenZip = require('7zip-bin').path7za;
|
|
execSync(`"${sevenZip}" x "${archivePath}" -o"${destDir}" -y`, { timeout: 300000 });
|
|
} catch {
|
|
execSync(`7z x "${archivePath}" -o"${destDir}" -y`, { timeout: 300000 });
|
|
}
|
|
}
|
|
} else {
|
|
execSync(`unzip -o "${archivePath}" -d "${destDir}"`, { timeout: 300000 });
|
|
}
|
|
} else if (archiveName.endsWith('.tar.gz') || archiveName.endsWith('.tgz')) {
|
|
onProgress({ decompId, stage: 'extracting', percent: 75, message: 'Extracting tar archive...' });
|
|
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { timeout: 300000 });
|
|
} else if (archiveName.endsWith('.7z')) {
|
|
onProgress({ decompId, stage: 'extracting', percent: 75, message: 'Extracting 7z archive...' });
|
|
// Try bundled 7za first, then system 7z
|
|
let extracted = false;
|
|
try {
|
|
const sevenZip = require('7zip-bin').path7za;
|
|
execSync(`"${sevenZip}" x "${archivePath}" -o"${destDir}" -y`, { timeout: 300000 });
|
|
extracted = true;
|
|
} catch { /* bundled 7za failed */ }
|
|
if (!extracted) {
|
|
execSync(`7z x "${archivePath}" -o"${destDir}" -y`, { timeout: 300000 });
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
throw new Error(`Extraction failed: ${err.message}`);
|
|
}
|
|
}
|