Compare commits

...
4 Commits
Author SHA1 Message Date
Tunglies 8ad95657bd style(widget): adopt system type styles and control conventions
Replace fixed point sizes with semantic footnote/caption styles, dim
unselected mode labels into a secondary tier, drop the mode track fill
and capsule borders in favor of one capsule language, and reshape the
toggles as borderless system-style pills. Section spacing unifies at
8pt and the error row gains a leading warning glyph. No behavior
change.
2026-09-14 18:50:16 +08:00
Tunglies a544990f9f fix(widget): raise the window via a dedicated deep-link route
Tapping a desktop-widget background activates a tray-only host through
LaunchServices, which never fires the macOS reopen event, so the app
appeared in the Dock without a window. Point the widget tap URL at the
dedicated clash-verge://show route and handle it in the scheme resolver
by raising the main window (including leaving lightweight mode). Bare
and subscription deep links keep their previous behavior.

Signed-off-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-09-14 15:03:41 +08:00
Tunglies e35a239a06 refactor(widget): resolve all SwiftLint warnings
Split the extension socket client into connect/send/read helpers so
exchange() stays under the complexity and body-length limits, rename the
file-descriptor parameters, drop redundant nil initialization on the
debug preview knobs, and wrap over-length lines. No behavior change.
2026-09-14 13:59:51 +08:00
Tunglies 143ed48aa9 feat(widget): add macOS desktop widget
Ship a native WidgetKit extension (routing mode, TUN and system proxy
toggles) as part of every macOS build. The extension talks to the Rust
backend over an App Group Unix socket with mutual code-signing checks,
reuses the backend i18n catalogs, and stays inert on macOS < 14.

A beforeBundle hook builds, localizes and pre-signs the extension before
Tauri signs the host; pnpm build resolves a single local signing identity
automatically and CI keeps using its injected credentials. An Xcode
style-preview host is included for iterating on the widget layout. The
i18n cleanup scanner now also scans Swift sources so widget-only keys
are not flagged unused.

Signed-off-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-09-14 13:59:40 +08:00
42 changed files with 1727 additions and 7 deletions
+1
View File
@@ -17,3 +17,4 @@ target
.vfox
.claude
.zed
/src-tauri/generated/widget/
Generated
+14
View File
@@ -1139,6 +1139,7 @@ dependencies = [
"clash-verge-logging",
"clash-verge-media-unlock",
"clash-verge-signal",
"clash-verge-widget",
"clash_verge_service_ipc",
"core-foundation 0.9.4",
"criterion",
@@ -1265,6 +1266,19 @@ dependencies = [
"tokio",
]
[[package]]
name = "clash-verge-widget"
version = "0.1.0"
dependencies = [
"anyhow",
"libc",
"log",
"scopeguard",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "clash_verge_service_ipc"
version = "2.6.9"
+2
View File
@@ -8,6 +8,7 @@ members = [
"crates/tauri-plugin-clash-verge-sysinfo",
"crates/clash-verge-i18n",
"crates/clash-verge-limiter",
"crates/clash-verge-widget",
]
resolver = "2"
@@ -53,6 +54,7 @@ clash-verge-logging = { path = "crates/clash-verge-logging" }
clash-verge-signal = { path = "crates/clash-verge-signal" }
clash-verge-i18n = { path = "crates/clash-verge-i18n" }
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
clash-verge-widget = { path = "crates/clash-verge-widget" }
tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", branch = "main" }
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tracing = "0.1"
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: وكيل النظام
tun: TUN
profile: ملف تعريفي
widget:
unavailable: افتح Clash Verge
failed: فشل التحديث؛ تحقق من Clash Verge
'on': مفعّل
'off': معطّل
unknown: غير معروف
name: توجيه Clash Verge
description: التحكم في وضع التوجيه وTUN ووكيل النظام.
update: تحديث Clash Verge
setting: الإعداد
value: القيمة
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: Systemproxy
tun: TUN
profile: Profil
widget:
unavailable: Clash Verge öffnen
failed: Aktualisierung fehlgeschlagen; Clash Verge prüfen
'on': Ein
'off': Aus
unknown: Unbekannt
name: Clash Verge Routing
description: Routingmodus, TUN und Systemproxy steuern.
update: Clash Verge aktualisieren
setting: Einstellung
value: Wert
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: System Proxy
tun: TUN
profile: Profile
widget:
unavailable: Open Clash Verge
failed: Update failed; check Clash Verge
'on': 'On'
'off': 'Off'
unknown: Unknown
name: Clash Verge Routing
description: Routing mode, TUN and system proxy controls.
update: Update Clash Verge
setting: Setting
value: Value
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: Proxy del sistema
tun: TUN
profile: Perfil
widget:
unavailable: Abre Clash Verge
failed: Error al actualizar; revisa Clash Verge
'on': Activado
'off': Desactivado
unknown: Desconocido
name: Enrutamiento de Clash Verge
description: Controles del modo de enrutamiento, TUN y proxy del sistema.
update: Actualizar Clash Verge
setting: Ajuste
value: Valor
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: پروکسی سیستم
tun: TUN
profile: پروفایل
widget:
unavailable: Clash Verge را باز کنید
failed: به‌روزرسانی ناموفق بود؛ Clash Verge را بررسی کنید
'on': روشن
'off': خاموش
unknown: نامشخص
name: مسیریابی Clash Verge
description: کنترل حالت مسیریابی، TUN و پراکسی سیستم.
update: به‌روزرسانی Clash Verge
setting: تنظیم
value: مقدار
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: Proksi Sistem
tun: TUN
profile: Profil
widget:
unavailable: Buka Clash Verge
failed: Pembaruan gagal; periksa Clash Verge
'on': Aktif
'off': Nonaktif
unknown: Tidak diketahui
name: Perutean Clash Verge
description: Kontrol mode perutean, TUN, dan proksi sistem.
update: Perbarui Clash Verge
setting: Pengaturan
value: Nilai
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: システムプロキシ
tun: TUN
profile: プロファイル
widget:
unavailable: Clash Verge を開いてください
failed: 更新失敗。Clash Verge を確認してください
'on': オン
'off': オフ
unknown: 不明
name: Clash Verge ルーティング
description: ルーティングモード、TUN、システムプロキシの操作。
update: Clash Verge を更新
setting: 設定
value: 値
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: 시스템 프록시
tun: TUN
profile: 프로필
widget:
unavailable: Clash Verge를 여세요
failed: 업데이트 실패. Clash Verge를 확인하세요
'on': 켜짐
'off': 꺼짐
unknown: 알 수 없음
name: Clash Verge 라우팅
description: 라우팅 모드, TUN 및 시스템 프록시 제어.
update: Clash Verge 업데이트
setting: 설정
value: 값
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: Системный прокси
tun: TUN
profile: Профиль
widget:
unavailable: Откройте Clash Verge
failed: Ошибка обновления; проверьте Clash Verge
'on': Вкл.
'off': Выкл.
unknown: Неизвестно
name: Маршрутизация Clash Verge
description: Управление режимом маршрутизации, TUN и системным прокси.
update: Обновить Clash Verge
setting: Настройка
value: Значение
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: Sistem Vekil'i
tun: TUN
profile: Profil
widget:
unavailable: Clash Verge uygulamasını açın
failed: Güncelleme başarısız; Clash Verge uygulamasını kontrol edin
'on': Açık
'off': Kapalı
unknown: Bilinmiyor
name: Clash Verge Yönlendirme
description: Yönlendirme modu, TUN ve sistem vekili denetimleri.
update: Clash Verge güncelle
setting: Ayar
value: Değer
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: Системалы прокси
tun: TUN
profile: Профиль
widget:
unavailable: Clash Verge ачыгыз
failed: Яңарту уңышсыз; Clash Verge тикшерегез
'on': Кабызылган
'off': Сүндерелгән
unknown: Билгесез
name: Clash Verge маршрутлау
description: Маршрутлау режимы, TUN һәм система проксие белән идарә итү.
update: Clash Verge яңарту
setting: Көйләү
value: Кыйммәт
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: 系统代理
tun: TUN
profile: 订阅
widget:
unavailable: 请打开 Clash Verge
failed: 更新失败,请检查 Clash Verge
'on': 开启
'off': 关闭
unknown: 未知
name: Clash Verge 路由
description: 路由模式、TUN 和系统代理控制。
update: 更新 Clash Verge
setting: 设置
value: 值
+11
View File
@@ -91,3 +91,14 @@ tray:
systemProxy: 系統代理
tun: 虛擬網路介面卡
profile: 訂閱
widget:
unavailable: 請開啟 Clash Verge
failed: 更新失敗,請檢查 Clash Verge
'on': 開啟
'off': 關閉
unknown: 未知
name: Clash Verge 路由
description: 路由模式、TUN 和系統代理控制。
update: 更新 Clash Verge
setting: 設定
value: 值
+3
View File
@@ -0,0 +1,3 @@
# Xcode per-user state and local build output
xcuserdata/
DerivedData/
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "clash-verge-widget"
version = "0.1.0"
edition = "2024"
license = "GPL-3.0-only"
[dependencies]
serde = { workspace = true, features = ["derive"] }
[target.'cfg(target_os = "macos")'.dependencies]
anyhow = { workspace = true }
libc = "0.2.189"
log = { workspace = true }
scopeguard = "1.2.0"
serde_json = { workspace = true }
tokio = { workspace = true, features = ["net", "io-util"] }
[lints]
workspace = true
+232
View File
@@ -0,0 +1,232 @@
import { execFileSync } from 'node:child_process'
import { X509Certificate, randomBytes } from 'node:crypto'
import {
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const crate = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const root = resolve(crate, '../..')
const app = join(root, 'src-tauri')
const stage = join(app, 'generated/widget')
const run = (command, args) => {
try {
return execFileSync(command, args, {
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
})
} catch (error) {
throw new Error(
`${command} failed (${error.status}); ${command === 'security' ? 'check signing credentials' : error.stderr?.toString() || 'no diagnostic output'}`,
{ cause: error },
)
}
}
const config = JSON.parse(readFileSync(join(app, 'tauri.conf.json'), 'utf8'))
const mac = JSON.parse(readFileSync(join(app, 'tauri.macos.conf.json'), 'utf8'))
const temporary = mkdtempSync(join(tmpdir(), 'verge-widget-build-'))
let keychain
try {
if (process.platform !== 'darwin')
throw new Error('Widget packaging requires macOS.')
run('xcodebuild', ['-version'])
run('xcrun', ['--find', 'appintentsmetadataprocessor'])
const target = process.env.TAURI_ENV_TARGET_TRIPLE
const arch = {
'aarch64-apple-darwin': 'arm64',
'x86_64-apple-darwin': 'x86_64',
'universal-apple-darwin': 'arm64 x86_64',
}[target]
if (!arch) throw new Error(`Unsupported widget target: ${target}`)
if (process.env.APPLE_CERTIFICATE) {
keychain = join(temporary, 'widget.keychain-db')
const password = randomBytes(24).toString('hex')
const certificate = join(temporary, 'signing.p12')
writeFileSync(
certificate,
Buffer.from(process.env.APPLE_CERTIFICATE, 'base64'),
{ mode: 0o600 },
)
run('security', ['create-keychain', '-p', password, keychain])
run('security', ['unlock-keychain', '-p', password, keychain])
run('security', [
'import',
certificate,
'-k',
keychain,
'-P',
process.env.APPLE_CERTIFICATE_PASSWORD || '',
'-T',
'/usr/bin/codesign',
])
run('security', [
'set-key-partition-list',
'-S',
'apple-tool:,apple:,codesign:',
'-s',
'-k',
password,
keychain,
])
}
const keychainArgs = keychain ? [keychain] : []
const identities = [
...run('security', [
'find-identity',
'-v',
'-p',
'codesigning',
...keychainArgs,
]).matchAll(/\b([A-F0-9]{40}) "([^"]+)"/g),
].map((match) => ({ hash: match[1], name: match[2] }))
const requested =
process.env.APPLE_SIGNING_IDENTITY || mac.bundle.macOS.signingIdentity
if (requested === '-') {
console.warn(
'CLASH_VERGE_WIDGET=0: skipping widget embedding; this build has no desktop widget extension.',
)
process.exit(0)
}
if (!requested)
throw new Error(
'pnpm build on macOS needs APPLE_SIGNING_IDENTITY (or a single valid signing certificate) for widget and host signing.',
)
const matches = identities.filter(
({ hash, name }) => hash === requested.toUpperCase() || name === requested,
)
if (matches.length !== 1)
throw new Error(
'Widget packaging requires one valid Apple signing identity; run pnpm build with APPLE_SIGNING_IDENTITY set.',
)
const identity = matches[0]
const certificates =
run('security', ['find-certificate', '-a', '-p', ...keychainArgs]).match(
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,
) || []
const certificate = certificates
.map((pem) => new X509Certificate(pem))
.find((cert) => cert.fingerprint.replaceAll(':', '') === identity.hash)
const team = certificate?.subject.match(
/(?:^|\n)OU=([A-Z0-9]{10})(?:\n|$)/,
)?.[1]
if (!team) throw new Error('Selected certificate has no Apple Team ID.')
const group = `${team}.io.vergewidget`
const version = config.version
mkdirSync(stage, { recursive: true })
run('python3', [
'-c',
`
import json, plistlib, sys
from pathlib import Path
app, stage, team, group, version = sys.argv[1:]
stage = Path(stage)
common = {'VergeWidgetTeam': team, 'VergeWidgetGroup': group}
info = dict(common, CFBundleIdentifier='io.github.clash-verge-rev.clash-verge-rev.widget', CFBundleName='VergeWidget', CFBundleDevelopmentRegion='zh-Hans', CFBundleDisplayName='Clash Verge Routing', CFBundleExecutable='VergeWidget', CFBundlePackageType='XPC!', CFBundleShortVersionString=version, CFBundleVersion=version, LSMinimumSystemVersion='14.0', NSExtension={'NSExtensionPointIdentifier':'com.apple.widgetkit-extension'})
with open(Path(app)/'packages/macos/info_merge.plist','rb') as f: host_info = plistlib.load(f)
host_info.update(common)
with open(Path(app)/'packages/macos/entitlements.plist','rb') as f: host_entitlements = plistlib.load(f)
host_entitlements['com.apple.security.application-groups'] = [group]
host_entitlements.pop('com.apple.security.inherit', None)
ext_entitlements = {'com.apple.security.app-sandbox': True, 'com.apple.security.application-groups': [group], 'com.apple.security.network.client': True}
for name, value in [('Widget.plist',info), ('Host.plist',host_info), ('Host.entitlements',host_entitlements), ('Widget.entitlements',ext_entitlements)]:
with (stage/name).open('wb') as f: plistlib.dump(value,f)
`,
app,
stage,
team,
group,
version,
])
const products = join(temporary, 'products')
run('xcodebuild', [
'-project',
join(crate, 'native/Widget.xcodeproj'),
'-target',
'VergeWidget',
'-configuration',
'Release',
`CONFIGURATION_BUILD_DIR=${products}`,
`OBJROOT=${temporary}/objects`,
`SYMROOT=${temporary}/symbols`,
`ARCHS=${arch}`,
'ONLY_ACTIVE_ARCH=NO',
'MACOSX_DEPLOYMENT_TARGET=14.0',
'CODE_SIGNING_ALLOWED=NO',
`WIDGET_INFO_PLIST=${stage}/Widget.plist`,
`WIDGET_ENTITLEMENTS=${stage}/Widget.entitlements`,
'build',
])
readFileSync(
join(
products,
'VergeWidget.appex/Contents/Resources/Metadata.appintents/extract.actionsdata',
),
)
const extension = join(products, 'VergeWidget.appex')
if (process.env.WIDGET_PROFILE) {
cpSync(
process.env.WIDGET_PROFILE,
join(extension, 'Contents/embedded.provisionprofile'),
)
}
run('codesign', [
'--force',
'--sign',
identity.hash,
...(keychain ? ['--keychain', keychain] : []),
'--options',
'runtime',
'--entitlements',
join(stage, 'Widget.entitlements'),
extension,
])
rmSync(join(stage, 'VergeWidget.appex'), { recursive: true, force: true })
cpSync(extension, join(stage, 'VergeWidget.appex'), { recursive: true })
const slices = arch.split(' ').map((architecture) => {
const output = join(temporary, `${architecture}.dylib`)
run('xcrun', [
'swiftc',
'-emit-library',
'-parse-as-library',
'-target',
`${architecture}-apple-macos14.0`,
join(crate, 'native/Bridge.swift'),
'-o',
output,
])
return output
})
run('xcrun', [
'lipo',
'-create',
...slices,
'-output',
join(stage, 'VergeWidgetBridge.dylib'),
])
console.log(
`Prepared signed widget ${version} (${arch}); Tauri will embed it before host signing and packaging.`,
)
} catch (error) {
console.error(
'Widget packaging failed:',
error.stderr?.toString() || error.message,
)
process.exitCode = 1
} finally {
if (keychain) {
try {
run('security', ['delete-keychain', keychain])
} catch {
console.error('Could not remove temporary widget keychain.')
}
}
rmSync(temporary, { recursive: true, force: true })
}
@@ -0,0 +1,37 @@
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { load } from 'js-yaml'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const resources = process.argv[2]
if (!resources) throw new Error('Pass the extension resources directory.')
const catalogs = join(root, 'crates/clash-verge-i18n/locales')
for (const file of readdirSync(catalogs)
.filter((file) => file.endsWith('.yml'))
.sort()) {
const locale = file.slice(0, -4)
const catalog = load(readFileSync(join(catalogs, file), 'utf8'))
const language =
{ jp: 'ja', zh: 'zh-Hans', zhtw: 'zh-Hant' }[locale] || locale
const strings = {
'tray.rule': catalog.tray.rule,
'tray.global': catalog.tray.global,
'tray.direct': catalog.tray.direct,
'tray.systemProxy': catalog.tray.systemProxy,
'tray.tooltip.tun': catalog.tray.tooltip.tun,
}
for (const [key, value] of Object.entries(catalog.widget))
strings[`widget.${key}`] = value
const directory = join(resources, `${language}.lproj`)
mkdirSync(directory, { recursive: true })
writeFileSync(
join(directory, 'Localizable.strings'),
Object.entries(strings)
.map(
([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)};`,
)
.join('\n'),
)
}
@@ -0,0 +1,53 @@
import Darwin
import Foundation
import Security
import WidgetKit
let widgetKind = "VergeRoutingWidget"
let hostID = "io.github.clash-verge-rev.clash-verge-rev"
func socketPath() -> String? {
guard let group = Bundle.main.object(forInfoDictionaryKey: "VergeWidgetGroup") as? String else { return nil }
guard let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else {
return nil
}
return container.appendingPathComponent("verge-widget.sock").path
}
func authenticPeer(_ descriptor: Int32, identifier: String) -> Bool {
var token = audit_token_t()
var size = socklen_t(MemoryLayout.size(ofValue: token))
guard getsockopt(descriptor, SOL_LOCAL, LOCAL_PEERTOKEN, &token, &size) == 0,
size == MemoryLayout.size(ofValue: token) else { return false }
let data = withUnsafeBytes(of: token) { Data($0) }
var peer: SecCode?
let attributes = [kSecGuestAttributeAudit: data] as CFDictionary
guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &peer) == errSecSuccess,
let peer,
let team = Bundle.main.object(forInfoDictionaryKey: "VergeWidgetTeam") as? String,
team.range(of: "^[A-Z0-9]{10}$", options: .regularExpression) != nil else { return false }
var requirement: SecRequirement?
let expression = """
anchor apple generic and certificate leaf[subject.OU] = "\(team)" and identifier "\(identifier)"
"""
guard SecRequirementCreateWithString(expression as CFString, [], &requirement) == errSecSuccess,
let requirement else { return false }
return SecCodeCheckValidity(peer, [], requirement) == errSecSuccess
}
#if !WIDGET_EXTENSION
@_cdecl("verge_widget_path")
public func vergeWidgetPath() -> UnsafeMutablePointer<CChar>? {
socketPath().flatMap { strdup($0) }
}
@_cdecl("verge_widget_authenticate")
public func vergeWidgetAuthenticate(_ descriptor: Int32) -> Bool {
authenticPeer(descriptor, identifier: hostID + ".widget")
}
@_cdecl("verge_widget_reload")
public func vergeWidgetReload() {
WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
}
#endif
@@ -0,0 +1,11 @@
import SwiftUI
@main
struct WidgetPreviewHost: App {
var body: some Scene {
WindowGroup("Widget Preview Host") {
Text("Use the StylePreviews.swift Canvas to preview routing controls.")
.padding(24)
}
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
<key>CFBundleName</key><string>VergeWidget</string>
<key>CFBundlePackageType</key><string>XPC!</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleDevelopmentRegion</key><string>zh-Hans</string>
<key>LSMinimumSystemVersion</key><string>14.0</string>
<key>NSExtension</key><dict>
<key>NSExtensionPointIdentifier</key><string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,201 @@
import SwiftUI
import WidgetKit
import Foundation
struct RoutingView: View {
let entry: Entry
@Environment(\.widgetFamily) private var widgetFamily
#if DEBUG
var previewFamily: WidgetFamily?
private var family: WidgetFamily { previewFamily ?? widgetFamily }
#else
private var family: WidgetFamily { widgetFamily }
#endif
@Environment(\.widgetRenderingMode) private var systemRenderingMode
#if DEBUG
var previewRenderingMode: WidgetRenderingMode?
var previewMaterial = "automatic"
var previewIncreaseContrast = false
private var renderingMode: WidgetRenderingMode { previewRenderingMode ?? systemRenderingMode }
#else
private var renderingMode: WidgetRenderingMode { systemRenderingMode }
#endif
@Environment(\.colorScheme) private var colorScheme
@Environment(\.colorSchemeContrast) private var systemContrast
#if DEBUG
private var contrast: ColorSchemeContrast { previewIncreaseContrast ? .increased : systemContrast }
#else
private var contrast: ColorSchemeContrast { systemContrast }
#endif
@Environment(\.accessibilityReduceMotion) private var systemReduceMotion
@Environment(\.accessibilityReduceTransparency) private var systemReduceTransparency
#if DEBUG
var previewReduceMotion: Bool?
var previewReduceTransparency: Bool?
private var reduceMotion: Bool { previewReduceMotion ?? systemReduceMotion }
private var reduceTransparency: Bool { previewReduceTransparency ?? systemReduceTransparency }
#else
private var reduceMotion: Bool { systemReduceMotion }
private var reduceTransparency: Bool { systemReduceTransparency }
#endif
private func text(_ key: String) -> String { localized(key, language: entry.status.language) }
private let modes = ["rule", "global", "direct"]
private var ink: Color { renderingMode == .fullColor ? (colorScheme == .dark ? .white : .black) : .primary }
private var secondaryInk: Color { ink.opacity(contrast == .increased ? 0.8 : 0.6) }
@ViewBuilder private var selectionBackground: some View {
#if DEBUG
if previewMaterial == "thin" && renderingMode == .fullColor && !reduceTransparency {
Capsule().fill(.thinMaterial)
} else {
automaticSelectionBackground
}
#else
automaticSelectionBackground
#endif
}
@ViewBuilder private var automaticSelectionBackground: some View {
if renderingMode != .fullColor {
Capsule().strokeBorder(ink.opacity(0.65), lineWidth: 1)
} else if reduceTransparency {
Capsule().fill(ink)
} else if #available(macOS 26.0, *) {
Capsule().fill(.clear)
.glassEffect(.regular, in: Capsule())
} else {
Capsule().fill(.thinMaterial)
}
}
private func modeInk(selected: Bool) -> Color {
guard selected && reduceTransparency && renderingMode == .fullColor else {
return selected ? ink : secondaryInk
}
return colorScheme == .dark ? .black : .white
}
private var locale: Locale {
let aliases = ["jp": "ja", "zh": "zh-Hans", "zhtw": "zh-Hant"]
let identifier = entry.status.language.map { aliases[$0] ?? $0 } ?? Locale.current.identifier
return Locale(identifier: identifier)
}
private var rtl: Bool { locale.language.characterDirection == .rightToLeft }
func control(_ title: String, action: String, value: Bool?, icon: String) -> some View {
let compact = family == .systemSmall
return Button(intent: SetRouting(action, value == true ? "false" : "true")) {
let label = HStack(spacing: 4) {
if !compact { Image(systemName: icon).font(.caption2) }
Text(title).font(.caption)
.lineLimit(compact ? 2 : 1)
.frame(height: compact ? 30 : nil, alignment: .topLeading)
}
let toggle = ZStack(alignment: .leading) {
Capsule().fill(ink.opacity(value == true ? 0.28 : 0.08))
Circle().fill(ink)
.frame(width: 13, height: 13)
.offset(x: value.map { $0 ? 19.0 : 4.0 } ?? 11.5)
}
.frame(width: 36, height: 22)
.environment(\.layoutDirection, .leftToRight)
if compact {
VStack(alignment: .leading, spacing: 8) {
label
if value == nil { Text(text("widget.unknown")).font(.caption2) } else { toggle }
}
} else {
HStack(spacing: 8) {
label
Spacer(minLength: 0)
if value == nil { Text(text("widget.unknown")).font(.caption2) } else { toggle }
}
}
}
.foregroundStyle(secondaryInk)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.disabled(value == nil)
.transaction { $0.animation = nil; $0.disablesAnimations = true }
.invalidatableContent()
.accessibilityLabel(title)
.accessibilityValue(text(value.map { $0 ? "widget.on" : "widget.off" } ?? "widget.unknown"))
}
@ViewBuilder private var containerMaterial: some View {
if reduceTransparency {
Rectangle().fill(.background)
} else {
Rectangle().fill(.ultraThinMaterial)
}
}
@ViewBuilder var body: some View {
#if DEBUG
if previewFamily != nil {
content.padding(16)
.frame(width: family == .systemSmall ? 170 : 360, height: 170)
.background { containerMaterial }
.clipShape(RoundedRectangle(cornerRadius: 24))
} else {
content.containerBackground(for: .widget) { containerMaterial }
}
#else
content.containerBackground(for: .widget) { containerMaterial }
#endif
}
private var content: some View {
VStack(alignment: .leading, spacing: 8) {
GeometryReader { geometry in
let width = (geometry.size.width - 4) / 3
let orderedModes = rtl ? Array(modes.reversed()) : modes
let index = orderedModes.firstIndex(of: entry.status.mode ?? "")
ZStack(alignment: .leading) {
selectionBackground
.frame(width: width - 2, height: 26)
.offset(x: 3 + CGFloat(index ?? 0) * width)
.opacity(index == nil ? 0 : 1)
.allowsHitTesting(false)
HStack(spacing: 0) {
ForEach(orderedModes, id: \.self) { mode in
let selected = entry.status.mode == mode
let label = Text(text("tray." + mode))
.font(selected ? .footnote.weight(.semibold) : .footnote)
.lineLimit(1)
.frame(maxWidth: .infinity)
.frame(height: 30)
.foregroundStyle(modeInk(selected: selected))
.contentTransition(reduceMotion ? .identity : .opacity)
.contentShape(Rectangle())
Button(intent: SetRouting("mode", mode)) { label }
.disabled(index == nil)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
.padding(.horizontal, 2)
.zIndex(1)
}
.invalidatableContent()
.environment(\.layoutDirection, .leftToRight)
}
.frame(height: 30)
HStack(spacing: 8) {
control(text("tray.tooltip.tun"), action: "tun", value: entry.status.tun, icon: "network")
control(
text("tray.systemProxy"), action: "proxy",
value: entry.status.proxy, icon: "shield.lefthalf.filled"
)
}
if let error = entry.status.error {
Label {
Text(text(error)).font(.caption2).lineLimit(2)
} icon: {
Image(systemName: "exclamationmark.triangle.fill").font(.caption2)
}
.foregroundStyle(secondaryInk)
}
}
.environment(\.locale, locale)
.environment(\.layoutDirection, rtl ? .rightToLeft : .leftToRight)
.transaction { $0.animation = nil }
.buttonStyle(.plain)
.widgetURL(URL(string: "clash-verge://show"))
}
}
@@ -0,0 +1,204 @@
import SwiftUI
import WidgetKit
#if DEBUG && PREVIEW_HOST
import UniformTypeIdentifiers
import AppKit
private struct RoutingStylePreview: View {
@State var medium = false
@State var dark = false
@State var language = "en"
@State var mode = "rule"
@State var tun = "on"
@State var proxy = "off"
@State var error = "none"
@State var reduceTransparency = false
@State var reduceMotion = false
@State private var backdrop = "gradient"
@State private var material = "automatic"
@State private var rendering = "automatic"
@State private var increaseContrast = false
@State private var importingWallpaper = false
@State private var wallpaper: NSImage?
@State private var wallpaperName = ""
@State private var wallpaperError: String?
private var renderingOverride: WidgetRenderingMode? {
switch rendering {
case "fullColor": .fullColor
case "accented": .accented
case "vibrant": .vibrant
default: nil
}
}
private func importWallpaper(_ result: Result<URL, Error>) {
do {
let url = try result.get()
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
guard let image = NSImage(data: data) else {
wallpaperError = "The selected file is not a readable image."
return
}
wallpaper = image
wallpaperName = url.lastPathComponent
wallpaperError = nil
backdrop = "wallpaper"
} catch {
let failure = error as NSError
if failure.domain != NSCocoaErrorDomain || failure.code != NSUserCancelledError {
wallpaperError = error.localizedDescription
}
}
}
private func state(_ value: String) -> Bool? {
value == "unknown" ? nil : value == "on"
}
private var sample: Status {
Status(mode: mode == "unknown" ? nil : mode, tun: state(tun), proxy: state(proxy),
error: error == "none" ? nil : "widget." + error, language: language)
}
@ViewBuilder private var background: some View {
switch backdrop {
case "wallpaper":
if let wallpaper {
GeometryReader { geometry in
Image(nsImage: wallpaper).resizable().scaledToFill()
.frame(width: geometry.size.width, height: geometry.size.height).clipped()
}
}
case "light": Color(white: 0.92)
case "dark": Color(white: 0.1)
case "pattern":
Canvas { context, _ in
for row in 0..<8 {
for column in 0..<14 {
let rect = CGRect(x: CGFloat(column) * 32, y: CGFloat(row) * 32, width: 32, height: 32)
context.fill(Path(rect), with: .color((row + column).isMultiple(of: 2) ? .white : .black))
}
}
}
default:
LinearGradient(colors: [.blue, .purple, .orange], startPoint: .topLeading, endPoint: .bottomTrailing)
}
}
var body: some View {
VStack(spacing: 12) {
ZStack {
background
RoutingView(
entry: Entry(date: .now, status: sample),
previewFamily: medium ? .systemMedium : .systemSmall,
previewRenderingMode: renderingOverride,
previewMaterial: material,
previewIncreaseContrast: increaseContrast,
previewReduceMotion: reduceMotion,
previewReduceTransparency: reduceTransparency
)
.environment(\.colorScheme, dark ? .dark : .light)
.allowsHitTesting(false)
}
.frame(height: 218)
.clipShape(RoundedRectangle(cornerRadius: 12))
ScrollView {
VStack(alignment: .leading, spacing: 10) {
HStack {
Toggle("Medium", isOn: $medium)
Toggle("Dark", isOn: $dark)
}
Picker("Background", selection: $backdrop) {
Text("Light").tag("light")
Text("Dark").tag("dark")
Text("Gradient").tag("gradient")
Text("Contrast pattern").tag("pattern")
if wallpaper != nil { Text("Selected wallpaper").tag("wallpaper") }
}
HStack {
Button("Choose wallpaper…") { importingWallpaper = true }
if wallpaper != nil {
Text(wallpaperName).lineLimit(1).truncationMode(.middle)
Button("Clear") {
wallpaper = nil; wallpaperName = ""; wallpaperError = nil
if backdrop == "wallpaper" { backdrop = "gradient" }
}
}
}
if let wallpaperError { Text(wallpaperError).font(.caption).foregroundStyle(.red) }
Picker("Selected material", selection: $material) {
Text("Automatic").tag("automatic")
Text("Thin material").tag("thin")
if #available(macOS 26.0, *) { Text("Liquid Glass").tag("glass") }
}
Text("Selected material applies only in full-color rendering with Reduce transparency off.")
.font(.caption).foregroundStyle(.secondary)
Picker("Rendering input (approximate)", selection: $rendering) {
Text("Automatic").tag("automatic")
Text("Full color").tag("fullColor")
Text("Accented").tag("accented")
Text("Vibrant").tag("vibrant")
}
Toggle("Increase contrast (approximate)", isOn: $increaseContrast)
Picker("Language", selection: $language) {
Text("English").tag("en")
Text("简体中文").tag("zh")
}
Picker("Mode", selection: $mode) {
Text("Rule").tag("rule")
Text("Global").tag("global")
Text("Direct").tag("direct")
Text("Unknown").tag("unknown")
}
.pickerStyle(.segmented)
HStack {
Picker("TUN", selection: $tun) {
Text("On").tag("on"); Text("Off").tag("off"); Text("Unknown").tag("unknown")
}
Picker("Proxy", selection: $proxy) {
Text("On").tag("on"); Text("Off").tag("off"); Text("Unknown").tag("unknown")
}
}
Picker("Error", selection: $error) {
Text("None").tag("none")
Text("Offline").tag("unavailable")
Text("Failed").tag("failed")
}
Toggle("Reduce transparency", isOn: $reduceTransparency)
Toggle("Reduce motion", isOn: $reduceMotion)
Text(
"Style only. Controls change sample data; no proxy requests. "
+ "Desktop WidgetKit compositing is not simulated."
)
.font(.caption).foregroundStyle(.secondary)
}
.padding(4)
}
}
.padding(16)
.frame(width: 440, height: 570)
.fileImporter(isPresented: $importingWallpaper, allowedContentTypes: [.image], onCompletion: importWallpaper)
}
}
#Preview("Style control panel") {
RoutingStylePreview()
}
#Preview("Medium · 中文 · Dark") {
RoutingStylePreview(medium: true, dark: true, language: "zh", mode: "global")
}
#Preview("Small · English · Reduced transparency") {
RoutingStylePreview(language: "en", mode: "direct", reduceTransparency: true)
}
#Preview("中文 · Unknown · Offline") {
RoutingStylePreview(
medium: true, dark: true, language: "zh", mode: "unknown",
tun: "unknown", proxy: "unknown", error: "unavailable"
)
}
#endif
@@ -0,0 +1,65 @@
import AppIntents
import SwiftUI
import WidgetKit
import Foundation
struct SetRouting: AppIntent {
static let title: LocalizedStringResource = "widget.update"
static let openAppWhenRun = false
@Parameter(title: "widget.setting") var action: String
@Parameter(title: "widget.value") var value: String
init() {}
init(_ action: String, _ value: String) { self.action = action; self.value = value }
func perform() async throws -> some IntentResult {
let status = await Task.detached { exchange(action: action, value: value.isEmpty ? nil : value) }.value
if let error = status.error {
WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
throw WidgetFailure(message: localized(error, language: status.language))
}
return .result()
}
}
struct WidgetFailure: LocalizedError {
let message: String
var errorDescription: String? { message }
}
struct Entry: TimelineEntry {
var date: Date
var status: Status
}
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> Entry {
Entry(date: .now, status: Status(error: "widget.unavailable"))
}
func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void) {
if context.isPreview {
completion(Entry(date: .now, status: Status(mode: "rule", tun: true, proxy: false)))
} else {
getTimeline(in: context) { completion($0.entries[0]) }
}
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
DispatchQueue.global().async {
let status = exchange()
let now = Date()
let unavailable = status.mode == nil || status.tun == nil || status.proxy == nil
let refresh = now.addingTimeInterval(unavailable ? 60 : 900)
completion(Timeline(entries: [Entry(date: now, status: status)], policy: .after(refresh)))
}
}
}
#if !PREVIEW_HOST
@main
#endif
struct RoutingWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: widgetKind, provider: Provider()) { RoutingView(entry: $0) }
.configurationDisplayName("widget.name")
.description("widget.description")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
@@ -0,0 +1,83 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {};
objectVersion = 56;
objects = {
A00000000000000000000001 = {isa = PBXProject; buildConfigurationList = A00000000000000000000002; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; knownRegions = (en, Base); mainGroup = A00000000000000000000003; productRefGroup = A00000000000000000000004; projectDirPath = ""; projectRoot = ""; targets = (A00000000000000000000005, A00000000000000000000020); };
A00000000000000000000002 = {isa = XCConfigurationList; buildConfigurations = (A00000000000000000000006, A00000000000000000000010); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; };
A00000000000000000000003 = {isa = PBXGroup; children = (A00000000000000000000007, A00000000000000000000008, A00000000000000000000021, A00000000000000000000030, A00000000000000000000040, A00000000000000000000041, A00000000000000000000004); sourceTree = "<group>"; };
A00000000000000000000004 = {isa = PBXGroup; children = (A00000000000000000000009, A00000000000000000000022); name = Products; sourceTree = "<group>"; };
A00000000000000000000005 = {isa = PBXNativeTarget; buildConfigurationList = A0000000000000000000000A; buildPhases = (A0000000000000000000000B, A0000000000000000000000C, A00000000000000000000012); buildRules = (); dependencies = (); name = VergeWidget; productName = VergeWidget; productReference = A00000000000000000000009; productType = "com.apple.product-type.app-extension"; };
A00000000000000000000006 = {isa = XCBuildConfiguration; name = Release; buildSettings = {MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0; }; };
A00000000000000000000007 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Widget.swift; sourceTree = "<group>"; };
A00000000000000000000008 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bridge.swift; sourceTree = "<group>"; };
A00000000000000000000009 = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; path = VergeWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
A0000000000000000000000A = {isa = XCConfigurationList; buildConfigurations = (A0000000000000000000000D, A00000000000000000000011); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; };
A0000000000000000000000B = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A0000000000000000000000E, A0000000000000000000000F, A00000000000000000000042, A00000000000000000000043); runOnlyForDeploymentPostprocessing = 0; };
A0000000000000000000000C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
A0000000000000000000000D = {isa = XCBuildConfiguration; name = Release; buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = "$(WIDGET_ENTITLEMENTS)";
CODE_SIGN_STYLE = Manual;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "$(WIDGET_INFO_PLIST)";
WIDGET_INFO_PLIST = "$(SRCROOT)/Preview.plist";
WIDGET_ENTITLEMENTS = "";
ENABLE_USER_SCRIPT_SANDBOXING = NO;
LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks");
PRODUCT_BUNDLE_IDENTIFIER = "io.github.clash-verge-rev.clash-verge-rev.widget";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = WIDGET_EXTENSION;
SWIFT_EMIT_LOC_STRINGS = YES;
}; };
A00000000000000000000011 = {isa = XCBuildConfiguration; name = Debug; buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = "$(WIDGET_ENTITLEMENTS)";
CODE_SIGN_STYLE = Manual;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "$(WIDGET_INFO_PLIST)";
WIDGET_INFO_PLIST = "$(SRCROOT)/Preview.plist";
WIDGET_ENTITLEMENTS = "";
ENABLE_USER_SCRIPT_SANDBOXING = NO;
LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks");
PRODUCT_BUNDLE_IDENTIFIER = "io.github.clash-verge-rev.clash-verge-rev.preview.widget";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "WIDGET_EXTENSION DEBUG";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
CODE_SIGN_IDENTITY = "-";
ENABLE_PREVIEWS = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
}; };
A0000000000000000000000E = {isa = PBXBuildFile; fileRef = A00000000000000000000007; };
A0000000000000000000000F = {isa = PBXBuildFile; fileRef = A00000000000000000000008; };
A00000000000000000000010 = {isa = XCBuildConfiguration; name = Debug; buildSettings = {MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0; }; };
A00000000000000000000012 = {isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = (); inputPaths = (); outputPaths = (); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; alwaysOutOfDate = 1; shellScript = "export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH\nnode \"$SRCROOT/../build/localize.mjs\" \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH\"\n"; };
A00000000000000000000020 = {isa = PBXNativeTarget; buildConfigurationList = A00000000000000000000023; buildPhases = (A00000000000000000000024, A00000000000000000000025, A00000000000000000000026, A00000000000000000000034); buildRules = (); dependencies = (A00000000000000000000027); name = PreviewHost; productName = PreviewHost; productReference = A00000000000000000000022; productType = "com.apple.product-type.application"; };
A00000000000000000000021 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Host.swift; sourceTree = "<group>"; };
A00000000000000000000022 = {isa = PBXFileReference; explicitFileType = wrapper.application; path = PreviewHost.app; sourceTree = BUILT_PRODUCTS_DIR; };
A00000000000000000000023 = {isa = XCConfigurationList; buildConfigurations = (A00000000000000000000028); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; };
A00000000000000000000024 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000029, A00000000000000000000031, A00000000000000000000032, A00000000000000000000033, A00000000000000000000044, A00000000000000000000045); runOnlyForDeploymentPostprocessing = 0; };
A00000000000000000000025 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
A00000000000000000000026 = {isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = (A0000000000000000000002A); name = "Embed Widget"; runOnlyForDeploymentPostprocessing = 0; };
A00000000000000000000027 = {isa = PBXTargetDependency; target = A00000000000000000000005; targetProxy = A0000000000000000000002B; };
A00000000000000000000028 = {isa = XCBuildConfiguration; name = Debug; buildSettings = {CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Widget Preview Host"; PRODUCT_BUNDLE_IDENTIFIER = "io.github.clash-verge-rev.clash-verge-rev.preview"; PRODUCT_NAME = "$(TARGET_NAME)"; MARKETING_VERSION = 1.0; CURRENT_PROJECT_VERSION = 1; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG PREVIEW_HOST WIDGET_EXTENSION"; ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_PREVIEWS = YES; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks"); }; };
A00000000000000000000029 = {isa = PBXBuildFile; fileRef = A00000000000000000000021; };
A0000000000000000000002A = {isa = PBXBuildFile; fileRef = A00000000000000000000009; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy); }; };
A0000000000000000000002B = {isa = PBXContainerItemProxy; containerPortal = A00000000000000000000001; proxyType = 1; remoteGlobalIDString = A00000000000000000000005; remoteInfo = VergeWidget; };
A00000000000000000000030 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StylePreviews.swift; sourceTree = "<group>"; };
A00000000000000000000031 = {isa = PBXBuildFile; fileRef = A00000000000000000000007; };
A00000000000000000000032 = {isa = PBXBuildFile; fileRef = A00000000000000000000008; };
A00000000000000000000033 = {isa = PBXBuildFile; fileRef = A00000000000000000000030; };
A00000000000000000000034 = {isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = (); inputPaths = (); outputPaths = (); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; alwaysOutOfDate = 1; shellScript = "export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH\nnode \"$SRCROOT/../build/localize.mjs\" \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH\"\n"; };
A00000000000000000000040 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetClient.swift; sourceTree = "<group>"; };
A00000000000000000000041 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutingView.swift; sourceTree = "<group>"; };
A00000000000000000000042 = {isa = PBXBuildFile; fileRef = A00000000000000000000040; };
A00000000000000000000043 = {isa = PBXBuildFile; fileRef = A00000000000000000000041; };
A00000000000000000000044 = {isa = PBXBuildFile; fileRef = A00000000000000000000040; };
A00000000000000000000045 = {isa = PBXBuildFile; fileRef = A00000000000000000000041; };
};
rootObject = A00000000000000000000001;
}
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A00000000000000000000020"
BuildableName = "PreviewHost.app"
BlueprintName = "PreviewHost"
ReferencedContainer = "container:Widget.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "NO">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A00000000000000000000020"
BuildableName = "PreviewHost.app"
BlueprintName = "PreviewHost"
ReferencedContainer = "container:Widget.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Debug"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A00000000000000000000020"
BuildableName = "PreviewHost.app"
BlueprintName = "PreviewHost"
ReferencedContainer = "container:Widget.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,104 @@
import Darwin
import Foundation
struct Status: Codable {
var mode: String?
var tun: Bool?
var proxy: Bool?
var error: String?
var language: String?
}
func localized(_ key: String, language: String? = nil) -> String {
let code = language.map { ["jp": "ja", "zh": "zh-Hans", "zhtw": "zh-Hant"][$0] ?? $0 }
let path = code.flatMap { Bundle.main.path(forResource: $0, ofType: "lproj") }
let bundle = path.flatMap(Bundle.init(path:)) ?? .main
return bundle.localizedString(forKey: key, value: nil, table: nil)
}
private func connectSocket(_ path: String) -> Int32? {
let descriptor = socket(AF_UNIX, SOCK_STREAM, 0)
guard descriptor >= 0 else { return nil }
var timeout = timeval(tv_sec: 8, tv_usec: 0)
var noSignal: Int32 = 1
setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout)))
setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout)))
setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size(ofValue: noSignal)))
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
path.withCString { source in
withUnsafeMutableBytes(of: &address.sun_path) { bytes in
bytes.copyMemory(from: UnsafeRawBufferPointer(start: source, count: path.utf8.count + 1))
}
}
let connected = withUnsafePointer(to: &address) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
connect(descriptor, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard connected == 0 else {
close(descriptor)
return nil
}
return descriptor
}
private func sendRequest(_ data: Data, descriptor: Int32) -> Bool {
data.withUnsafeBytes { bytes in
var offset = 0
while offset < bytes.count {
let count = write(descriptor, bytes.baseAddress!.advanced(by: offset), bytes.count - offset)
if count < 0 && errno == EINTR { continue }
guard count > 0 else { return false }
offset += count
}
return true
}
}
private func readResponse(descriptor: Int32) -> Status? {
var response = Data(count: 4096)
var received = 0
while received < 4096 {
let count = response.withUnsafeMutableBytes { bytes in
read(descriptor, bytes.baseAddress!.advanced(by: received), bytes.count - received)
}
if count < 0 && errno == EINTR { continue }
guard count > 0 else { return nil }
let end = received + count
if let newline = response[received..<end].firstIndex(of: 10) {
response.count = newline
return try? JSONDecoder().decode(Status.self, from: response)
}
received = end
}
return nil
}
func exchange(action: String = "status", value: String? = nil) -> Status {
#if PREVIEW_HOST
return Status(mode: "rule", tun: true, proxy: false, language: "en")
#else
#if DEBUG
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
return Status(mode: "rule", tun: true, proxy: false, language: "en")
}
#endif
func unavailable(_ key: String) -> Status { Status(error: key) }
guard let path = socketPath(), path.utf8.count < 104 else {
return unavailable("widget.unavailable")
}
guard let descriptor = connectSocket(path) else { return unavailable("widget.unavailable") }
defer { close(descriptor) }
guard authenticPeer(descriptor, identifier: hostID) else { return unavailable("widget.unavailable") }
var request = ["action": action]
if let value { request["value"] = value }
guard var data = try? JSONSerialization.data(withJSONObject: request) else {
return unavailable("widget.failed")
}
data.append(10)
guard sendRequest(data, descriptor: descriptor) else { return unavailable("widget.failed") }
return readResponse(descriptor: descriptor) ?? unavailable("widget.failed")
#endif
}
+57
View File
@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
use std::future::Future;
#[cfg(target_os = "macos")]
mod server;
#[cfg(target_os = "macos")]
pub use server::{reload, start};
#[derive(Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Rule,
Global,
Direct,
}
impl Mode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Rule => "rule",
Self::Global => "global",
Self::Direct => "direct",
}
}
}
#[derive(Clone, Copy)]
pub enum Action {
Mode(Mode),
Tun(bool),
Proxy(bool),
}
#[derive(Default, Serialize)]
pub struct Status {
pub mode: Option<Mode>,
pub tun: Option<bool>,
pub proxy: Option<bool>,
pub error: Option<String>,
pub language: String,
}
impl Status {
pub fn confirms(&self, action: Action) -> bool {
match action {
Action::Mode(value) => self.mode == Some(value),
Action::Tun(value) => self.tun == Some(value),
Action::Proxy(value) => self.proxy == Some(value),
}
}
}
pub trait Backend: Send + Sync + 'static {
fn snapshot(&self) -> impl Future<Output = Status> + Send;
fn language(&self) -> impl Future<Output = String> + Send;
fn apply(&self, action: Action) -> impl Future<Output = Result<(), String>> + Send;
}
+219
View File
@@ -0,0 +1,219 @@
use crate::{Action, Backend, Mode, Status};
use serde::Deserialize;
use std::{
borrow::Cow,
ffi::{CStr, CString},
fs, mem,
os::fd::AsRawFd as _,
os::unix::fs::PermissionsExt as _,
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::{
io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader},
net::UnixListener,
time::timeout,
};
struct Native {
path: unsafe extern "C" fn() -> *mut libc::c_char,
authenticate: unsafe extern "C" fn(i32) -> bool,
reload: unsafe extern "C" fn(),
}
static NATIVE: OnceLock<Native> = OnceLock::new();
fn load_native() -> anyhow::Result<Native> {
let executable = std::env::current_exe()?;
let contents = executable
.parent()
.and_then(|p| p.parent())
.ok_or_else(|| anyhow::anyhow!("Not an app bundle"))?;
let path = CString::new(
contents
.join("Frameworks/VergeWidgetBridge.dylib")
.as_os_str()
.as_encoded_bytes(),
)?;
unsafe {
let library = libc::dlopen(path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL);
anyhow::ensure!(!library.is_null(), "Widget bridge unavailable");
let path = libc::dlsym(library, c"verge_widget_path".as_ptr());
let authenticate = libc::dlsym(library, c"verge_widget_authenticate".as_ptr());
let reload = libc::dlsym(library, c"verge_widget_reload".as_ptr());
anyhow::ensure!(
!path.is_null() && !authenticate.is_null() && !reload.is_null(),
"Invalid widget bridge"
);
Ok(Native {
path: mem::transmute::<*mut libc::c_void, unsafe extern "C" fn() -> *mut libc::c_char>(path),
authenticate: mem::transmute::<*mut libc::c_void, unsafe extern "C" fn(i32) -> bool>(authenticate),
reload: mem::transmute::<*mut libc::c_void, unsafe extern "C" fn()>(reload),
})
}
}
static MUTATING: AtomicBool = AtomicBool::new(false);
pub fn reload() {
if MUTATING.load(Ordering::Acquire) {
return;
}
if let Some(native) = NATIVE.get() {
unsafe { (native.reload)() };
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request<'a> {
#[serde(borrow)]
action: Cow<'a, str>,
#[serde(borrow)]
value: Option<RequestValue<'a>>,
}
#[derive(Deserialize)]
#[serde(transparent)]
struct RequestValue<'a>(#[serde(borrow)] Cow<'a, str>);
impl Request<'_> {
fn action(&self) -> Result<Option<Action>, &'static str> {
match (self.action.as_ref(), self.value.as_ref().map(|value| value.0.as_ref())) {
("status", None) => Ok(None),
("mode", Some("rule")) => Ok(Some(Action::Mode(Mode::Rule))),
("mode", Some("global")) => Ok(Some(Action::Mode(Mode::Global))),
("mode", Some("direct")) => Ok(Some(Action::Mode(Mode::Direct))),
("tun", Some(value @ ("true" | "false"))) => Ok(Some(Action::Tun(value == "true"))),
("proxy", Some(value @ ("true" | "false"))) => Ok(Some(Action::Proxy(value == "true"))),
_ => Err("Invalid widget request"),
}
}
}
async fn serve(backend: impl Backend) -> anyhow::Result<()> {
let native = load_native()?;
let raw = unsafe { (native.path)() };
anyhow::ensure!(!raw.is_null(), "App Group container unavailable");
let path = unsafe { CStr::from_ptr(raw).to_string_lossy().into_owned() };
unsafe { libc::free(raw.cast()) };
// A second process must not unlink the live app's listener.
if tokio::net::UnixStream::connect(&path).await.is_ok() {
anyhow::bail!("Widget listener already running");
}
match fs::remove_file(&path) {
Ok(()) => (),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
Err(error) => return Err(error.into()),
}
let listener = UnixListener::bind(&path)?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
let _ = NATIVE.set(native);
reload();
let mut last_error = None;
let mut pending = None;
loop {
let (stream, _) = listener.accept().await?;
let Some(native) = NATIVE.get() else { break };
if !unsafe { (native.authenticate)(stream.as_raw_fd()) } {
continue;
}
// Serialize widget mutations; never cancel a backend transaction on client timeout.
let mut stream = BufReader::new(stream);
let mut line = Vec::new();
let read = timeout(
Duration::from_secs(2),
(&mut stream).take(1024).read_until(b'\n', &mut line),
)
.await;
if !matches!(read, Ok(Ok(_))) || line.last() != Some(&b'\n') {
continue;
}
let request = match serde_json::from_slice::<Request>(&line) {
Ok(request) => request,
Err(_) => continue,
};
let action = match request.action() {
Ok(action) => action,
Err(_) => continue,
};
let changed = action.is_some();
// Coalesce hooks across the whole transaction, including errors and disconnected clients.
MUTATING.store(changed, Ordering::Release);
let refresh = scopeguard::guard(changed, |changed| {
MUTATING.store(false, Ordering::Release);
if changed {
reload();
}
});
// Mutations reply without re-reading state so the widget repaints one round trip
// sooner; the status read after the reload confirms the pending action.
let mut reply = match action {
Some(action) => {
match backend.apply(action).await {
Ok(()) => {
last_error = None;
pending = Some(action);
}
Err(error) => {
pending = None;
last_error = Some(error);
}
}
Status::default()
}
None => {
let state = backend.snapshot().await;
if let Some(action) = pending.take()
&& !state.confirms(action)
{
last_error = Some("widget.failed".into());
}
state
}
};
if action.is_some() {
reply.language = backend.language().await;
}
reply.error = last_error.take();
let serialized = serde_json::to_vec(&reply);
last_error = reply.error.take();
let mut bytes = serialized?;
bytes.push(b'\n');
let _ = timeout(Duration::from_secs(2), stream.get_mut().write_all(&bytes)).await;
drop(refresh);
}
Ok(())
}
pub fn start(backend: impl Backend) {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::Relaxed) {
return;
}
// Darwin 23 is macOS 14; older hosts must never load the WidgetKit bridge.
let mut version = [0u8; 64];
let mut length = version.len();
let result = unsafe {
libc::sysctlbyname(
c"kern.osrelease".as_ptr(),
version.as_mut_ptr().cast(),
&mut length,
std::ptr::null_mut(),
0,
)
};
let major = std::str::from_utf8(&version)
.ok()
.and_then(|v| v.split('.').next()?.parse::<u32>().ok());
if result != 0 || major.is_none_or(|major| major < 23) {
return;
}
tokio::spawn(async move {
if let Err(error) = serve(backend).await {
log::warn!("Widget unavailable: {error:#}");
}
});
}
+2 -1
View File
@@ -43,6 +43,7 @@ export default defineConfig([
'eslint.config.ts',
`vite.config.mts`,
'scripts/*.mjs',
'crates/clash-verge-widget/build/*.mjs',
'tests/*.ts',
'src/polyfills/*.js',
'tests/*.mjs',
@@ -143,7 +144,7 @@ export default defineConfig([
},
},
{
files: ['scripts/*.mjs'],
files: ['scripts/*.mjs', 'crates/clash-verge-widget/build/*.mjs'],
languageOptions: {
globals: {
+2 -2
View File
@@ -10,8 +10,8 @@
"dev:diff": "node scripts/dev.mjs dev",
"dev:trace": "node scripts/dev.mjs trace",
"dev:tauri": "cross-env RUST_BACKTRACE=full tauri dev -f tauri-dev",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
"build": "node scripts/build.mjs",
"build:fast": "node scripts/build.mjs -- --profile fast-release",
"tauri": "tauri",
"web:dev": "vite",
"web:build": "tsc --noEmit && vite build",
+62
View File
@@ -0,0 +1,62 @@
import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const root = join(import.meta.dirname, '..')
const forward = () => {
const tauri = spawnSync(
'pnpm',
['exec', 'tauri', 'build', ...process.argv.slice(2)],
{
cwd: root,
stdio: 'inherit',
env: {
...process.env,
NODE_OPTIONS: process.env.NODE_OPTIONS || '--max-old-space-size=4096',
},
shell: process.platform === 'win32',
},
)
process.exit(tauri.status ?? 1)
}
if (process.platform !== 'darwin') forward()
if (process.env.CLASH_VERGE_WIDGET === '0') {
console.warn(
'CLASH_VERGE_WIDGET=0: building without the desktop widget extension.',
)
process.env.APPLE_SIGNING_IDENTITY = '-'
forward()
}
const run = (command, args) => {
const result = spawnSync(command, args, { encoding: 'utf8' })
if (result.error) return ''
return result.stdout
}
let identity = process.env.APPLE_SIGNING_IDENTITY
if (!identity) {
const mac = JSON.parse(
readFileSync(join(root, 'src-tauri/tauri.macos.conf.json'), 'utf8'),
)
identity = mac.bundle?.macOS?.signingIdentity || undefined
}
if (!identity) {
const identities = [
...run('security', ['find-identity', '-v', '-p', 'codesigning']).matchAll(
/\b([A-F0-9]{40}) "([^"]+)"/g,
),
]
if (identities.length === 1) identity = identities[0][2]
}
if (!identity) {
console.error(
'macOS packaging needs the widget extension signed with the host: set APPLE_SIGNING_IDENTITY to a valid certificate, or export CLASH_VERGE_WIDGET=0 to build without the widget.',
)
process.exit(1)
}
process.env.APPLE_SIGNING_IDENTITY = identity
forward()
+1 -1
View File
@@ -49,7 +49,7 @@ const FRONTEND_EXTENSIONS = new Set([
'.vue',
'.json',
])
const BACKEND_EXTENSIONS = new Set(['.rs'])
const BACKEND_EXTENSIONS = new Set(['.rs', '.swift'])
const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
+1
View File
@@ -114,6 +114,7 @@ tokio-util = { version = "0.7.19", features = ["time"] }
libc = "0.2.189"
[target.'cfg(target_os = "macos")'.dependencies]
clash-verge-widget = { workspace = true }
tauri-plugin-wdio-webdriver = { version = "=1.3.0", optional = true }
core-foundation = "0.9"
system-configuration = "0.7"
+6
View File
@@ -36,10 +36,14 @@ impl Handle {
}
pub fn refresh_clash() {
#[cfg(target_os = "macos")]
clash_verge_widget::reload();
Self::send_event(FrontendEvent::RefreshClash);
}
pub fn refresh_verge() {
#[cfg(target_os = "macos")]
clash_verge_widget::reload();
Self::send_event(FrontendEvent::RefreshVerge);
}
@@ -56,6 +60,8 @@ impl Handle {
/// Sent on every transition, so the frontend does not have to poll to notice that the Core
/// stopped or that the Service came back.
pub fn notify_run_state(state: &crate::core::runstate::RunStateView) {
#[cfg(target_os = "macos")]
clash_verge_widget::reload();
let Ok(state) = serde_json::to_value(state) else {
return;
};
+2
View File
@@ -19,6 +19,8 @@ pub mod timer;
pub mod tray;
pub mod updater;
pub mod validate;
#[cfg(target_os = "macos")]
pub mod widget;
pub mod win_uwp;
pub use self::{manager::CoreManager, timer::Timer, updater::SilentUpdater};
+83
View File
@@ -0,0 +1,83 @@
use crate::{cmd, config::IVerge, core::handle::Handle, feat, process::AsyncHandler};
use clash_verge_widget::{Action, Backend, Mode, Status};
use tauri_plugin_mihomo::models::ClashMode;
struct AppBackend;
impl Backend for AppBackend {
async fn snapshot(&self) -> Status {
let core = Handle::mihomo().get_base_config().await;
let proxy = {
let manual = cmd::network::get_sys_proxy()
.await
.ok()
.and_then(|v| v.get("enable").and_then(|v| v.as_bool()));
let pac = cmd::network::get_auto_proxy()
.await
.ok()
.and_then(|v| v.get("enable").and_then(|v| v.as_bool()));
match (manual, pac) {
(Some(true), _) | (_, Some(true)) => Some(true),
(Some(false), Some(false)) => Some(false),
_ => None,
}
};
let (mode, tun) = match core {
Ok(config) => (
Some(match config.mode {
ClashMode::Rule => Mode::Rule,
ClashMode::Global => Mode::Global,
ClashMode::Direct => Mode::Direct,
}),
Some(config.tun.enable),
),
_ => (None, None),
};
Status {
mode,
tun,
proxy,
error: None,
language: clash_verge_i18n::current_language(
crate::config::Config::verge().await.data_arc().language.as_deref(),
)
.into_owned(),
}
}
async fn language(&self) -> String {
clash_verge_i18n::current_language(crate::config::Config::verge().await.data_arc().language.as_deref())
.into_owned()
}
async fn apply(&self, action: Action) -> Result<(), String> {
if Handle::global().is_exiting() {
return Err("widget.unavailable".into());
}
match action {
Action::Mode(mode) => feat::change_clash_mode(mode.as_str().into())
.await
.map_err(|_| "widget.failed".into()),
Action::Tun(_) | Action::Proxy(_) => {
let payload = IVerge {
enable_tun_mode: match action {
Action::Tun(value) => Some(value),
_ => None,
},
enable_system_proxy: match action {
Action::Proxy(value) => Some(value),
_ => None,
},
..Default::default()
};
cmd::verge::patch_verge_config(payload)
.await
.map_err(|_| "widget.failed".into())
}
}
}
}
pub fn start() {
AsyncHandler::spawn(|| async { clash_verge_widget::start(AppBackend) });
}
+2
View File
@@ -341,6 +341,8 @@ pub fn run() -> std::process::ExitCode {
logging!(info, Type::System, "应用就绪");
crate::utils::server::set_commands_ready();
#[cfg(target_os = "macos")]
crate::core::widget::start();
#[cfg(target_os = "macos")]
if let Some(window) = _app_handle.get_webview_window("main") {
+15
View File
@@ -8,6 +8,8 @@ use crate::{
core::{CoreManager, handle, timer::Timer},
utils::help,
};
#[cfg(target_os = "macos")]
use crate::{module::lightweight, utils::window_manager::WindowManager};
use clash_verge_logging::{Type, logging, logging_error};
pub(super) async fn resolve_scheme(param: &str) -> Result<()> {
@@ -25,6 +27,19 @@ pub(super) async fn resolve_scheme(param: &str) -> Result<()> {
let link_parsed = Url::parse(param_str)
.map_err(|e| anyhow::anyhow!("failed to parse deep link: {e:?}, param: {masked_deep_link}"))?;
// Dedicated "raise the window" route (clash-verge://show) for surfaces that
// activate a tray-only host without firing the macOS reopen event.
#[cfg(target_os = "macos")]
if link_parsed.host_str() == Some("show") {
if lightweight::is_in_lightweight_mode() {
lightweight::exit_lightweight_mode().await;
} else {
handle::Handle::global().set_activation_policy_regular();
WindowManager::show_main_window().await;
}
return Ok(());
}
let Some((url, name)) = extract_subscription_info(&link_parsed) else {
logging!(
warn,
+9 -3
View File
@@ -2,14 +2,20 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"identifier": "io.github.clash-verge-rev.clash-verge-rev",
"productName": "Clash Verge",
"build": {
"beforeBundleCommand": "node crates/clash-verge-widget/build/bundle.mjs"
},
"bundle": {
"targets": ["app", "dmg"],
"macOS": {
"frameworks": [],
"frameworks": ["generated/widget/VergeWidgetBridge.dylib"],
"files": {
"PlugIns/VergeWidget.appex": "generated/widget/VergeWidget.appex"
},
"minimumSystemVersion": "11.0",
"exceptionDomain": "",
"signingIdentity": null,
"entitlements": "packages/macos/entitlements.plist",
"entitlements": "generated/widget/Host.entitlements",
"dmg": {
"background": "images/background.png",
"appPosition": {
@@ -29,7 +35,7 @@
"y": 180
}
},
"infoPlist": "packages/macos/info_merge.plist"
"infoPlist": "generated/widget/Host.plist"
}
}
}