1.修复内核更新,导致配置错误的问题

2.新增核心版本选择功能,避免核心配置变动导致的不可用。
This commit is contained in:
ZenEcho committed 2026-03-27 16:07:53 +08:00
1 parent 0a5b1780da
commit a5e1a79e31
15 files changed
+1349 -190

No files matched your search

+9 -1
View File
@@ -87,7 +87,15 @@ contextBridge.exposeInMainWorld('singbox', {
/** 重启内核 */
restart: (config: string) => ipcRenderer.invoke('singbox-restart', config),
/** 确保核心已安装 */
ensureCoreInstalled: () => ipcRenderer.invoke('singbox-ensure-core'),
ensureCoreInstalled: (preferredVersion?: string) => ipcRenderer.invoke('singbox-ensure-core', preferredVersion),
/** 安装或重装指定版本核心 */
installCore: (preferredVersion?: string) => ipcRenderer.invoke('singbox-install-core', preferredVersion),
/** 获取可用核心版本列表 */
listCoreVersions: (forceRefresh?: boolean) => ipcRenderer.invoke('singbox-list-core-versions', forceRefresh),
/** 获取当前首选核心版本 */
getPreferredVersion: () => ipcRenderer.invoke('singbox-get-preferred-version'),
/** 设置首选核心版本 */
setPreferredVersion: (preferredVersion?: string) => ipcRenderer.invoke('singbox-set-preferred-version', preferredVersion),
/** 获取核心安装状态 */
getInstallInfo: () => ipcRenderer.invoke('singbox-get-install-info'),
})
+9 -1
View File
@@ -92,7 +92,15 @@ export class SingBoxConfigManager {
hints.push('提示:看起来是权限不足导致启动失败,请尝试“以管理员身份运行”。')
}
if (text.includes('invalid') || text.includes('unknown field') || text.includes('parse') || text.includes('json')) {
if (
text.includes('invalid')
|| text.includes('unknown field')
|| text.includes('parse')
|| text.includes('json')
|| text.includes('deprecated')
|| text.includes('removed in sing-box')
|| text.includes('use rule actions instead')
) {
hints.push('提示:节点/配置参数可能不兼容当前 sing-box 版本,请把弹窗里的最后一行报错发我。')
}
+22 -5
View File
@@ -65,15 +65,32 @@ export class SingBoxService {
await this.start(configPath)
})
})
ipcMain.handle('singbox-ensure-core', async () => {
ipcMain.handle('singbox-ensure-core', async (_, preferredVersion?: string) => {
if (preferredVersion) {
this.installer.setPreferredVersion(preferredVersion)
}
await this.installer.checkAndDownloadBinary()
})
ipcMain.handle('singbox-install-core', async (_, preferredVersion?: string) => {
this.installer.setPreferredVersion(preferredVersion)
if (this.process) {
throw new Error('sing-box 正在运行,请先停止加速后再切换核心版本')
}
await this.installer.installCore(preferredVersion)
})
ipcMain.handle('singbox-list-core-versions', async (_, forceRefresh?: boolean) => {
return this.installer.listAvailableVersions(Boolean(forceRefresh))
})
ipcMain.handle('singbox-get-preferred-version', async () => {
return this.installer.getPreferredVersion()
})
ipcMain.handle('singbox-set-preferred-version', async (_, preferredVersion?: string) => {
return this.installer.setPreferredVersion(preferredVersion)
})
ipcMain.handle('singbox-get-install-info', async () => {
const exists = await this.installer.checkBinaryExists()
return {
exists,
installDir: this.installer.getInstallDir(),
binaryPath: this.installer.getBinaryPath()
...await this.installer.getInstallInfo(),
isRunning: !!this.process
}
})
}
+346 -36
View File
@@ -6,6 +6,9 @@ import { pipeline } from 'stream/promises'
import { createWriteStream } from 'fs'
import { Readable } from 'stream'
import https from 'node:https'
import { app } from 'electron'
import pkg from '../../../package.json'
import { JsonStore } from '../../common/store'
import { generateId } from '../../utils/id'
import { BIN_DIR } from './constants'
@@ -36,7 +39,46 @@ export interface InstallerStatus {
isTimeout?: boolean
}
export type StatusFn = (status: InstallerStatus) => void
export interface SingBoxCoreRelease {
version: string
tagName: string
publishedAt?: string
channel: 'stable' | 'beta' | 'alpha'
}
export interface SingBoxInstallInfo {
exists: boolean
installDir: string
binaryPath: string
installedVersion?: string
preferredVersion: string
}
interface ReleaseAssetResolution {
version: string
tagName: string
downloadUrl: string
archiveExt: string
}
interface InstallMeta {
version: string
tagName?: string
downloadUrl?: string
installedAt: number
}
type StatusFn = (status: InstallerStatus) => void
const GITHUB_RELEASES_BASE = 'https://api.github.com/repos/SagerNet/sing-box/releases'
const RELEASE_CACHE_TTL_MS = 5 * 60 * 1000
const TESTED_SINGBOX_VERSION = normalizePackageVersion(pkg.lagZeroTestSingboxVersion)
function normalizePackageVersion(version: unknown) {
const raw = String(version || '').trim()
if (!raw || raw.toLowerCase() === 'latest') return ''
return raw.replace(/^v/i, '')
}
/**
* Sing-box 安装器
@@ -46,10 +88,19 @@ export class SingBoxInstaller {
private log: LogFn
private onStatus?: StatusFn
private installingPromise: Promise<string> | null = null
private settingsStore: JsonStore<{ preferredVersion: string }>
private releaseCache: { fetchedAt: number, versions: SingBoxCoreRelease[] } | null = null
constructor(logFn: LogFn, statusFn?: StatusFn) {
this.log = logFn
this.onStatus = statusFn
this.settingsStore = new JsonStore({
name: 'singbox-core-settings',
cwd: app.getPath('userData'),
defaults: {
preferredVersion: 'latest'
}
})
}
getInstallDir() {
@@ -61,6 +112,73 @@ export class SingBoxInstaller {
return path.join(BIN_DIR, `sing-box${ext}`)
}
getPreferredVersion() {
return this.normalizeRequestedVersion(this.settingsStore.get().preferredVersion)
}
setPreferredVersion(version?: string) {
const normalized = this.normalizeRequestedVersion(version)
const current = this.settingsStore.get()
if (current.preferredVersion !== normalized) {
this.settingsStore.set({
...current,
preferredVersion: normalized
})
}
return normalized
}
async listAvailableVersions(forceRefresh = false): Promise<SingBoxCoreRelease[]> {
const now = Date.now()
if (!forceRefresh && this.releaseCache && now - this.releaseCache.fetchedAt < RELEASE_CACHE_TTL_MS) {
return this.releaseCache.versions
}
let versions: SingBoxCoreRelease[]
try {
const releases = await this.fetchJson<any[]>(`${GITHUB_RELEASES_BASE}?per_page=50`)
versions = releases
.filter((release) => !release?.draft)
.reduce<SingBoxCoreRelease[]>((acc, release) => {
const tagName = String(release?.tag_name || '').trim()
const version = this.normalizeVersionFromTag(tagName)
if (!version) return acc
acc.push({
version,
tagName,
publishedAt: typeof release?.published_at === 'string' ? release.published_at : undefined,
channel: this.inferReleaseChannel(release)
})
return acc
}, [])
} catch (error) {
if (!TESTED_SINGBOX_VERSION) throw error
this.log(`获取 sing-box 版本列表失败,已回退到保底版本 v${TESTED_SINGBOX_VERSION}:${String((error as any)?.message || error)}`, 'error')
versions = []
}
versions = this.ensureTestedVersionPresent(versions)
this.releaseCache = {
fetchedAt: now,
versions
}
return versions
}
async getInstallInfo(): Promise<SingBoxInstallInfo> {
const exists = await this.checkBinaryExists()
const meta = exists ? await this.readInstallMeta() : null
return {
exists,
installDir: this.getInstallDir(),
binaryPath: this.getBinaryPath(),
installedVersion: meta?.version,
preferredVersion: this.getPreferredVersion()
}
}
async checkBinaryExists() {
const binPath = this.getBinaryPath()
if (await fs.pathExists(binPath)) return true
@@ -68,7 +186,7 @@ export class SingBoxInstaller {
}
/**
* 检查 sing-box 是否存在,不存在则自动下载
* 检查 sing-box 是否存在,不存在则根据首选版本自动下载
* @returns 可执行文件的绝对路径
*/
async checkAndDownloadBinary(): Promise<string> {
@@ -77,7 +195,8 @@ export class SingBoxInstaller {
return this.installingPromise
}
this.installingPromise = this.checkAndDownloadBinaryInternal()
const requestedVersion = this.getPreferredVersion()
this.installingPromise = this.checkAndDownloadBinaryInternal(requestedVersion)
try {
return await this.installingPromise
} finally {
@@ -85,7 +204,25 @@ export class SingBoxInstaller {
}
}
private async checkAndDownloadBinaryInternal(): Promise<string> {
/**
* 安装指定版本的 sing-box(会覆盖现有核心)
*/
async installCore(version?: string): Promise<string> {
if (this.installingPromise) {
this.log('检测到已有 sing-box 安装任务,等待完成...')
return this.installingPromise
}
const requestedVersion = this.setPreferredVersion(version)
this.installingPromise = this.installCoreInternal(requestedVersion)
try {
return await this.installingPromise
} finally {
this.installingPromise = null
}
}
private async checkAndDownloadBinaryInternal(requestedVersion: string): Promise<string> {
await fs.ensureDir(BIN_DIR)
const platform = process.platform
const arch = process.arch
@@ -93,28 +230,49 @@ export class SingBoxInstaller {
this.emitStatus({ phase: 'checking' })
if (await this.checkBinaryExists()) {
this.emitStatus({ phase: 'ready' })
const meta = await this.readInstallMeta()
this.emitStatus({ phase: 'ready', version: meta?.version })
return binPath
}
this.emitStatus({ phase: 'missing' })
return this.downloadAndInstallBinary(platform, arch, binPath)
return this.downloadAndInstallBinary(platform, arch, binPath, requestedVersion)
}
private async installCoreInternal(requestedVersion: string): Promise<string> {
await fs.ensureDir(BIN_DIR)
const platform = process.platform
const arch = process.arch
const binPath = this.getBinaryPath()
this.emitStatus({ phase: 'checking' })
return this.downloadAndInstallBinary(platform, arch, binPath, requestedVersion)
}
/**
* 执行下载和安装流程
*/
private async downloadAndInstallBinary(platform: NodeJS.Platform, arch: string, binPath: string): Promise<string> {
this.log('未检测到 sing-box,开始自动下载...')
this.emitStatus({ phase: 'resolving' })
private async downloadAndInstallBinary(
platform: NodeJS.Platform,
arch: string,
binPath: string,
requestedVersion: string
): Promise<string> {
const displayVersion = requestedVersion === 'latest' ? 'latest' : requestedVersion
this.log(`开始准备 sing-box ${displayVersion} 安装包...`)
this.emitStatus({
phase: 'resolving',
version: requestedVersion === 'latest' ? undefined : requestedVersion
})
let version: string | undefined
let tagName: string | undefined
let downloadUrl: string | undefined
let archivePath = ''
let extractDir = ''
try {
const resolved = await this.resolveLatestReleaseDownload(platform, arch)
const resolved = await this.resolveReleaseDownload(platform, arch, requestedVersion)
version = resolved.version
tagName = resolved.tagName
downloadUrl = resolved.downloadUrl
const archiveExt = resolved.archiveExt
this.emitStatus({
@@ -125,7 +283,7 @@ export class SingBoxInstaller {
downloadedBytes: 0
})
archivePath = path.join(BIN_DIR, `sing-box-${version}${archiveExt}`)
archivePath = path.join(BIN_DIR, `sing-box-${version}-${generateId()}${archiveExt}`)
extractDir = path.join(BIN_DIR, `tmp-${generateId()}`)
await fs.ensureDir(extractDir)
await this.downloadToFile(downloadUrl, archivePath, (progress, downloadedBytes, totalBytes) => {
@@ -150,6 +308,12 @@ export class SingBoxInstaller {
throw new Error('安装完成后仍未找到 sing-box 可执行文件')
}
await this.writeInstallMeta({
version,
tagName,
downloadUrl,
installedAt: Date.now()
})
this.log(`sing-box 已安装:${binPath}`)
this.emitStatus({
phase: 'completed',
@@ -171,22 +335,26 @@ export class SingBoxInstaller {
throw new Error(msg)
} finally {
if (extractDir) {
await fs.remove(extractDir).catch(() => {})
await fs.remove(extractDir).catch(() => { })
}
if (archivePath) {
await fs.remove(archivePath).catch(() => {})
await fs.remove(archivePath).catch(() => { })
}
}
}
/**
* 解析 GitHub 最新 Release 版本信息
* 解析 GitHub Release 版本信息
*/
private async resolveLatestReleaseDownload(platform: NodeJS.Platform, arch: string): Promise<{ version: string, downloadUrl: string, archiveExt: string }> {
private async resolveReleaseDownload(
platform: NodeJS.Platform,
arch: string,
requestedVersion: string
): Promise<ReleaseAssetResolution> {
const platformName = platform === 'win32' ? 'windows'
: platform === 'darwin' ? 'darwin'
: platform === 'linux' ? 'linux'
: null
: platform === 'linux' ? 'linux'
: null
if (!platformName) {
throw new Error(`不支持的平台:${platform}`)
@@ -194,30 +362,74 @@ export class SingBoxInstaller {
const archName = arch === 'x64' ? 'amd64'
: arch === 'arm64' ? 'arm64'
: arch === 'ia32' ? '386'
: null
: arch === 'ia32' ? '386'
: null
if (!archName) {
throw new Error(`不支持的架构:${arch}`)
}
const archiveExt = platformName === 'windows' ? '.zip' : '.tar.gz'
const requestedCandidates = this.buildReleaseRequestCandidates(requestedVersion)
let lastError: unknown = null
const release = await this.fetchJson<any>('https://api.github.com/repos/SagerNet/sing-box/releases/latest')
const tagName = String(release?.tag_name || '')
const version = tagName.startsWith('v') ? tagName.slice(1) : tagName
if (!version) throw new Error('无法解析 sing-box 版本号')
for (const candidate of requestedCandidates) {
try {
const release = candidate === 'latest'
? await this.fetchJson<any>(`${GITHUB_RELEASES_BASE}/latest`)
: await this.fetchReleaseByVersion(candidate)
const assetName = `sing-box-${version}-${platformName}-${archName}${archiveExt}`
const assets: any[] = Array.isArray(release?.assets) ? release.assets : []
const asset = assets.find(a => String(a?.name || '') === assetName)
const downloadUrl = String(asset?.browser_download_url || '')
const tagName = String(release?.tag_name || '').trim()
const version = this.normalizeVersionFromTag(tagName)
if (!version) throw new Error('无法解析 sing-box 版本号')
if (!downloadUrl) {
throw new Error(`未找到对应的发布包:${assetName}`)
const assetName = `sing-box-${version}-${platformName}-${archName}${archiveExt}`
const assets: any[] = Array.isArray(release?.assets) ? release.assets : []
const asset = assets.find((a) => String(a?.name || '') === assetName)
const downloadUrl = String(asset?.browser_download_url || '')
if (!downloadUrl) {
throw new Error(`未找到对应的发布包:${assetName}`)
}
if (candidate !== requestedCandidates[0]) {
this.log(`sing-box latest 不可用,已回退到保底版本 v${version}`)
}
return { version, tagName, downloadUrl, archiveExt }
} catch (error) {
lastError = error
if (candidate !== requestedCandidates.at(-1)) {
this.log(`解析 sing-box ${candidate} 失败,尝试保底版本:${String((error as any)?.message || error)}`, 'error')
continue
}
}
}
return { version, downloadUrl, archiveExt }
throw lastError instanceof Error
? lastError
: new Error(`未找到可用的 sing-box 发布包:${requestedVersion}`)
}
private async fetchReleaseByVersion(requestedVersion: string): Promise<any> {
const candidates = this.buildTagCandidates(requestedVersion)
let lastError: unknown = null
for (const tag of candidates) {
try {
return await this.fetchJson<any>(`${GITHUB_RELEASES_BASE}/tags/${encodeURIComponent(tag)}`)
} catch (error) {
lastError = error
const message = String((error as any)?.message || error || '')
if (!message.includes('404')) {
throw error
}
}
}
throw lastError instanceof Error
? lastError
: new Error(`未找到 sing-box 版本:${requestedVersion}`)
}
/**
@@ -274,7 +486,7 @@ export class SingBoxInstaller {
await this.withRetry(
async (attempt) => {
if (attempt > 1) {
await fs.remove(destPath).catch(() => {})
await fs.remove(destPath).catch(() => { })
}
await this.doDownloadRequest(url, destPath, 5, onProgress)
},
@@ -420,8 +632,8 @@ export class SingBoxInstaller {
const exeDir = path.dirname(exePath)
const entries = await fs.readdir(exeDir, { withFileTypes: true })
const dlls = entries
.filter(e => e.isFile() && e.name.toLowerCase().endsWith('.dll'))
.map(e => path.join(exeDir, e.name))
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.dll'))
.map((e) => path.join(exeDir, e.name))
for (const dll of dlls) {
const target = path.join(BIN_DIR, path.basename(dll))
@@ -429,7 +641,7 @@ export class SingBoxInstaller {
}
}
await fs.chmod(binPath, 0o755).catch(() => {})
await fs.chmod(binPath, 0o755).catch(() => { })
}
/**
@@ -462,7 +674,8 @@ export class SingBoxInstaller {
if (path.resolve(manualPath) === path.resolve(binPath)) return true
await fs.copyFile(manualPath, binPath)
await fs.chmod(binPath, 0o755).catch(() => {})
await fs.chmod(binPath, 0o755).catch(() => { })
await this.clearInstallMeta()
if (process.platform === 'win32') {
const manualDir = path.dirname(manualPath)
@@ -470,7 +683,7 @@ export class SingBoxInstaller {
for (const entry of entries) {
if (!entry.isFile()) continue
if (!entry.name.toLowerCase().endsWith('.dll')) continue
await fs.copyFile(path.join(manualDir, entry.name), path.join(BIN_DIR, entry.name)).catch(() => {})
await fs.copyFile(path.join(manualDir, entry.name), path.join(BIN_DIR, entry.name)).catch(() => { })
}
}
@@ -478,6 +691,103 @@ export class SingBoxInstaller {
return true
}
private getInstallMetaPath() {
return path.join(BIN_DIR, 'sing-box-install-meta.json')
}
private async readInstallMeta(): Promise<InstallMeta | null> {
try {
const meta = await fs.readJson(this.getInstallMetaPath()) as Partial<InstallMeta>
if (!meta || typeof meta !== 'object' || typeof meta.version !== 'string' || !meta.version.trim()) {
return null
}
return {
version: meta.version.trim(),
tagName: typeof meta.tagName === 'string' ? meta.tagName : undefined,
downloadUrl: typeof meta.downloadUrl === 'string' ? meta.downloadUrl : undefined,
installedAt: typeof meta.installedAt === 'number' ? meta.installedAt : 0
}
} catch {
return null
}
}
private async writeInstallMeta(meta: InstallMeta) {
await fs.ensureDir(path.dirname(this.getInstallMetaPath()))
await fs.writeJson(this.getInstallMetaPath(), meta, { spaces: 2 })
}
private async clearInstallMeta() {
await fs.remove(this.getInstallMetaPath()).catch(() => { })
}
private normalizeRequestedVersion(version?: string) {
const raw = String(version || '').trim()
if (!raw || raw.toLowerCase() === 'latest') return 'latest'
return raw.replace(/^v/i, '')
}
private normalizeVersionFromTag(tagName: string) {
return String(tagName || '').trim().replace(/^v/i, '')
}
private inferReleaseChannel(release: any): 'stable' | 'beta' | 'alpha' {
const text = `${String(release?.tag_name || '')} ${String(release?.name || '')}`.toLowerCase()
if (text.includes('alpha')) return 'alpha'
if (
text.includes('beta')
|| /\brc\b/.test(text)
|| text.includes('preview')
|| text.includes('pre-release')
|| release?.prerelease
) {
return 'beta'
}
return 'stable'
}
private buildTagCandidates(version: string) {
const normalized = this.normalizeRequestedVersion(version)
if (normalized === 'latest') return ['latest']
const original = String(version || '').trim()
return [...new Set([
original,
normalized,
`v${normalized}`
].filter(Boolean))]
}
private buildReleaseRequestCandidates(version: string) {
const normalized = this.normalizeRequestedVersion(version)
if (normalized !== 'latest' || !TESTED_SINGBOX_VERSION || TESTED_SINGBOX_VERSION === normalized) {
return [normalized]
}
return ['latest', TESTED_SINGBOX_VERSION]
}
private ensureTestedVersionPresent(versions: SingBoxCoreRelease[]) {
if (!TESTED_SINGBOX_VERSION) return versions
if (versions.some((release) => release.version === TESTED_SINGBOX_VERSION)) {
return versions
}
return [
{
version: TESTED_SINGBOX_VERSION,
tagName: `v${TESTED_SINGBOX_VERSION}`,
channel: this.inferReleaseChannelFromVersion(TESTED_SINGBOX_VERSION)
},
...versions
]
}
private inferReleaseChannelFromVersion(version: string): 'stable' | 'beta' | 'alpha' {
const text = String(version || '').toLowerCase()
if (text.includes('alpha')) return 'alpha'
if (text.includes('beta') || /\brc\b/.test(text) || text.includes('preview')) return 'beta'
return 'stable'
}
private emitStatus(payload: Omit<InstallerStatus, 'installDir' | 'binaryPath'>) {
this.onStatus?.({
...payload,
+2 -1
View File
@@ -2,7 +2,8 @@
"name": "lagzero",
"productName": "LagZero",
"private": true,
"version": "0.2.1",
"version": "0.2.2",
"lagZeroTestSingboxVersion": "1.13.4",
"main": "dist-electron/index.js",
"type": "module",
"author": "LagZero Team",
+5 -1
View File
@@ -2,6 +2,10 @@ export const singboxApi = {
start: (config: string) => window.singbox.start(config),
stop: () => window.singbox.stop(),
restart: (config: string) => window.singbox.restart(config),
ensureCoreInstalled: () => window.singbox.ensureCoreInstalled(),
ensureCoreInstalled: (preferredVersion?: string) => window.singbox.ensureCoreInstalled(preferredVersion),
installCore: (preferredVersion?: string) => window.singbox.installCore(preferredVersion),
listCoreVersions: (forceRefresh?: boolean) => window.singbox.listCoreVersions(forceRefresh),
getPreferredVersion: () => window.singbox.getPreferredVersion(),
setPreferredVersion: (preferredVersion?: string) => window.singbox.setPreferredVersion(preferredVersion),
getInstallInfo: () => window.singbox.getInstallInfo(),
}
+336 -3
View File
@@ -113,6 +113,151 @@
</div>
</section>
<!-- Sing-box Core -->
<section>
<h2 class="text-xs font-bold uppercase tracking-widest text-primary mb-3 pl-1">
{{ $t('settings.core_management') }}
</h2>
<div
class="bg-surface-panel/50 border border-border/50 rounded-2xl flex flex-col divide-y divide-border/30 backdrop-blur-sm overflow-hidden shadow-sm">
<div
class="flex flex-col sm:flex-row sm:items-center justify-between p-4 lg:p-5 gap-4 hover:bg-surface-overlay/30 transition-colors">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0">
<div class="i-material-symbols-memory-outline text-xl"></div>
</div>
<div>
<div class="font-bold text-sm">{{ $t('settings.core_management') }}</div>
<div class="text-xs text-on-surface-muted mt-0.5 line-clamp-2">
{{ $t('settings.core_version_desc') }}
</div>
</div>
</div>
<div class="shrink-0">
<span
class="inline-flex items-center rounded-full border px-3 py-1 text-xs font-bold"
:class="coreInstallInfo.exists
? 'border-success/30 bg-success/10 text-success'
: 'border-warning/30 bg-warning/10 text-warning'"
>
{{ coreStatusText }}
</span>
</div>
</div>
<div class="p-4 lg:p-5 space-y-4 hover:bg-surface-overlay/30 transition-colors">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="w-full sm:w-[220px] font-bold text-sm">{{ $t('settings.core_current_version') }}</div>
<div class="w-full md:flex-1 text-sm text-on-surface-muted break-all">
{{ installedCoreVersionText }}
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="w-full sm:w-[220px] font-bold text-sm">{{ $t('settings.core_test_version') }}</div>
<div class="w-full md:flex-1 text-sm text-on-surface-muted break-all">
{{ testedCoreVersionText }}
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="w-full sm:w-[220px] font-bold text-sm">{{ $t('settings.core_version') }}</div>
<div class="w-full md:flex-1 space-y-2">
<n-select
v-model:value="settingsStore.singboxCoreVersion"
:options="coreVersionOptions"
:loading="loadingCoreVersions"
filterable
class="glass-select"
/>
<p class="text-xs text-on-surface-muted">
{{ $t('settings.core_target_version', { version: selectedCoreVersionText }) }}
</p>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="w-full sm:w-[220px] font-bold text-sm">{{ $t('settings.core_install_dir') }}</div>
<div class="w-full md:flex-1 text-sm text-on-surface-muted break-all">
{{ coreInstallInfo.installDir || '-' }}
</div>
</div>
<p v-if="coreVersionError" class="text-xs text-warning">
{{ $t('settings.core_version_fetch_failed', { error: coreVersionError }) }}
</p>
<p v-if="coreInstallInfo.isRunning" class="text-xs text-warning">
{{ $t('settings.core_switch_requires_stop') }}
</p>
<div class="flex flex-wrap gap-3">
<n-button
secondary
class="glass-button"
:loading="installingCore"
:disabled="coreInstallInfo.isRunning"
@click="handleInstallCore"
>
<template #icon>
<div class="i-material-symbols-download"></div>
</template>
{{ $t('settings.core_download') }}
</n-button>
<n-button secondary class="glass-button" :loading="loadingCoreVersions" @click="refreshCoreVersions(true)">
<template #icon>
<div class="i-material-symbols-refresh"></div>
</template>
{{ $t('settings.core_refresh_versions') }}
</n-button>
<n-button secondary class="glass-button" :loading="refreshingCoreInfo" @click="refreshCoreInstallInfo">
<template #icon>
<div class="i-material-symbols-info-outline"></div>
</template>
{{ $t('settings.core_refresh_status') }}
</n-button>
<n-button secondary class="glass-button" @click="openCoreInstallDir">
<template #icon>
<div class="i-material-symbols-folder-open-outline"></div>
</template>
{{ $t('settings.core_open_dir') }}
</n-button>
</div>
<div v-if="showCoreInstallProgress" class="rounded-xl border border-border/40 bg-surface-overlay/30 p-3 text-sm">
<div class="mb-2 flex items-center justify-between gap-3">
<span class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('singbox_installer.download_progress') }}
</span>
<span
class="text-xs font-medium"
:class="coreInstallPhase === 'failed'
? 'text-error'
: coreInstallPhase === 'completed'
? 'text-success'
: 'text-on-surface-muted'"
>
{{ coreInstallPhaseSummary }}
</span>
</div>
<n-progress
v-if="typeof coreInstallProgress === 'number'"
type="line"
:percentage="coreInstallProgress"
:indicator-placement="'inside'"
:processing="coreInstallPhase !== 'completed' && coreInstallPhase !== 'failed'"
/>
<div v-else class="flex items-center gap-2 text-on-surface-muted">
<n-spin size="small" />
<span>{{ coreInstallPhaseSummary }}</span>
</div>
<p class="mt-2 text-xs text-on-surface-muted break-words">
{{ coreInstallPhaseDetail }}
</p>
</div>
</div>
</div>
</section>
<!-- Local Proxy -->
<section>
<h2 class="text-xs font-bold uppercase tracking-widest text-primary mb-3 pl-1">
@@ -387,18 +532,37 @@
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import pkg from '../../../package.json'
import { useSettingsStore } from '@/stores/settings'
import { useSingboxInstallerStore } from '@/stores/singbox-installer'
import { useLocalProxyStore } from '@/stores/local-proxy'
import { useGameStore } from '@/stores/games'
import { NSelect, NInput, NButton, NSwitch, NInputNumber } from 'naive-ui'
import { systemApi } from '@/api'
import { NSelect, NInput, NButton, NSwitch, NInputNumber, NProgress, NSpin, useMessage, type SelectGroupOption, type SelectOption } from 'naive-ui'
import { appApi, systemApi } from '@/api'
const { t } = useI18n()
const message = useMessage()
const settingsStore = useSettingsStore()
const singboxInstallerStore = useSingboxInstallerStore()
const localProxyStore = useLocalProxyStore()
const gameStore = useGameStore()
const {
availableCoreVersions,
loadingCoreVersions,
versionLoadError: coreVersionError,
refreshingInstallInfo: refreshingCoreInfo,
installInfo: coreInstallInfo,
phase: coreInstallPhase,
progress: coreInstallProgress,
resolvedVersion: coreInstallResolvedVersion,
errorMessage: coreInstallErrorMessage,
failedByTimeout: coreInstallFailedByTimeout,
isInstalling: installingCore,
hasInstallProgress: showCoreInstallProgress
} = storeToRefs(singboxInstallerStore)
const working = ref(false)
const applyingDns = ref(false)
@@ -429,6 +593,93 @@ const vlessEncodingOptions = computed(() => ([
{ label: 'xudp', value: 'xudp' }
]))
const isTunNetworkMode = computed(() => settingsStore.accelNetworkMode === 'tun')
const latestStableCoreVersion = computed(() => (
availableCoreVersions.value.find((release) => release.channel === 'stable')?.version
))
const coreVersionOptions = computed<Array<SelectOption | SelectGroupOption>>(() => {
const latestLabel = latestStableCoreVersion.value
? t('settings.core_version_latest_with_version', { version: latestStableCoreVersion.value })
: t('settings.core_version_latest')
const selectedVersion = settingsStore.singboxCoreVersion
const needsSelectedFallback = selectedVersion !== 'latest'
&& !availableCoreVersions.value.some((release) => release.version === selectedVersion)
const grouped = {
stable: availableCoreVersions.value.filter((release) => release.channel === 'stable'),
beta: availableCoreVersions.value.filter((release) => release.channel === 'beta'),
alpha: availableCoreVersions.value.filter((release) => release.channel === 'alpha')
}
if (needsSelectedFallback) {
const fallbackChannel = inferReleaseChannelFromVersion(selectedVersion)
grouped[fallbackChannel] = [
{
version: selectedVersion,
tagName: `v${selectedVersion}`,
channel: fallbackChannel
},
...grouped[fallbackChannel]
]
}
return [
{ label: latestLabel, value: 'latest' },
...buildCoreVersionGroups(grouped)
]
})
const coreStatusText = computed(() => (
coreInstallInfo.value.exists
? t('settings.core_installed')
: t('settings.core_missing')
))
const installedCoreVersionText = computed(() => {
if (!coreInstallInfo.value.exists) return t('settings.core_missing')
if (coreInstallInfo.value.installedVersion) return `v${coreInstallInfo.value.installedVersion}`
return t('settings.core_installed_unknown')
})
const testedCoreVersionText = computed(() => {
const version = String(pkg.lagZeroTestSingboxVersion || '').trim()
if (!version) return t('settings.core_test_version_unset')
return version.startsWith('v') ? version : `v${version}`
})
const selectedCoreVersionText = computed(() => {
if (settingsStore.singboxCoreVersion === 'latest') {
return latestStableCoreVersion.value
? t('settings.core_version_latest_with_version', { version: latestStableCoreVersion.value })
: t('settings.core_version_latest')
}
return `v${settingsStore.singboxCoreVersion}`
})
const resolvedCoreVersionText = computed(() => {
const version = String(coreInstallResolvedVersion.value || '').trim()
if (!version) return selectedCoreVersionText.value
return version.startsWith('v') ? version : `v${version}`
})
const coreInstallPhaseSummary = computed(() => {
if (coreInstallPhase.value === 'checking') return t('singbox_installer.checking')
if (coreInstallPhase.value === 'missing') return t('singbox_installer.missing')
if (coreInstallPhase.value === 'resolving') return t('singbox_installer.resolving')
if (coreInstallPhase.value === 'downloading') {
return typeof coreInstallProgress.value === 'number'
? t('singbox_installer.downloading_percent', { percent: coreInstallProgress.value })
: t('singbox_installer.downloading')
}
if (coreInstallPhase.value === 'extracting') return t('singbox_installer.extracting')
if (coreInstallPhase.value === 'completed') {
return t('settings.core_download_success', { version: resolvedCoreVersionText.value })
}
if (coreInstallPhase.value === 'failed') {
return coreInstallFailedByTimeout.value
? t('singbox_installer.failed_timeout')
: t('singbox_installer.failed')
}
return t('singbox_installer.preparing')
})
const coreInstallPhaseDetail = computed(() => {
if (coreInstallPhase.value === 'failed' && coreInstallErrorMessage.value) {
return coreInstallErrorMessage.value
}
return coreInstallPhaseSummary.value
})
function setCheckMethod(method: string) {
// @ts-ignore
@@ -439,6 +690,82 @@ function applyProfilePreset(profile: 'stable' | 'aggressive') {
settingsStore.applySessionNetworkProfilePreset(profile)
}
function formatCoreVersionOptionLabel(version: string, publishedAt?: string) {
if (!publishedAt) return `v${version}`
const date = new Date(publishedAt)
if (Number.isNaN(date.getTime())) return `v${version}`
return `v${version} (${date.toLocaleDateString()})`
}
function inferReleaseChannelFromVersion(version: string): 'stable' | 'beta' | 'alpha' {
const text = String(version || '').toLowerCase()
if (text.includes('alpha')) return 'alpha'
if (text.includes('beta') || /\brc\b/.test(text) || text.includes('preview')) return 'beta'
return 'stable'
}
function buildCoreVersionGroups(grouped: Record<'stable' | 'beta' | 'alpha', Array<{
version: string
tagName: string
publishedAt?: string
channel: 'stable' | 'beta' | 'alpha'
}>>): SelectGroupOption[] {
const groups: Array<{ key: 'stable' | 'beta' | 'alpha', label: string }> = [
{ key: 'stable', label: t('settings.core_version_group_stable') },
{ key: 'beta', label: t('settings.core_version_group_beta') },
{ key: 'alpha', label: t('settings.core_version_group_alpha') }
]
return groups.reduce<SelectGroupOption[]>((acc, group) => {
const releases = grouped[group.key]
if (!releases.length) return acc
acc.push({
type: 'group' as const,
key: `core-${group.key}`,
label: group.label,
children: releases.map((release) => ({
label: formatCoreVersionOptionLabel(release.version, release.publishedAt),
value: release.version
}))
})
return acc
}, [])
}
async function refreshCoreVersions(forceRefresh = false) {
try {
await singboxInstallerStore.refreshCoreVersions(forceRefresh)
} catch {
// 错误状态已同步到共享 store
}
}
async function refreshCoreInstallInfo() {
try {
await singboxInstallerStore.refreshInstallInfo()
} catch (e: any) {
message.error(t('settings.core_status_load_failed', { error: String(e?.message || e) }))
}
}
async function handleInstallCore() {
if (installingCore.value) return
try {
await singboxInstallerStore.installCore(settingsStore.singboxCoreVersion)
await refreshCoreInstallInfo()
message.success(t('settings.core_download_success', { version: resolvedCoreVersionText.value }))
} catch (e: any) {
message.error(t('settings.core_download_failed', { error: String(e?.message || e) }))
}
}
function openCoreInstallDir() {
if (!coreInstallInfo.value.installDir) return
void appApi.openDir(coreInstallInfo.value.installDir).catch((e: any) => {
message.error(t('settings.core_open_dir_failed', { error: String(e?.message || e) }))
})
}
async function handleFlushDns() {
if (working.value) return
working.value = true
@@ -532,4 +859,10 @@ watch(
}
}
)
onMounted(() => {
void singboxInstallerStore.initialize()
void refreshCoreVersions()
void refreshCoreInstallInfo()
})
</script>
+222 -119
View File
@@ -1,8 +1,14 @@
<template>
<!-- 正常模式:模态框 -->
<n-modal :show="visible && !isMinimized" preset="card"
class="w-[680px] max-w-[96vw] transition-all duration-300 ease-in-out" :title="t('singbox_installer.title')"
:closable="false" :mask-closable="false" :close-on-esc="false">
<n-modal
:show="visible && !isMinimized"
preset="card"
class="w-[680px] max-w-[96vw] transition-all duration-300 ease-in-out"
:title="t('singbox_installer.title')"
:closable="false"
:mask-closable="false"
:close-on-esc="false"
>
<template #header-extra>
<n-button quaternary circle size="small" @click="isMinimized = true">
<template #icon>
@@ -17,19 +23,51 @@
</p>
<div class="rounded-xl border border-border bg-surface-overlay/35 p-3 text-sm">
<div class="font-semibold mb-2">{{ t('singbox_installer.download_url') }}</div>
<div class="break-all text-on-surface-muted">{{ effectiveDownloadUrl }}</div>
<div class="font-semibold mb-2">{{ t('settings.core_version') }}</div>
<n-select
v-model:value="settingsStore.singboxCoreVersion"
:options="coreVersionOptions"
:loading="loadingCoreVersions"
:disabled="!canChooseVersion"
filterable
class="glass-select"
/>
<div class="mt-3 space-y-2 rounded-lg bg-surface/50 px-3 py-2 text-xs text-on-surface-muted">
<div class="flex items-center justify-between gap-3">
<span>{{ t('settings.core_target_version_label') }}</span>
<span class="font-medium text-on-surface">{{ selectedCoreVersionText }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span>{{ t('settings.core_test_version') }}</span>
<span class="font-medium text-on-surface">{{ testedCoreVersionText }}</span>
</div>
</div>
<div v-if="versionLoadError" class="mt-2 text-xs text-warning break-words">
{{ t('settings.core_version_fetch_failed', { error: versionLoadError }) }}
</div>
<div class="mt-3 flex gap-2">
<n-button size="small" @click="openDownloadUrl">
{{ t('singbox_installer.open_download_url') }}
<n-button size="small" :loading="loadingCoreVersions" @click="refreshCoreVersions(true)">
{{ t('singbox_installer.refresh_versions') }}
</n-button>
</div>
</div>
<div class="rounded-xl border border-border bg-surface-overlay/35 p-3 text-sm">
<div class="font-semibold mb-2">{{ t('singbox_installer.install_dir') }}</div>
<div class="break-all text-on-surface-muted">{{ installDir }}</div>
<div class="mt-3 flex gap-2">
<div class="font-semibold mb-3">{{ t('singbox_installer.resource_info') }}</div>
<div class="space-y-3">
<div class="rounded-lg bg-surface/50 px-3 py-2">
<div class="mb-1 text-xs font-medium text-on-surface">{{ t('singbox_installer.download_url') }}</div>
<div class="break-all text-xs text-on-surface-muted">{{ effectiveDownloadUrl }}</div>
</div>
<div class="rounded-lg bg-surface/50 px-3 py-2">
<div class="mb-1 text-xs font-medium text-on-surface">{{ t('singbox_installer.install_dir') }}</div>
<div class="break-all text-xs text-on-surface-muted">{{ installDir }}</div>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<n-button size="small" @click="openDownloadUrl">
{{ t('singbox_installer.open_download_url') }}
</n-button>
<n-button size="small" @click="openInstallDir">
{{ t('singbox_installer.open_install_dir') }}
</n-button>
@@ -38,8 +76,13 @@
<div class="rounded-xl border border-border bg-surface-overlay/35 p-3 text-sm">
<div class="font-semibold mb-2">{{ t('singbox_installer.download_progress') }}</div>
<n-progress v-if="typeof progress === 'number'" type="line" :percentage="progress"
:indicator-placement="'inside'" processing />
<n-progress
v-if="typeof progress === 'number'"
type="line"
:percentage="progress"
:indicator-placement="'inside'"
processing
/>
<div v-else class="text-on-surface-muted flex items-center gap-2">
<n-spin size="small" />
<span>{{ phaseText }}</span>
@@ -64,8 +107,13 @@
<template #footer>
<div class="flex justify-end gap-2">
<n-button v-if="phase === 'failed'" :loading="ensuring" @click="retryDownload">
{{ t('singbox_installer.retry_download') }}
<n-button
v-if="phase === 'missing' || phase === 'failed'"
type="primary"
:loading="ensuring"
@click="ensureCore"
>
{{ downloadButtonText }}
</n-button>
<n-button :loading="rechecking" @click="checkAgain">
{{ t('singbox_installer.recheck') }}
@@ -77,9 +125,12 @@
<!-- 最小化模式:底部悬浮窗 -->
<Teleport to="body">
<div v-if="visible && isMinimized" class="fixed bottom-8 left-1/2 -translate-x-1/2 z-[5000] w-[320px]">
<n-card size="small" class="shadow-xl border-border/50 backdrop-blur-md bg-surface-panel/95"
<n-card
size="small"
class="shadow-xl border-border/50 backdrop-blur-md bg-surface-panel/95"
:title="typeof progress === 'number' ? t('singbox_installer.downloading_percent', { percent: progress }) : phaseText"
:bordered="true">
:bordered="true"
>
<template #header-extra>
<n-button quaternary circle size="small" @click="isMinimized = false">
<template #icon>
@@ -89,8 +140,13 @@
</template>
<div class="flex flex-col gap-2 py-1">
<n-progress v-if="typeof progress === 'number'" type="line" :percentage="progress"
:indicator-placement="'inside'" processing />
<n-progress
v-if="typeof progress === 'number'"
type="line"
:percentage="progress"
:indicator-placement="'inside'"
processing
/>
<div v-else-if="phase === 'failed'" class="text-error text-sm flex items-center gap-2">
<div class="i-material-symbols-error"></div>
<span>{{ t('singbox_installer.failed') }}</span>
@@ -105,52 +161,95 @@
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useMessage } from 'naive-ui'
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useMessage, type SelectGroupOption, type SelectOption } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { appApi, singboxApi } from '@/api'
type InstallerPhase =
| 'idle'
| 'checking'
| 'ready'
| 'missing'
| 'resolving'
| 'downloading'
| 'extracting'
| 'completed'
| 'failed'
interface InstallerStatusEvent {
phase: Exclude<InstallerPhase, 'idle'>
installDir: string
binaryPath: string
version?: string
downloadUrl?: string
progress?: number
error?: string
isTimeout?: boolean
}
import pkg from '../../../package.json'
import { appApi } from '@/api'
import { useSettingsStore } from '@/stores/settings'
import { useSingboxInstallerStore, type SingboxCoreReleaseOption } from '@/stores/singbox-installer'
const DEFAULT_DOWNLOAD_URL = 'https://github.com/SagerNet/sing-box/releases/latest'
const message = useMessage()
const { t } = useI18n()
const settingsStore = useSettingsStore()
const singboxInstallerStore = useSingboxInstallerStore()
const {
initialized,
availableCoreVersions,
loadingCoreVersions,
versionLoadError,
installInfo,
phase,
progress,
downloadUrl,
errorMessage,
failedByTimeout,
isInstalling: ensuring
} = storeToRefs(singboxInstallerStore)
const visible = ref(false)
const phase = ref<InstallerPhase>('idle')
const progress = ref<number | undefined>(undefined)
const installDir = ref('')
const binaryPath = ref('')
const downloadUrl = ref('')
const errorMessage = ref('')
const failedByTimeout = ref(false)
const ensuring = ref(false)
const rechecking = ref(false)
const coreReadyLock = ref(false)
const isMinimized = ref(false)
const installDir = computed(() => installInfo.value.installDir)
const effectiveDownloadUrl = computed(() => downloadUrl.value || DEFAULT_DOWNLOAD_URL)
const latestStableCoreVersion = computed(() => (
availableCoreVersions.value.find((release) => release.channel === 'stable')?.version
))
const coreVersionOptions = computed<Array<SelectOption | SelectGroupOption>>(() => {
const latestLabel = latestStableCoreVersion.value
? t('settings.core_version_latest_with_version', { version: latestStableCoreVersion.value })
: t('settings.core_version_latest')
const selectedVersion = settingsStore.singboxCoreVersion
const needsSelectedFallback = selectedVersion !== 'latest'
&& !availableCoreVersions.value.some((release) => release.version === selectedVersion)
const grouped = {
stable: availableCoreVersions.value.filter((release) => release.channel === 'stable'),
beta: availableCoreVersions.value.filter((release) => release.channel === 'beta'),
alpha: availableCoreVersions.value.filter((release) => release.channel === 'alpha')
}
if (needsSelectedFallback) {
const fallbackChannel = inferReleaseChannelFromVersion(selectedVersion)
grouped[fallbackChannel] = [
{
version: selectedVersion,
tagName: `v${selectedVersion}`,
channel: fallbackChannel
},
...grouped[fallbackChannel]
]
}
return [
{ label: latestLabel, value: 'latest' },
...buildCoreVersionGroups(grouped)
]
})
const selectedCoreVersionText = computed(() => {
if (settingsStore.singboxCoreVersion === 'latest') {
return latestStableCoreVersion.value
? t('settings.core_version_latest_with_version', { version: latestStableCoreVersion.value })
: t('settings.core_version_latest')
}
return `v${settingsStore.singboxCoreVersion}`
})
const testedCoreVersionText = computed(() => {
const version = String(pkg.lagZeroTestSingboxVersion || '').trim()
if (!version) return t('settings.core_test_version_unset')
return version.startsWith('v') ? version : `v${version}`
})
const effectiveDownloadUrl = computed(() => {
if (downloadUrl.value) return downloadUrl.value
return buildReleasePageUrl(settingsStore.singboxCoreVersion)
})
const canChooseVersion = computed(() => !ensuring.value && !['resolving', 'downloading', 'extracting'].includes(phase.value))
const downloadButtonText = computed(() => (
phase.value === 'failed'
? t('singbox_installer.retry_download')
: t('singbox_installer.download_selected')
))
const phaseText = computed(() => {
if (phase.value === 'checking') return t('singbox_installer.checking')
if (phase.value === 'missing') return t('singbox_installer.missing')
@@ -165,74 +264,92 @@ const phaseText = computed(() => {
return t('singbox_installer.preparing')
})
const onInstallerStatus = (payload: InstallerStatusEvent) => {
if (coreReadyLock.value) {
visible.value = false
return
}
function formatCoreVersionOptionLabel(version: string, publishedAt?: string) {
if (!publishedAt) return `v${version}`
const date = new Date(publishedAt)
if (Number.isNaN(date.getTime())) return `v${version}`
return `v${version} (${date.toLocaleDateString()})`
}
phase.value = payload.phase
installDir.value = payload.installDir || installDir.value
binaryPath.value = payload.binaryPath || binaryPath.value
downloadUrl.value = payload.downloadUrl || downloadUrl.value
errorMessage.value = payload.error || ''
failedByTimeout.value = Boolean(payload.isTimeout)
progress.value = typeof payload.progress === 'number'
? Math.max(0, Math.min(100, Math.floor(payload.progress)))
: undefined
function inferReleaseChannelFromVersion(version: string): 'stable' | 'beta' | 'alpha' {
const text = String(version || '').toLowerCase()
if (text.includes('alpha')) return 'alpha'
if (text.includes('beta') || /\brc\b/.test(text) || text.includes('preview')) return 'beta'
return 'stable'
}
if (payload.phase === 'ready' || payload.phase === 'completed') {
markCoreReady()
return
}
function buildCoreVersionGroups(grouped: Record<'stable' | 'beta' | 'alpha', SingboxCoreReleaseOption[]>): SelectGroupOption[] {
const groups: Array<{ key: 'stable' | 'beta' | 'alpha', label: string }> = [
{ key: 'stable', label: t('settings.core_version_group_stable') },
{ key: 'beta', label: t('settings.core_version_group_beta') },
{ key: 'alpha', label: t('settings.core_version_group_alpha') }
]
if (payload.phase === 'missing' || payload.phase === 'resolving' || payload.phase === 'downloading' || payload.phase === 'extracting' || payload.phase === 'failed') {
visible.value = true
return groups.reduce<SelectGroupOption[]>((acc, group) => {
const releases = grouped[group.key]
if (!releases.length) return acc
acc.push({
type: 'group' as const,
key: `core-${group.key}`,
label: group.label,
children: releases.map((release) => ({
label: formatCoreVersionOptionLabel(release.version, release.publishedAt),
value: release.version
}))
})
return acc
}, [])
}
function buildReleasePageUrl(version: string) {
if (!version || version === 'latest') return DEFAULT_DOWNLOAD_URL
return `https://github.com/SagerNet/sing-box/releases/tag/v${version}`
}
async function refreshCoreVersions(forceRefresh = false) {
try {
await singboxInstallerStore.refreshCoreVersions(forceRefresh)
} catch {
// 错误状态已同步到共享 store
}
}
const openInstallDir = () => {
function openInstallDir() {
void appApi.openDir(installDir.value).catch((e: any) => {
message.error(t('singbox_installer.open_dir_failed', { error: String(e?.message || e) }))
})
}
onMounted(async () => {
window.electron.on('singbox-installer-status', onInstallerStatus)
try {
const info = await singboxApi.getInstallInfo()
installDir.value = info.installDir
binaryPath.value = info.binaryPath
if (!info.exists) {
phase.value = 'missing'
visible.value = true
void ensureCore()
} else {
markCoreReady()
watch(
() => [initialized.value, installInfo.value.exists, phase.value] as const,
([ready, exists, currentPhase]) => {
if (!ready) return
if (currentPhase === 'ready' || currentPhase === 'completed') {
visible.value = false
return
}
} catch (e: any) {
visible.value = true
phase.value = 'failed'
errorMessage.value = String(e?.message || e)
}
})
onUnmounted(() => {
window.electron.off('singbox-installer-status', onInstallerStatus)
if (!exists || currentPhase === 'missing' || currentPhase === 'resolving' || currentPhase === 'downloading' || currentPhase === 'extracting' || currentPhase === 'failed') {
visible.value = true
}
},
{ immediate: true }
)
onMounted(() => {
void singboxInstallerStore.initialize().catch(() => {
// 共享 store 已保存失败状态,这里只避免未处理的异步警告
})
void refreshCoreVersions()
})
async function ensureCore() {
if (ensuring.value) return
ensuring.value = true
try {
await singboxApi.ensureCoreInstalled()
await singboxInstallerStore.installCore(settingsStore.singboxCoreVersion)
} catch (e: any) {
if (phase.value !== 'failed') {
phase.value = 'failed'
errorMessage.value = String(e?.message || e)
}
visible.value = true
} finally {
ensuring.value = false
}
}
@@ -240,12 +357,9 @@ async function checkAgain() {
if (rechecking.value) return
rechecking.value = true
try {
const info = await singboxApi.getInstallInfo()
installDir.value = info.installDir
binaryPath.value = info.binaryPath
const info = await singboxInstallerStore.refreshInstallInfo({ syncPhase: true })
if (info.exists) {
markCoreReady()
const restarted = await appApi.restart()
if (!restarted) {
message.warning(t('singbox_installer.restart_hint'))
@@ -253,6 +367,7 @@ async function checkAgain() {
return
}
phase.value = 'missing'
message.warning(t('singbox_installer.core_not_found'))
} catch (e: any) {
message.error(t('singbox_installer.recheck_failed', { error: String(e?.message || e) }))
@@ -266,16 +381,4 @@ function openDownloadUrl() {
message.error(t('singbox_installer.open_url_failed', { error: String(e?.message || e) }))
})
}
function retryDownload() {
phase.value = 'missing'
errorMessage.value = ''
void ensureCore()
}
function markCoreReady() {
coreReadyLock.value = true
visible.value = false
phase.value = 'ready'
}
</script>
+32 -2
View File
@@ -243,6 +243,33 @@
"dns_secondary": "Secondary DNS",
"dns_bootstrap": "Bootstrap DNS",
"dns_bootstrap_hint": "Used to resolve remote DNS domains (e.g. dns.google). Prefer a directly reachable DNS IP such as 223.5.5.5.",
"core_management": "sing-box Core",
"core_version": "Core Version",
"core_version_desc": "Choose core versions by Stable, Beta, or Alpha channels, then lock and install the one you want.",
"core_version_latest": "Latest stable (auto)",
"core_version_latest_with_version": "Latest stable (auto, current {version})",
"core_version_group_stable": "Stable",
"core_version_group_beta": "Beta",
"core_version_group_alpha": "Alpha",
"core_current_version": "Installed Version",
"core_test_version": "Tested Core Version",
"core_test_version_unset": "Not set",
"core_target_version_label": "Download Target",
"core_target_version": "Current download target: {version}",
"core_install_dir": "Core Install Directory",
"core_download": "Download / Reinstall Core",
"core_refresh_versions": "Refresh Version List",
"core_refresh_status": "Refresh Install Status",
"core_open_dir": "Open Core Folder",
"core_installed": "Installed",
"core_missing": "Not Installed",
"core_installed_unknown": "Installed (version unknown, likely manually placed)",
"core_switch_requires_stop": "The core is currently running. Stop acceleration before switching core versions.",
"core_version_fetch_failed": "Failed to load core versions: {error}",
"core_status_load_failed": "Failed to load core install status: {error}",
"core_download_success": "Core downloaded successfully: {version}",
"core_download_failed": "Core download failed: {error}",
"core_open_dir_failed": "Failed to open core folder: {error}",
"download_update": "Download Update Now",
"tagline": "A game accelerator specially designed for enhancing gaming experience",
"preferences": "Preferences",
@@ -381,16 +408,19 @@
"open_download_url": "Open Download URL",
"install_dir": "Core Installation Folder",
"open_install_dir": "Open Core Folder",
"resource_info": "Download & Install Info",
"download_progress": "Download Progress",
"download_timeout": "Download timed out, please install manually",
"download_failed": "Download failed, please install manually",
"auto_download_failed": "Automatic download failed. Please download manually and place it in the installation folder.",
"manual_install_guide": "Manual installation: Download and extract the archive, then place `sing-box.exe` (Windows) or `sing-box` (Linux/macOS) into the directory above.",
"download_selected": "Download Selected Version",
"refresh_versions": "Refresh Versions",
"recheck": "I have placed the core, recheck",
"retry_download": "Retry Download",
"checking": "Checking local core...",
"missing": "Core not found, preparing to download...",
"resolving": "Resolving latest version download URL...",
"missing": "Core not found. Select a version before downloading.",
"resolving": "Resolving core download URL...",
"downloading": "Downloading core...",
"downloading_percent": "Downloading core: {percent}%",
"extracting": "Download complete, installing core...",
+33 -3
View File
@@ -243,6 +243,33 @@
"dns_secondary": "备用 DNS",
"dns_bootstrap": "Bootstrap DNS",
"dns_bootstrap_hint": "用于解析远端 DNS 域名(例如 dns.google)。推荐填写可直连的 DNS IP,如 223.5.5.5。",
"core_management": "sing-box 核心",
"core_version": "核心版本",
"core_version_desc": "支持按正式版、Beta、Alpha 分类选择核心版本,可锁定兼容版本并手动下载/重装。",
"core_version_latest": "最新稳定版(自动)",
"core_version_latest_with_version": "最新稳定版(自动,当前 {version})",
"core_version_group_stable": "正式版",
"core_version_group_beta": "Beta 版本",
"core_version_group_alpha": "Alpha 版本",
"core_current_version": "当前已安装版本",
"core_test_version": "测试所用核心版本",
"core_test_version_unset": "未设置",
"core_target_version_label": "当前下载目标",
"core_target_version": "当前下载目标:{version}",
"core_install_dir": "核心安装目录",
"core_download": "下载/重装核心",
"core_refresh_versions": "刷新版本列表",
"core_refresh_status": "刷新安装状态",
"core_open_dir": "打开核心目录",
"core_installed": "已安装",
"core_missing": "未安装",
"core_installed_unknown": "已安装(版本未知,可能为手动放置)",
"core_switch_requires_stop": "检测到核心正在运行,请先停止当前加速后再切换核心版本。",
"core_version_fetch_failed": "获取核心版本列表失败:{error}",
"core_status_load_failed": "读取核心安装状态失败:{error}",
"core_download_success": "核心已下载完成:{version}",
"core_download_failed": "核心下载失败:{error}",
"core_open_dir_failed": "打开核心目录失败:{error}",
"download_update": "立即下载更新",
"tagline": "一款专为游戏体验打造的游戏加速器",
"preferences": "偏好设置",
@@ -381,16 +408,19 @@
"open_download_url": "打开下载地址",
"install_dir": "核心安装文件夹",
"open_install_dir": "打开核心文件夹",
"resource_info": "下载与安装信息",
"download_progress": "下载进度",
"download_timeout": "下载超时,请手动安装核心",
"download_failed": "下载失败,请手动安装核心",
"auto_download_failed": "自动下载失败,请手动下载后放到安装文件夹。",
"manual_install_guide": "手动安装方式:下载压缩包并解压,把 `sing-box.exe`(Windows)或 `sing-box`(Linux/macOS)放到上述目录。",
"download_selected": "下载所选版本",
"refresh_versions": "刷新版本列表",
"recheck": "我已放置核心,重新检测",
"retry_download": "重试自动下载",
"checking": "正在检测本地核心...",
"missing": "未检测到核心,准备下载...",
"resolving": "正在解析最新版本下载地址...",
"missing": "未检测到核心,请先选择版本后下载。",
"resolving": "正在解析核心下载地址...",
"downloading": "正在下载核心...",
"downloading_percent": "正在下载核心:{percent}%",
"extracting": "下载完成,正在安装核心...",
@@ -419,4 +449,4 @@
"show_window": "显示主界面",
"quit": "退出"
}
}
}
+27
View File
@@ -10,7 +10,9 @@ import { initLatencySessionStore } from './utils/latency-session'
import { setupRuntimeLogging } from './utils/runtime-logger'
import { useLocalProxyStore } from './stores/local-proxy'
import { useNodeStore } from './stores/nodes'
import { useSingboxInstallerStore } from './stores/singbox-installer'
import { useSettingsStore } from './stores/settings'
import { singboxApi } from './api'
setupRuntimeLogging()
@@ -30,13 +32,38 @@ const isTrayWindow = window.location.hash.includes('/tray')
if (!isTrayWindow) {
const localProxyStore = useLocalProxyStore()
const nodeStore = useNodeStore()
const singboxInstallerStore = useSingboxInstallerStore()
const settingsStore = useSettingsStore()
let pendingStartAfterStarting = false
const syncPreferredCoreVersion = async (version: string) => {
try {
const normalized = await singboxApi.setPreferredVersion(version)
if (normalized && normalized !== settingsStore.singboxCoreVersion) {
settingsStore.singboxCoreVersion = normalized
}
} catch (e) {
console.warn('同步 sing-box 核心版本偏好失败:', e)
}
}
void syncPreferredCoreVersion(settingsStore.singboxCoreVersion)
void singboxInstallerStore.initialize().catch((e) => {
console.warn('初始化 sing-box 安装状态失败:', e)
})
void localProxyStore.startLocalProxy('startup').catch((e) => {
console.error('启动时启动本地代理失败:', e)
})
watch(
() => settingsStore.singboxCoreVersion,
(version, prev) => {
if (version === prev) return
void syncPreferredCoreVersion(version)
}
)
watch(
() => nodeStore.nodes.length,
(len) => {
+2
View File
@@ -70,6 +70,7 @@ export const useSettingsStore = defineStore('settings', () => {
'<local>\nlocalhost\n127.0.0.1\n::1\n192.168.*\n10.*\n*.local'
) // 系统代理绕过列表
const tunInterfaceName = useLocalStorage('settings-tun-interface-name', 'LagZero') // TUN接口名称 默认LagZero
const singboxCoreVersion = useLocalStorage('settings-singbox-core-version', 'latest') // sing-box 首选核心版本
if (tunInterfaceName.value === 'singbox-tun') {
tunInterfaceName.value = 'LagZero'
}
@@ -108,6 +109,7 @@ export const useSettingsStore = defineStore('settings', () => {
systemProxyPort,
systemProxyBypass,
tunInterfaceName,
singboxCoreVersion,
sessionNetworkTuning,
resetSessionNetworkTuning,
applySessionNetworkProfilePreset
+287
View File
@@ -0,0 +1,287 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { singboxApi } from '@/api'
import { useSettingsStore } from './settings'
export type SingboxInstallerPhase =
| 'idle'
| 'checking'
| 'ready'
| 'missing'
| 'resolving'
| 'downloading'
| 'extracting'
| 'completed'
| 'failed'
export interface SingboxInstallerStatusEvent {
phase: Exclude<SingboxInstallerPhase, 'idle'>
installDir: string
binaryPath: string
version?: string
downloadUrl?: string
progress?: number
error?: string
isTimeout?: boolean
}
export interface SingboxCoreReleaseOption {
version: string
tagName: string
publishedAt?: string
channel: 'stable' | 'beta' | 'alpha'
}
export interface SingboxInstallInfo {
exists: boolean
installDir: string
binaryPath: string
installedVersion?: string
preferredVersion: string
isRunning: boolean
}
const ACTIVE_INSTALL_PHASES = new Set<SingboxInstallerPhase>([
'checking',
'resolving',
'downloading',
'extracting'
])
function createDefaultInstallInfo(preferredVersion = 'latest'): SingboxInstallInfo {
return {
exists: false,
installDir: '',
binaryPath: '',
installedVersion: undefined,
preferredVersion,
isRunning: false
}
}
function normalizeProgress(progress?: number) {
return typeof progress === 'number'
? Math.max(0, Math.min(100, Math.floor(progress)))
: undefined
}
export const useSingboxInstallerStore = defineStore('singbox-installer', () => {
const settingsStore = useSettingsStore()
const initialized = ref(false)
const availableCoreVersions = ref<SingboxCoreReleaseOption[]>([])
const loadingCoreVersions = ref(false)
const versionLoadError = ref('')
const refreshingInstallInfo = ref(false)
const installInfo = ref<SingboxInstallInfo>(createDefaultInstallInfo(settingsStore.singboxCoreVersion))
const phase = ref<SingboxInstallerPhase>('idle')
const progress = ref<number | undefined>(undefined)
const resolvedVersion = ref('')
const downloadUrl = ref('')
const errorMessage = ref('')
const failedByTimeout = ref(false)
const installCallCount = ref(0)
let initializePromise: Promise<void> | null = null
let refreshCoreVersionsPromise: Promise<SingboxCoreReleaseOption[]> | null = null
let refreshInstallInfoPromise: Promise<SingboxInstallInfo> | null = null
let installCorePromise: Promise<void> | null = null
let statusListenerBound = false
const isInstalling = computed(() => (
installCallCount.value > 0 || ACTIVE_INSTALL_PHASES.has(phase.value)
))
const hasInstallProgress = computed(() => (
phase.value !== 'idle' && phase.value !== 'ready' && phase.value !== 'completed'
))
watch(
() => settingsStore.singboxCoreVersion,
(version, prev) => {
if (version === prev) return
installInfo.value = {
...installInfo.value,
preferredVersion: version
}
if (isInstalling.value) return
downloadUrl.value = ''
progress.value = undefined
resolvedVersion.value = ''
errorMessage.value = ''
failedByTimeout.value = false
phase.value = installInfo.value.exists ? 'ready' : 'missing'
}
)
function applyInstallInfo(next: SingboxInstallInfo) {
installInfo.value = next
}
function syncPhaseFromInstallInfo() {
if (isInstalling.value) return
phase.value = installInfo.value.exists ? 'ready' : 'missing'
}
function applyInstallerStatus(payload: SingboxInstallerStatusEvent) {
phase.value = payload.phase
resolvedVersion.value = String(payload.version || '').trim()
downloadUrl.value = payload.downloadUrl || downloadUrl.value
errorMessage.value = String(payload.error || '')
failedByTimeout.value = Boolean(payload.isTimeout)
progress.value = normalizeProgress(payload.progress)
installInfo.value = {
...installInfo.value,
installDir: payload.installDir || installInfo.value.installDir,
binaryPath: payload.binaryPath || installInfo.value.binaryPath
}
if (payload.phase === 'ready' || payload.phase === 'completed') {
installInfo.value = {
...installInfo.value,
exists: true,
installedVersion: resolvedVersion.value || installInfo.value.installedVersion
}
void refreshInstallInfo()
return
}
if (payload.phase === 'missing') {
installInfo.value = {
...installInfo.value,
exists: false,
installedVersion: undefined
}
}
}
function bindStatusListener() {
if (statusListenerBound || typeof window === 'undefined' || typeof window.electron?.on !== 'function') {
return
}
window.electron.on('singbox-installer-status', applyInstallerStatus)
statusListenerBound = true
}
async function initialize() {
if (initialized.value) return
if (initializePromise) return initializePromise
bindStatusListener()
initializePromise = (async () => {
try {
await refreshInstallInfo({ syncPhase: true })
} catch (e: any) {
phase.value = 'failed'
errorMessage.value = String(e?.message || e)
throw e
} finally {
initialized.value = true
initializePromise = null
}
})()
return initializePromise
}
async function refreshCoreVersions(forceRefresh = false) {
if (refreshCoreVersionsPromise) return refreshCoreVersionsPromise
loadingCoreVersions.value = true
versionLoadError.value = ''
refreshCoreVersionsPromise = (async () => {
try {
const versions = await singboxApi.listCoreVersions(forceRefresh)
availableCoreVersions.value = versions
return versions
} catch (e: any) {
versionLoadError.value = String(e?.message || e || '')
throw e
} finally {
loadingCoreVersions.value = false
refreshCoreVersionsPromise = null
}
})()
return refreshCoreVersionsPromise
}
async function refreshInstallInfo(options: { syncPhase?: boolean } = {}) {
if (refreshInstallInfoPromise) {
const info = await refreshInstallInfoPromise
if (options.syncPhase) syncPhaseFromInstallInfo()
return info
}
refreshingInstallInfo.value = true
refreshInstallInfoPromise = (async () => {
try {
const info = await singboxApi.getInstallInfo()
applyInstallInfo(info)
if (options.syncPhase) syncPhaseFromInstallInfo()
return info
} finally {
refreshingInstallInfo.value = false
refreshInstallInfoPromise = null
}
})()
return refreshInstallInfoPromise
}
async function installCore(preferredVersion?: string) {
bindStatusListener()
if (installCorePromise) return installCorePromise
installCallCount.value += 1
downloadUrl.value = ''
progress.value = undefined
resolvedVersion.value = ''
errorMessage.value = ''
failedByTimeout.value = false
phase.value = 'checking'
installCorePromise = (async () => {
try {
await singboxApi.installCore(preferredVersion)
await refreshInstallInfo()
} catch (e: any) {
if (phase.value !== 'failed') {
phase.value = 'failed'
errorMessage.value = String(e?.message || e)
}
throw e
} finally {
installCallCount.value = Math.max(0, installCallCount.value - 1)
installCorePromise = null
}
})()
return installCorePromise
}
return {
initialized,
availableCoreVersions,
loadingCoreVersions,
versionLoadError,
refreshingInstallInfo,
installInfo,
phase,
progress,
resolvedVersion,
downloadUrl,
errorMessage,
failedByTimeout,
isInstalling,
hasInstallProgress,
initialize,
refreshCoreVersions,
refreshInstallInfo,
installCore
}
})
+13 -1
View File
@@ -24,11 +24,23 @@ export interface SingboxAPI {
start: (config: string) => Promise<void>
stop: () => Promise<void>
restart: (config: string) => Promise<void>
ensureCoreInstalled: () => Promise<void>
ensureCoreInstalled: (preferredVersion?: string) => Promise<void>
installCore: (preferredVersion?: string) => Promise<void>
listCoreVersions: (forceRefresh?: boolean) => Promise<Array<{
version: string
tagName: string
publishedAt?: string
channel: 'stable' | 'beta' | 'alpha'
}>>
getPreferredVersion: () => Promise<string>
setPreferredVersion: (preferredVersion?: string) => Promise<string>
getInstallInfo: () => Promise<{
exists: boolean
installDir: string
binaryPath: string
installedVersion?: string
preferredVersion: string
isRunning: boolean
}>
}
+4 -17
View File
@@ -152,22 +152,13 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
{
type: 'direct',
tag: 'direct'
},
// 阻断出口
{
type: 'block',
tag: 'block'
},
// DNS 出口(用于劫持 DNS 流量)
{
type: 'dns',
tag: 'dns-out'
}
],
route: {
rules: [
// 所有的 DNS 流量都转发给 DNS 模块处理
{ protocol: 'dns', outbound: 'dns-out' }
// sing-box 1.13+ 已移除 inbound.sniff 和 dns/block 特殊出站,这里改用规则动作。
{ action: 'sniff' },
{ protocol: 'dns', action: 'hijack-dns' }
],
auto_detect_interface: true,
// 最终兜底规则:
@@ -202,7 +193,6 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
// 说明:NAT1 取决于公网直连能力,客户端侧只能做“更开放映射”的优化,无法绝对保证。
endpoint_independent_nat: true,
stack: sessionTuning.tunStack,
sniff: true // 开启流量嗅探,用于域名解析
})
}
@@ -218,7 +208,6 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
tag: 'http-in',
listen: '127.0.0.1',
listen_port: httpPort,
sniff: true,
set_system_proxy: false
})
@@ -227,8 +216,7 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
type: 'socks',
tag: 'socks-in',
listen: '127.0.0.1',
listen_port: socksPort,
sniff: true
listen_port: socksPort
})
// 配置本地代理的流量出口
@@ -262,7 +250,6 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
tag: 'system-http-in',
listen: '127.0.0.1',
listen_port: httpPort,
sniff: true,
set_system_proxy: false // 注意:这里仅开启端口,系统代理的设置由外部程序控制
})