mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-16 14:20:53 +08:00
* feat(service): hand upgraded cores to the service through the installer The service now only executes administrator-staged core copies, so an in-app core upgrade has to deliver the new bytes there before the restart asks for them; otherwise a service-mode restart keeps running the previous core while the UI reports the new version. After publishing the downloaded core, service-mode upgrades invoke the elevated service installer with --install-core <path> --sha256 <hex>. The digest is computed over the published file and rides the elevated command line, which no other local account can alter, so a file swapped after hashing is refused rather than staged. Sidecar-mode upgrades run the file directly and prompt for nothing. Rollback distinguishes staging that never ran, was refused, or succeeded: only a successful staging obliges the rollback to restore the approved copy too (deliberately not gated on the running mode, which reads NotRunning after the very crash being rolled back), and a refused staging restores the file so a retry does not see the new version on disk and conclude there is nothing to do. Requires clash-verge-service-ipc >= 2.6.7. The pinned dependency and the packaged service release must move together with this change, or the old installer silently treats --install-core as a plain install. * feat(installer): attest bundled cores to the service at install time prebuild computes sha256 digests of the downloaded mihomo sidecars into packages/windows/core-hashes.nsh (gitignored), included through the NSIS installerHooks path. After unpacking and before starting the service, the installer stages both cores into the service's approved directory with those digests, so what gets attested is exactly what this installer shipped: a core swapped on disk in a user-writable install directory is refused, not published. Failure is logged and does not abort the install; a later elevated service install can stage the cores again. * chore(deps): require clash-verge-service-ipc v2.6.8 v2.6.8 is the first service whose installer understands --install-core --sha256; the staging this branch introduces is inert against anything older, because the old installer treats the flag as a plain service installation and reports success. The service release and this pin therefore have to ship in the same app release. * fix(service): heap-allocate the core hashing buffer clippy::large_stack_arrays denies the 64 KiB stack array.
758 lines
23 KiB
JavaScript
758 lines
23 KiB
JavaScript
import { execFileSync, execSync } from 'child_process'
|
|
import { createHash } from 'crypto'
|
|
import fs from 'fs'
|
|
import fsp from 'fs/promises'
|
|
import path from 'path'
|
|
import zlib from '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) => 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 { 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) =>
|
|
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 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: '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()
|