import { execFileSync, execSync } from 'node:child_process' import { createHash } from 'node:crypto' import fs from 'node:fs' import fsp from 'node:fs/promises' import path from 'node:path' import zlib from 'node:zlib' import AdmZip from 'adm-zip' import { glob } from 'glob' import { HttpsProxyAgent } from 'https-proxy-agent' import { extract } from 'tar' import { resolveServiceRelease } from './service-release.mjs' import { log_debug, log_error, log_info, log_success } from './utils.mjs' /** Prepares platform resources, caching versions and unchanged files unless `--force` is used. */ const cwd = process.cwd() const TEMP_DIR = path.join(cwd, 'node_modules/.verge') const FORCE = process.argv.includes('--force') || process.argv.includes('-f') const VERSION_CACHE_FILE = path.join(TEMP_DIR, '.version_cache.json') const HASH_CACHE_FILE = path.join(TEMP_DIR, '.hash_cache.json') const PLATFORM_MAP = { 'x86_64-pc-windows-msvc': 'win32', 'i686-pc-windows-msvc': 'win32', 'aarch64-pc-windows-msvc': 'win32', 'x86_64-apple-darwin': 'darwin', 'aarch64-apple-darwin': 'darwin', 'x86_64-unknown-linux-gnu': 'linux', 'i686-unknown-linux-gnu': 'linux', 'aarch64-unknown-linux-gnu': 'linux', 'armv7-unknown-linux-gnueabihf': 'linux', 'riscv64gc-unknown-linux-gnu': 'linux', 'loongarch64-unknown-linux-gnu': 'linux', } const ARCH_MAP = { 'x86_64-pc-windows-msvc': 'x64', 'i686-pc-windows-msvc': 'ia32', 'aarch64-pc-windows-msvc': 'arm64', 'x86_64-apple-darwin': 'x64', 'aarch64-apple-darwin': 'arm64', 'x86_64-unknown-linux-gnu': 'x64', 'i686-unknown-linux-gnu': 'ia32', 'aarch64-unknown-linux-gnu': 'arm64', 'armv7-unknown-linux-gnueabihf': 'arm', 'riscv64gc-unknown-linux-gnu': 'riscv64', 'loongarch64-unknown-linux-gnu': 'loong64', } const arg1 = process.argv.slice(2)[0] const arg2 = process.argv.slice(2)[1] const target = arg1 === '--force' || arg1 === '-f' ? arg2 : arg1 if (process.env.CI && !target) { throw new Error('prebuild requires an explicit target triple in CI') } const { platform, arch } = target ? { platform: PLATFORM_MAP[target], arch: ARCH_MAP[target] } : process const SIDECAR_HOST = target ? target : execSync('rustc -vV') .toString() .match(/(?<=host: ).+(?=\s*)/g)[0] const RESOURCES_DIR = path.join(cwd, 'src-tauri', 'resources') const SIDECAR_DIR = path.join(cwd, 'src-tauri', 'sidecar') // Linux service binaries are bundled as externalBin sidecars (see tauri.linux.conf.json) const SERVICE_DIR = platform === 'linux' ? SIDECAR_DIR : RESOURCES_DIR // Version cache async function loadVersionCache() { try { if (fs.existsSync(VERSION_CACHE_FILE)) { const data = await fsp.readFile(VERSION_CACHE_FILE, 'utf-8') return JSON.parse(data) } } catch (err) { log_debug('Failed to load version cache:', err.message) } return {} } async function saveVersionCache(cache) { try { await fsp.mkdir(TEMP_DIR, { recursive: true }) await fsp.writeFile(VERSION_CACHE_FILE, JSON.stringify(cache, null, 2)) log_debug('Version cache saved') } catch (err) { log_debug('Failed to save version cache:', err.message) } } async function getCachedVersion(key) { const cache = await loadVersionCache() const cached = cache[key] if (cached && Date.now() - cached.timestamp < 3600000) { log_info(`Using cached version for ${key}: ${cached.version}`) return cached.version } return null } async function setCachedVersion(key, version) { const cache = await loadVersionCache() cache[key] = { version, timestamp: Date.now() } await saveVersionCache(cache) } // File hash cache async function calculateFileHash(filePath) { try { const fileBuffer = await fsp.readFile(filePath) const hashSum = createHash('sha256') hashSum.update(fileBuffer) return hashSum.digest('hex') } catch (ignoreErr) { return null } } async function loadHashCache() { try { if (fs.existsSync(HASH_CACHE_FILE)) { const data = await fsp.readFile(HASH_CACHE_FILE, 'utf-8') return JSON.parse(data) } } catch (err) { log_debug('Failed to load hash cache:', err.message) } return {} } async function saveHashCache(cache) { try { await fsp.mkdir(TEMP_DIR, { recursive: true }) await fsp.writeFile(HASH_CACHE_FILE, JSON.stringify(cache, null, 2)) log_debug('Hash cache saved') } catch (err) { log_debug('Failed to save hash cache:', err.message) } } async function hasFileChanged(filePath, targetPath) { if (FORCE) return true if (!fs.existsSync(targetPath)) return true const hashCache = await loadHashCache() const sourceHash = await calculateFileHash(filePath) const targetHash = await calculateFileHash(targetPath) if (!sourceHash || !targetHash) return true const cacheKey = targetPath const cachedHash = hashCache[cacheKey] if (cachedHash === sourceHash && sourceHash === targetHash) { return false } return true } async function updateHashCache(targetPath) { const hashCache = await loadHashCache() const hash = await calculateFileHash(targetPath) if (hash) { hashCache[targetPath] = hash await saveHashCache(hashCache) } } // Mihomo release maps const META_ALPHA_VERSION_URL = 'https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/version.txt' const META_ALPHA_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha` let META_ALPHA_VERSION const META_VERSION_URL = 'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt' const META_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download` let META_VERSION const META_ALPHA_MAP = { 'win32-x64': 'mihomo-windows-amd64-v2', 'win32-ia32': 'mihomo-windows-386', 'win32-arm64': 'mihomo-windows-arm64', 'darwin-x64': 'mihomo-darwin-amd64-v1-go122', 'darwin-arm64': 'mihomo-darwin-arm64-go122', 'linux-x64': 'mihomo-linux-amd64-v2', 'linux-ia32': 'mihomo-linux-386', 'linux-arm64': 'mihomo-linux-arm64', 'linux-arm': 'mihomo-linux-armv7', 'linux-riscv64': 'mihomo-linux-riscv64', 'linux-loong64': 'mihomo-linux-loong64', } const META_MAP = { 'win32-x64': 'mihomo-windows-amd64-v2', 'win32-ia32': 'mihomo-windows-386', 'win32-arm64': 'mihomo-windows-arm64', 'darwin-x64': 'mihomo-darwin-amd64-v2-go122', 'darwin-arm64': 'mihomo-darwin-arm64-go122', 'linux-x64': 'mihomo-linux-amd64-v2', 'linux-ia32': 'mihomo-linux-386', 'linux-arm64': 'mihomo-linux-arm64', 'linux-arm': 'mihomo-linux-armv7', 'linux-riscv64': 'mihomo-linux-riscv64', 'linux-loong64': 'mihomo-linux-loong64', } // Release discovery async function getLatestAlphaVersion() { if (!FORCE) { const cached = await getCachedVersion('META_ALPHA_VERSION') if (cached) { META_ALPHA_VERSION = cached return } } const options = {} const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy) try { const response = await fetch(META_ALPHA_VERSION_URL, { ...options, method: 'GET', }) if (!response.ok) throw new Error( `Failed to fetch ${META_ALPHA_VERSION_URL}: ${response.status}`, ) META_ALPHA_VERSION = (await response.text()).trim() log_info(`Latest alpha version: ${META_ALPHA_VERSION}`) await setCachedVersion('META_ALPHA_VERSION', META_ALPHA_VERSION) } catch (err) { log_error('Error fetching latest alpha version:', err.message) process.exit(1) } } async function getLatestReleaseVersion() { if (!FORCE) { const cached = await getCachedVersion('META_VERSION') if (cached) { META_VERSION = cached return } } const options = {} const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy) try { const response = await fetch(META_VERSION_URL, { ...options, method: 'GET', }) if (!response.ok) throw new Error(`Failed to fetch ${META_VERSION_URL}: ${response.status}`) META_VERSION = (await response.text()).trim() log_info(`Latest release version: ${META_VERSION}`) await setCachedVersion('META_VERSION', META_VERSION) } catch (err) { log_error('Error fetching latest release version:', err.message) process.exit(1) } } if (!META_MAP[`${platform}-${arch}`]) { throw new Error(`clash meta unsupported platform "${platform}-${arch}"`) } if (!META_ALPHA_MAP[`${platform}-${arch}`]) { throw new Error(`clash meta alpha unsupported platform "${platform}-${arch}"`) } function clashMetaAlpha() { const name = META_ALPHA_MAP[`${platform}-${arch}`] const isWin = platform === 'win32' const urlExt = isWin ? 'zip' : 'gz' return { name: 'verge-mihomo-alpha', targetFile: `verge-mihomo-alpha-${SIDECAR_HOST}${isWin ? '.exe' : ''}`, exeFile: `${name}${isWin ? '.exe' : ''}`, zipFile: `${name}-${META_ALPHA_VERSION}.${urlExt}`, downloadURL: `${META_ALPHA_URL_PREFIX}/${name}-${META_ALPHA_VERSION}.${urlExt}`, } } function clashMeta() { const name = META_MAP[`${platform}-${arch}`] const isWin = platform === 'win32' const urlExt = isWin ? 'zip' : 'gz' return { name: 'verge-mihomo', targetFile: `verge-mihomo-${SIDECAR_HOST}${isWin ? '.exe' : ''}`, exeFile: `${name}${isWin ? '.exe' : ''}`, zipFile: `${name}-${META_VERSION}.${urlExt}`, downloadURL: `${META_URL_PREFIX}/${META_VERSION}/${name}-${META_VERSION}.${urlExt}`, } } async function downloadFile(url, outPath) { const options = {} const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy) const response = await fetch(url, { ...options, method: 'GET', headers: { 'Content-Type': 'application/octet-stream' }, }) if (!response.ok) { const body = await response.text().catch(() => '') await fsp.mkdir(path.dirname(outPath), { recursive: true }) await fsp.writeFile(outPath, body) throw new Error(`Failed to download ${url}: status ${response.status}`) } const buf = Buffer.from(await response.arrayBuffer()) await fsp.mkdir(path.dirname(outPath), { recursive: true }) if (url.endsWith('.gz') || url.endsWith('.tgz')) { if (!(buf[0] === 0x1f && buf[1] === 0x8b)) { await fsp.writeFile(outPath, buf) throw new Error( `Downloaded file for ${url} is not a valid gzip (magic mismatch).`, ) } } else if (url.endsWith('.zip')) { if (!(buf[0] === 0x50 && buf[1] === 0x4b)) { await fsp.writeFile(outPath, buf) throw new Error( `Downloaded file for ${url} is not a valid zip (magic mismatch).`, ) } } await fsp.writeFile(outPath, buf) log_success(`download finished: ${url}`) } async function resolveSidecar(binInfo) { const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo const sidecarPath = path.join(SIDECAR_DIR, targetFile) await fsp.mkdir(SIDECAR_DIR, { recursive: true }) if (!FORCE && fs.existsSync(sidecarPath)) { log_success(`"${name}" already exists, skipping download`) return } const tempDir = path.join(TEMP_DIR, name) const tempZip = path.join(tempDir, zipFile) const tempExe = path.join(tempDir, exeFile) await fsp.mkdir(tempDir, { recursive: true }) try { if (!fs.existsSync(tempZip)) { await downloadFile(downloadURL, tempZip) } if (zipFile.endsWith('.zip')) { const zip = new AdmZip(tempZip) zip.getEntries().forEach((entry) => { log_debug(`"${name}" entry: ${entry.entryName}`) }) zip.extractAllTo(tempDir, true) if (fs.existsSync(tempExe)) { await fsp.rename(tempExe, sidecarPath) } else { const files = await fsp.readdir(tempDir) const candidate = files.find( (f) => f === path.basename(exeFile) || f.endsWith('.exe') || !f.includes('.'), ) if (!candidate) throw new Error(`Expected binary not found in ${tempDir}`) await fsp.rename(path.join(tempDir, candidate), sidecarPath) } if (platform !== 'win32') await fsp.chmod(sidecarPath, 0o755) log_success(`unzip finished: "${name}"`) } else if (zipFile.endsWith('.tgz')) { await extract({ cwd: tempDir, file: tempZip }) const files = await fsp.readdir(tempDir) log_debug(`"${name}" extracted files:`, files) let extracted = files.find( (f) => f === path.basename(exeFile) || f.startsWith('虚空终端-') || !f.includes('.'), ) if (!extracted) extracted = files[0] if (!extracted) throw new Error(`Expected file not found in ${tempDir}`) await fsp.rename(path.join(tempDir, extracted), sidecarPath) await fsp.chmod(sidecarPath, 0o755) log_success(`tgz processed: "${name}"`) } else { const readStream = fs.createReadStream(tempZip) const writeStream = fs.createWriteStream(sidecarPath) await new Promise((resolve, reject) => { readStream .pipe(zlib.createGunzip()) .on('error', (e) => { log_error(`gunzip error for ${name}:`, e.message) reject(e) }) .pipe(writeStream) .on('finish', () => { if (platform !== 'win32') execFileSync('chmod', ['755', sidecarPath]) resolve() }) .on('error', (e) => { log_error(`write stream error for ${name}:`, e.message) reject(e) }) }) log_success(`gz binary processed: "${name}"`) } } catch (err) { await fsp.rm(sidecarPath, { recursive: true, force: true }) throw err } finally { await fsp.rm(tempDir, { recursive: true, force: true }) } } async function resolveResource(binInfo) { const { file, downloadURL, localPath, dir } = binInfo const baseDir = dir ?? RESOURCES_DIR const targetPath = path.join(baseDir, file) if (!FORCE && fs.existsSync(targetPath) && !downloadURL && !localPath) { log_success(`"${file}" already exists, skipping`) return } if (downloadURL) { if (!FORCE && fs.existsSync(targetPath)) { log_success(`"${file}" already exists, skipping download`) return } await fsp.mkdir(baseDir, { recursive: true }) await downloadFile(downloadURL, targetPath) await updateHashCache(targetPath) } if (localPath) { if (!(await hasFileChanged(localPath, targetPath))) { return } await fsp.mkdir(baseDir, { recursive: true }) await fsp.copyFile(localPath, targetPath) await updateHashCache(targetPath) log_success(`Copied file: ${file}`) } log_success(`${file} finished`) } // Windows NSIS plugin const resolvePlugin = async () => { const url = 'https://nsis.sourceforge.io/mediawiki/images/e/ef/NSIS_Simple_Service_Plugin_Unicode_1.30.zip' const tempDir = path.join(TEMP_DIR, 'SimpleSC') const tempZip = path.join( tempDir, 'NSIS_Simple_Service_Plugin_Unicode_1.30.zip', ) const tempDll = path.join(tempDir, 'SimpleSC.dll') const pluginDir = path.join(process.env.APPDATA || '', 'Local/NSIS') const pluginPath = path.join(pluginDir, 'SimpleSC.dll') await fsp.mkdir(pluginDir, { recursive: true }) await fsp.mkdir(tempDir, { recursive: true }) if (!FORCE && fs.existsSync(pluginPath)) return try { if (!fs.existsSync(tempZip)) { await downloadFile(url, tempZip) } const zip = new AdmZip(tempZip) zip .getEntries() .forEach((entry) => void log_debug(`"SimpleSC" entry`, entry.entryName)) zip.extractAllTo(tempDir, true) if (fs.existsSync(tempDll)) { await fsp.cp(tempDll, pluginPath, { recursive: true, force: true }) log_success(`unzip finished: "SimpleSC"`) } else { const files = await fsp.readdir(tempDir) const dll = files.find((f) => f.toLowerCase().endsWith('.dll')) if (dll) { await fsp.cp(path.join(tempDir, dll), pluginPath, { recursive: true, force: true, }) log_success(`unzip finished: "SimpleSC" (found ${dll})`) } else { throw new Error('SimpleSC.dll not found in zip') } } } finally { await fsp.rm(tempDir, { recursive: true, force: true }) } } // Service executable permissions const resolveServicePermission = async () => { const serviceExecutables = [ 'clash-verge-service*', 'clash-verge-service-install*', 'clash-verge-service-uninstall*', ] const hashCache = await loadHashCache() let hasChanges = false for (const f of serviceExecutables) { const files = glob.sync(path.join(SERVICE_DIR, f)) for (const filePath of files) { if (fs.existsSync(filePath)) { const currentHash = await calculateFileHash(filePath) const cacheKey = `${filePath}_chmod` if (!FORCE && hashCache[cacheKey] === currentHash) { continue } try { execSync(`chmod 755 ${filePath}`) log_success(`chmod finished: "${filePath}"`) } catch (e) { log_error(`chmod failed for ${filePath}:`, e.message) } hashCache[cacheKey] = currentHash hasChanges = true } } } if (hasChanges) { await saveHashCache(hashCache) } } // Other resources const SERVICE_BINARIES = [ 'clash-verge-service', 'clash-verge-service-install', 'clash-verge-service-uninstall', ] function serviceFileInfo(name) { const ext = platform === 'win32' ? '.exe' : '' const suffix = platform === 'linux' ? `-${SIDECAR_HOST}` : '' return { sourceFile: `${name}${ext}`, targetFile: `${name}${suffix}${ext}`, } } async function findExtractedFile(dir, fileName) { const entries = await fsp.readdir(dir, { withFileTypes: true }) for (const entry of entries) { const entryPath = path.join(dir, entry.name) if (entry.isFile() && entry.name === fileName) return entryPath if (entry.isDirectory()) { const found = await findExtractedFile(entryPath, fileName) if (found) return found } } return null } async function resolveServiceBundle() { const files = SERVICE_BINARIES.map((name) => { const info = serviceFileInfo(name) return { ...info, targetPath: path.join(SERVICE_DIR, info.targetFile), } }) const cargoManifest = await fsp.readFile( path.join(cwd, 'src-tauri', 'Cargo.toml'), 'utf8', ) const serviceDependency = cargoManifest .split(/\r?\n/) .find((line) => line.trimStart().startsWith('clash_verge_service_ipc =')) const sourcePath = serviceDependency?.match(/\bpath\s*=\s*"([^"]+)"/)?.[1] if (sourcePath) { const manifest = path.resolve(cwd, 'src-tauri', sourcePath, 'Cargo.toml') const targetDirectory = path.join(cwd, 'target', 'bundled-service') execFileSync( 'cargo', [ 'build', '--manifest-path', manifest, '--target-dir', targetDirectory, '--target', SIDECAR_HOST, '--release', '--features', 'standalone,client', '--bins', ], { stdio: 'inherit' }, ) await fsp.mkdir(SERVICE_DIR, { recursive: true }) for (const { sourceFile, targetPath } of files) { await fsp.copyFile( path.join(targetDirectory, SIDECAR_HOST, 'release', sourceFile), targetPath, ) if (platform !== 'win32') await fsp.chmod(targetPath, 0o755) await updateHashCache(targetPath) } return } const { archiveFile, downloadURL } = resolveServiceRelease( cargoManifest, SIDECAR_HOST, platform, ) const tempDir = path.join(TEMP_DIR, 'clash-verge-service-ipc') const tempArchive = path.join(tempDir, archiveFile) await fsp.mkdir(tempDir, { recursive: true }) await fsp.mkdir(SERVICE_DIR, { recursive: true }) try { await downloadFile(downloadURL, tempArchive) if (platform === 'win32') { const zip = new AdmZip(tempArchive) zip .getEntries() .forEach( (entry) => void log_debug('"clash-verge-service-ipc" entry:', entry.entryName), ) zip.extractAllTo(tempDir, true) } else { await extract({ cwd: tempDir, file: tempArchive }) } for (const { sourceFile, targetFile, targetPath } of files) { const extractedFile = await findExtractedFile(tempDir, sourceFile) if (!extractedFile) { throw new Error(`Expected binary ${sourceFile} not found in archive`) } await fsp.copyFile(extractedFile, targetPath) if (platform !== 'win32') await fsp.chmod(targetPath, 0o755) await updateHashCache(targetPath) log_success(`Extracted service file: ${targetFile}`) } log_success(`service bundle finished: ${archiveFile}`) } finally { await fsp.rm(tempDir, { recursive: true, force: true }) } } /// The NSIS installer publishes the bundled cores into the service's approved directory and must /// attest exactly the bytes it unpacked. The digests are computed here, where the sidecars land, /// and reach installer.nsi through the installerHooks include (see tauri.windows.conf.json). const CORE_HASHES_NSH = path.join( cwd, 'src-tauri', 'packages', 'windows', 'core-hashes.nsh', ) async function resolveCoreHashes() { const lines = [] for (const [define, name] of [ ['MIHOMO_SHA256', 'verge-mihomo'], ['MIHOMO_ALPHA_SHA256', 'verge-mihomo-alpha'], ]) { const sidecar = path.join(SIDECAR_DIR, `${name}-${SIDECAR_HOST}.exe`) const digest = createHash('sha256') .update(await fsp.readFile(sidecar)) .digest('hex') lines.push(`!define ${define} "${digest}"`) } await fsp.writeFile(CORE_HASHES_NSH, `${lines.join('\n')}\n`) log_success(`Generated ${CORE_HASHES_NSH}`) } const resolveMmdb = () => resolveResource({ file: 'Country.mmdb', downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`, }) const resolveASNMmdb = () => resolveResource({ file: 'ASN.mmdb', downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb`, }) const resolveGeosite = () => resolveResource({ file: 'geosite.dat', downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat`, }) const resolveGeoIP = () => resolveResource({ file: 'geoip.dat', downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat`, }) const resolveEnableLoopback = () => resolveResource({ file: 'enableLoopback.exe', downloadURL: `https://github.com/Kuingsmile/uwp-tool/releases/download/latest/enableLoopback.exe`, }) const resolveSetDnsScript = () => resolveResource({ file: 'set_dns.sh', localPath: path.join(cwd, 'scripts/set_dns.sh'), }) const resolveUnSetDnsScript = () => resolveResource({ file: 'unset_dns.sh', localPath: path.join(cwd, 'scripts/unset_dns.sh'), }) const tasks = [ { name: 'verge-mihomo-alpha', func: () => getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())), retry: 5, }, { name: 'verge-mihomo', func: () => getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())), retry: 5, }, // After both sidecar tasks: it hashes what they downloaded. { name: 'core_hashes', func: resolveCoreHashes, retry: 1, winOnly: true }, { name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true }, { name: 'service', func: resolveServiceBundle, retry: 5 }, { name: 'mmdb', func: resolveMmdb, retry: 5 }, { name: 'asn_mmdb', func: resolveASNMmdb, retry: 5 }, { name: 'geosite', func: resolveGeosite, retry: 5 }, { name: 'geoip', func: resolveGeoIP, retry: 5 }, { name: 'enableLoopback', func: resolveEnableLoopback, retry: 5, winOnly: true, }, { name: 'service_chmod', func: resolveServicePermission, retry: 5, unixOnly: platform === 'linux' || platform === 'darwin', }, { name: 'set_dns_script', func: resolveSetDnsScript, retry: 5, macosOnly: true, }, { name: 'unset_dns_script', func: resolveUnSetDnsScript, retry: 5, macosOnly: true, }, ] async function runTask() { const task = tasks.shift() if (!task) return if (task.unixOnly && platform === 'win32') return runTask() if (task.winOnly && platform !== 'win32') return runTask() if (task.macosOnly && platform !== 'darwin') return runTask() if (task.linuxOnly && platform !== 'linux') return runTask() for (let i = 0; i < task.retry; i++) { try { await task.func() break } catch (err) { log_error(`task::${task.name} try ${i} ==`, err.message) if (i === task.retry - 1) throw err } } return runTask() } runTask()