1.修复内核更新,导致配置错误的问题
2.新增核心版本选择功能,避免核心配置变动导致的不可用。
This commit is contained in:
1 parent
0a5b1780da
commit
a5e1a79e31
15 files changed
+1348
-189
No files matched your search
@@ -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'),
|
||||
})
|
||||
|
||||
@@ -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 版本,请把弹窗里的最后一行报错发我。')
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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...",
|
||||
|
||||
+32
-2
@@ -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": "下载完成,正在安装核心...",
|
||||
|
||||
+27
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Vendored
+13
-1
@@ -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
|
||||
}>
|
||||
}
|
||||
|
||||
|
||||
@@ -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 // 注意:这里仅开启端口,系统代理的设置由外部程序控制
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user