mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-04 06:02:08 +08:00
* refactor(utils): drop dead reset helper and tidy init_resources
- remove resolve_reset_async, which had no callers
- drop the duplicated time.*.com entry from the default fake-ip-filter
- init_resources no longer recreates app_home (ensure_directories during
init_config already created it) and now propagates res_dir creation errors
* refactor(profile): remove identical blind retry when switching node
The second select_node_for_group call in switch_proxy_node was a
zero-interval retry with identical arguments. Its success path was a
degraded copy of the first (missing the Refresh::Proxies announcement
ever since the old force-refresh emit was removed), and its distinct
"回退/最终失败" log wording misled maintainers. Keep a single call,
the primary success aftermath, and one error log.
* refactor(enhance): simplify tun mode check and lowercase helpers
- Drop the always-true second clause in use_tun's enhanced-mode check:
the key is absent only when the unwrap_or default already makes
current_mode == "fake-ip" true.
- Collapse use_lowercase into a delegation to use_lowercase_owned.
* refactor(core): drop unreachable hotkey dtor and dedupe tray predicate
Remove impl Drop for Hotkey: the instance lives in a OnceLock static via
the singleton! macro and is never dropped; exit-time unregistration is
already handled by Hotkey::reset().
Spell the include_proxy_groups/display-mode predicate identically at
both evaluation sites in tray menu building; behavior is unchanged
(Option != Some("disable") is equivalent to unwrap_or("default") != "disable").
* refactor(config): fold port guards into single helper
The five IClashTemp port guards duplicated the same read/parse/default
logic with drifting defaults: some hardcoded 7895/7898/7899 while others
referenced network::ports constants. Route them all through one
guard_port_value helper and use the constants everywhere, keeping each
guard's name, key and cfg attributes.
* refactor(config): dedupe prfitem factories and companion seeding
from_rules/from_proxies/from_groups differed only in uid prefix, type
string and template, so they now delegate to a shared from_seq_item.
The five current_* accessors collapse into a local macro. The identical
five-block companion seeding in from_local and from_url moves into
ensure_companion_items, preserving seeding order and error propagation.
* refactor(config): share patch macro and profiles helpers
IVerge::patch_config, IProfiles::patch_item and PrfOption::merge each
hand-rolled the same copy-Some-overwrite pattern; they now share one
patch_opt_field macro (the or-else merge form is field-for-field
equivalent for Option fields). The four profiles *_safe wrappers that
inlined Config::profiles().with_data_modify boilerplate delegate to a
with_profiles_edit helper, and record/forget_selected_node share the
current-item lookup prelude via current_item_mut.
* refactor(enhance): merge chain processing and drop unused builtin js payload
- collapse the identical process_global_items/process_profile_items into
one parameterized process_override_items (entry uid sources unchanged)
- fold the identical rules/proxies/groups arms of ChainItem::from_async
into a single arm with an inner dispatch
- stop embedding builtin JS payloads never executed by any path: the
enhance loop dispatches builtin items by uid to native implementations,
so ChainItem::builtin now carries empty script data (uids unchanged)
- source ProfileItems fallback literals from Default instead of a second
hand-written copy in collect_profile_items
* refactor(net): reuse webpki tls config and fallback
Extract build_webpki_rooted_tls_config so the delay-test TLS connector
and the reqwest clients share one static-webpki-roots builder; ALPN
stays caller-specific (only reqwest offers h2).
Collapse the duplicated platform-verifier -> static-webpki-roots retry
in get/get_bytes into request_with_static_webpki_fallback, gated by the
caller's certificate-verification setting.
* refactor(app): extract common exit cleanup
restart_app and quit duplicate the whole exit preparation: set the
exiting flag, persist configs, run interactive cleanup, and on a failed
core stop clear the flag, restore public DNS under the lifecycle lock
on macOS, and send the stop-failed notice.
Move that flow into run_exit_cleanup_flow next to clean_async, taking
the notice variant as a parameter and returning the CleanupResult so
each caller keeps its own tail: restart aborts before shutdown, quit
always shuts down and exits with the cleanup code.
* refactor(backup): share restore finalize path
restore_webdav_backup and restore_local_backup duplicated the
credential extraction (backup creation strips the WebDAV credentials,
so both re-inject the live ones), the archive extraction and the
finalize tail. Move that tail into a private restore_backup_archive
helper so the two entries keep only their download/locate logic; the
config-write lock scope and the off-runtime extraction land in one
place. The webdav entry keeps its downloaded-file cleanup.
* refactor(sysopt): drop redundant lock and busy-wait
The proxy target pair sat behind an Arc<RwLock> with no readers and an
Arc that was never cloned; both writers already serialize on
update_lock, so store the pair inside that mutex and delete the RwLock.
refresh_guard waited for the guard to become ready with a yield_now spin
at full CPU while holding guard_operation_lock; a 10 ms sleep keeps the
same wait condition and lock scope without the spin.
* refactor(sysopt): share macos bypass formatting
The macOS default bypass list and its three-way composition (empty ->
default, default + custom, custom only) were maintained twice: in
sysopt's format_bypass and inline in proxy_control's service_bypass.
Keep the literal once in constants and compose both paths through
format_bypass; service_bypass keeps its NUL check and UTF-8 truncation.
* refactor(core): dedupe window listeners, elevated runners, backup settings
- Collapse the close/focus window listener pairs in lightweight.rs onto a shared WindowListener with per-listener event and log label.
- Extract the windows runas-or-direct execution into service::run_maybe_elevated, shared by win_uwp and the service uninstaller, and factor the common uninstaller path resolution and status check shared across platforms.
- Make the auto-backup watch channel the single settings source, replacing the duplicate Arc<RwLock> copy with a stored receiver.
* refactor(enhance): fold lowercase helpers into single owned function
use_lowercase_owned disappears; the surviving use_lowercase takes the
owned Mapping and borrowed callers clone at the call site.
* refactor(enhance): rename ChainSupport variants to Mihomo names
Clash is a legacy name; the variants match the verge-mihomo and
verge-mihomo-alpha cores they gate.
* refactor(enhance): lowercase takes a reference and chain support is copy
use_lowercase borrows the mapping and clones only the values; call
sites no longer clone whole configs. ChainSupport is a fieldless enum
and now Copy; builtin() registers each support pair without clones.
* refactor(config): name prfitem constructor for what it builds
new_seq_item states that a fresh item is built from a template; the
from_ prefix wrongly implied an existing source. append_companion_item
returns the uid directly: append_item bails on a missing uid, so the
Option was always Some on the Ok path.
* refactor(sysopt): name the proxy state lock for its full role
The lock now owns the (Sysproxy, Autoproxy) state, so proxy_state_lock
says what it guards; the guard locals follow. constants::proxy held a
single sysproxy constant and is renamed sysproxy.
* refactor: replace remaining clash meta naming with mihomo
Doc comment, generated bindings and the prebuild sidecar helpers stop
referring to the legacy Clash Meta name; installer legacy-process
cleanup keeps the old executable names on purpose.
* refactor(enhance): drop builtin core filtering that never discriminates
Both valid cores matched a different ChainSupport variant, yet each
variant registered the same uid pair, so the filtered builtin list was
identical for stable and alpha. Invalid cores are coerced at startup
(validate_and_fix_config / get_valid_clash_core) and the None arm of
is_support was unreachable. builtin() now returns the items directly
and the clash_core threading through ConfigValues goes away; re-add
core discrimination when a builtin actually differs per core.
* refactor(enhance): drop unreachable mode script guard
merge_default_config overwrites the profile's mode with the verge-owned
clash config, whose mode is always present (template seeds rule and
patch semantics never clear it) and restricted to rule/global/direct;
change_clash_mode is rejected by the core before persisting anything
else. mode:script can therefore only arrive through a hand-crafted
patch_clash call, and for that path an explicit invalid-mode validation
error is more honest than silently rewriting to rule. mihomo rejects
mode:script outright (invalid mode), and user merge/script chains run
after the builtins, so the guard never covered them anyway.
* refactor(enhance): call the hysteria alpn builtin directly
builtin() existed to feed the uid-dispatch loop; with a single
builtin the vec and match are ceremony. apply_builtin_scripts calls
builtin_hy_alpn directly and to_script goes away; tests construct
ChainItem literals the way the merge items already did.
799 lines
24 KiB
JavaScript
799 lines
24 KiB
JavaScript
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(`mihomo unsupported platform "${platform}-${arch}"`)
|
|
}
|
|
if (!META_ALPHA_MAP[`${platform}-${arch}`]) {
|
|
throw new Error(`mihomo alpha unsupported platform "${platform}-${arch}"`)
|
|
}
|
|
|
|
function mihomoAlpha() {
|
|
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 mihomo() {
|
|
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(mihomoAlpha())),
|
|
retry: 5,
|
|
},
|
|
{
|
|
name: 'verge-mihomo',
|
|
func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())),
|
|
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()
|