diff --git a/.gitignore b/.gitignore index f783f4549..e773322c1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ target .vfox .claude .zed +/src-tauri/generated/widget/ diff --git a/Cargo.lock b/Cargo.lock index f5eea2c36..aa009fcb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 527258615..a056c00e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/clash-verge-i18n/locales/ar.yml b/crates/clash-verge-i18n/locales/ar.yml index c267deedd..171e548f8 100644 --- a/crates/clash-verge-i18n/locales/ar.yml +++ b/crates/clash-verge-i18n/locales/ar.yml @@ -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: القيمة diff --git a/crates/clash-verge-i18n/locales/de.yml b/crates/clash-verge-i18n/locales/de.yml index 5d6d8077b..646498e68 100644 --- a/crates/clash-verge-i18n/locales/de.yml +++ b/crates/clash-verge-i18n/locales/de.yml @@ -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 diff --git a/crates/clash-verge-i18n/locales/en.yml b/crates/clash-verge-i18n/locales/en.yml index c028f0a37..a1d1a0bd7 100644 --- a/crates/clash-verge-i18n/locales/en.yml +++ b/crates/clash-verge-i18n/locales/en.yml @@ -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 diff --git a/crates/clash-verge-i18n/locales/es.yml b/crates/clash-verge-i18n/locales/es.yml index 213e8cfca..bb16da57a 100644 --- a/crates/clash-verge-i18n/locales/es.yml +++ b/crates/clash-verge-i18n/locales/es.yml @@ -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 diff --git a/crates/clash-verge-i18n/locales/fa.yml b/crates/clash-verge-i18n/locales/fa.yml index f13f7bdb8..447bcd3d1 100644 --- a/crates/clash-verge-i18n/locales/fa.yml +++ b/crates/clash-verge-i18n/locales/fa.yml @@ -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: مقدار diff --git a/crates/clash-verge-i18n/locales/id.yml b/crates/clash-verge-i18n/locales/id.yml index 84e3b2dec..10332a540 100644 --- a/crates/clash-verge-i18n/locales/id.yml +++ b/crates/clash-verge-i18n/locales/id.yml @@ -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 diff --git a/crates/clash-verge-i18n/locales/jp.yml b/crates/clash-verge-i18n/locales/jp.yml index 36dbf276b..9477ff09e 100644 --- a/crates/clash-verge-i18n/locales/jp.yml +++ b/crates/clash-verge-i18n/locales/jp.yml @@ -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: 値 diff --git a/crates/clash-verge-i18n/locales/ko.yml b/crates/clash-verge-i18n/locales/ko.yml index daf96ba4a..402e80905 100644 --- a/crates/clash-verge-i18n/locales/ko.yml +++ b/crates/clash-verge-i18n/locales/ko.yml @@ -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: 값 diff --git a/crates/clash-verge-i18n/locales/ru.yml b/crates/clash-verge-i18n/locales/ru.yml index b2699cf6f..6a241e424 100644 --- a/crates/clash-verge-i18n/locales/ru.yml +++ b/crates/clash-verge-i18n/locales/ru.yml @@ -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: Значение diff --git a/crates/clash-verge-i18n/locales/tr.yml b/crates/clash-verge-i18n/locales/tr.yml index 630d156fd..f42371d37 100644 --- a/crates/clash-verge-i18n/locales/tr.yml +++ b/crates/clash-verge-i18n/locales/tr.yml @@ -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 diff --git a/crates/clash-verge-i18n/locales/tt.yml b/crates/clash-verge-i18n/locales/tt.yml index b7a5ef0f6..3b19a4cc1 100644 --- a/crates/clash-verge-i18n/locales/tt.yml +++ b/crates/clash-verge-i18n/locales/tt.yml @@ -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: Кыйммәт diff --git a/crates/clash-verge-i18n/locales/zh.yml b/crates/clash-verge-i18n/locales/zh.yml index 49c1203ce..b084329dd 100644 --- a/crates/clash-verge-i18n/locales/zh.yml +++ b/crates/clash-verge-i18n/locales/zh.yml @@ -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: 值 diff --git a/crates/clash-verge-i18n/locales/zhtw.yml b/crates/clash-verge-i18n/locales/zhtw.yml index 6ea5d9c59..78880fcd5 100644 --- a/crates/clash-verge-i18n/locales/zhtw.yml +++ b/crates/clash-verge-i18n/locales/zhtw.yml @@ -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: 值 diff --git a/crates/clash-verge-widget/.gitignore b/crates/clash-verge-widget/.gitignore new file mode 100644 index 000000000..cf3688fdc --- /dev/null +++ b/crates/clash-verge-widget/.gitignore @@ -0,0 +1,3 @@ +# Xcode per-user state and local build output +xcuserdata/ +DerivedData/ diff --git a/crates/clash-verge-widget/Cargo.toml b/crates/clash-verge-widget/Cargo.toml new file mode 100644 index 000000000..b8d8f072e --- /dev/null +++ b/crates/clash-verge-widget/Cargo.toml @@ -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 diff --git a/crates/clash-verge-widget/build/bundle.mjs b/crates/clash-verge-widget/build/bundle.mjs new file mode 100644 index 000000000..5b5f3cf7d --- /dev/null +++ b/crates/clash-verge-widget/build/bundle.mjs @@ -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 }) +} diff --git a/crates/clash-verge-widget/build/localize.mjs b/crates/clash-verge-widget/build/localize.mjs new file mode 100644 index 000000000..2221c7c80 --- /dev/null +++ b/crates/clash-verge-widget/build/localize.mjs @@ -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'), + ) +} diff --git a/crates/clash-verge-widget/native/Bridge.swift b/crates/clash-verge-widget/native/Bridge.swift new file mode 100644 index 000000000..0569ea3ec --- /dev/null +++ b/crates/clash-verge-widget/native/Bridge.swift @@ -0,0 +1,48 @@ +import Foundation +import Security +import WidgetKit +import Darwin + +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, + let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else { return nil } + return container.appendingPathComponent("verge-widget.sock").path +} + +func authenticPeer(_ fd: Int32, identifier: String) -> Bool { + var token = audit_token_t() + var size = socklen_t(MemoryLayout.size(ofValue: token)) + guard getsockopt(fd, 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? + guard SecCodeCopyGuestWithAttributes(nil, [kSecGuestAttributeAudit: data] as CFDictionary, [], &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? { + socketPath().flatMap { strdup($0) } +} + +@_cdecl("verge_widget_authenticate") +public func vergeWidgetAuthenticate(_ fd: Int32) -> Bool { + authenticPeer(fd, identifier: hostID + ".widget") +} + +@_cdecl("verge_widget_reload") +public func vergeWidgetReload() { + WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) +} +#endif diff --git a/crates/clash-verge-widget/native/Host.swift b/crates/clash-verge-widget/native/Host.swift new file mode 100644 index 000000000..0c3e51c02 --- /dev/null +++ b/crates/clash-verge-widget/native/Host.swift @@ -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) + } + } +} diff --git a/crates/clash-verge-widget/native/Preview.plist b/crates/clash-verge-widget/native/Preview.plist new file mode 100644 index 000000000..7dcf1ebe6 --- /dev/null +++ b/crates/clash-verge-widget/native/Preview.plist @@ -0,0 +1,17 @@ + + + + + CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleExecutable$(EXECUTABLE_NAME) + CFBundleNameVergeWidget + CFBundlePackageTypeXPC! + CFBundleShortVersionString1.0 + CFBundleVersion1 + CFBundleDevelopmentRegionzh-Hans + LSMinimumSystemVersion14.0 + NSExtension + NSExtensionPointIdentifiercom.apple.widgetkit-extension + + + diff --git a/crates/clash-verge-widget/native/RoutingView.swift b/crates/clash-verge-widget/native/RoutingView.swift new file mode 100644 index 000000000..383ec232b --- /dev/null +++ b/crates/clash-verge-widget/native/RoutingView.swift @@ -0,0 +1,191 @@ +import SwiftUI +import WidgetKit +import Foundation + +struct RoutingView: View { + let entry: Entry + @Environment(\.widgetFamily) private var widgetFamily + #if DEBUG + var previewFamily: WidgetFamily? = nil + private var family: WidgetFamily { previewFamily ?? widgetFamily } + #else + private var family: WidgetFamily { widgetFamily } + #endif + @Environment(\.widgetRenderingMode) private var systemRenderingMode + #if DEBUG + var previewRenderingMode: WidgetRenderingMode? = nil + 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(\.accessibilityReduceMotion) private var systemReduceMotion + @Environment(\.accessibilityReduceTransparency) private var systemReduceTransparency + #if DEBUG + var previewReduceMotion: Bool? = nil + var previewReduceTransparency: Bool? = nil + 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 } + @ViewBuilder private var selectionBackground: some View { + #if DEBUG + if previewMaterial == "thin" && renderingMode == .fullColor && !reduceTransparency { + Capsule().fill(.thinMaterial) + .overlay(Capsule().strokeBorder(ink.opacity(0.15), lineWidth: 1)) + } 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) + .overlay(Capsule().strokeBorder(ink.opacity(0.15), lineWidth: 1)) + } + } + private func modeInk(selected: Bool) -> Color { + guard selected && reduceTransparency && renderingMode == .fullColor else { return ink } + return colorScheme == .dark ? .black : .white + } + private var locale: Locale { + Locale(identifier: entry.status.language.map { ["jp": "ja", "zh": "zh-Hans", "zhtw": "zh-Hant"][$0] ?? $0 } ?? Locale.current.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(.system(size: compact ? 11 : 12, weight: .medium)) + .lineLimit(compact ? 2 : 1) + .frame(height: compact ? 28 : nil, alignment: .topLeading) + } + let toggle = ZStack(alignment: .leading) { + Capsule().strokeBorder(ink.opacity(0.5), lineWidth: 1) + .background(ink.opacity(value == true ? 0.22 : 0.06), in: Capsule()) + Circle().fill(ink) + .frame(width: 12, height: 12) + .offset(x: value.map { $0 ? 18.0 : 4.0 } ?? 11) + } + .frame(width: 34, height: 20) + .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(ink) + .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 } + .overlay { + if previewIncreaseContrast { + RoundedRectangle(cornerRadius: 24).strokeBorder(ink, lineWidth: 2) + } + } + .contrast(previewIncreaseContrast ? 1.25 : 1) + .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: family == .systemSmall ? 10 : 12) { + 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(.system(size: family == .systemSmall ? 12 : 13, weight: selected ? .semibold : .regular)) + .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) + } + .background(ink.opacity(0.04), in: Capsule()) + .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 { + Text(text(error)).font(.caption2).foregroundStyle(ink.opacity(0.8)).lineLimit(2) + } + } + .environment(\.locale, locale) + .environment(\.layoutDirection, rtl ? .rightToLeft : .leftToRight) + .transaction { $0.animation = nil } + .buttonStyle(.plain) + } +} diff --git a/crates/clash-verge-widget/native/StylePreviews.swift b/crates/clash-verge-widget/native/StylePreviews.swift new file mode 100644 index 000000000..a329b819e --- /dev/null +++ b/crates/clash-verge-widget/native/StylePreviews.swift @@ -0,0 +1,192 @@ +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) { + 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, size 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 diff --git a/crates/clash-verge-widget/native/Widget.swift b/crates/clash-verge-widget/native/Widget.swift new file mode 100644 index 000000000..7c1aeb562 --- /dev/null +++ b/crates/clash-verge-widget/native/Widget.swift @@ -0,0 +1,64 @@ +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) -> Void) { + DispatchQueue.global().async { + let status = exchange() + let now = Date() + let unavailable = status.mode == nil || status.tun == nil || status.proxy == nil + completion(Timeline(entries: [Entry(date: now, status: status)], policy: .after(now.addingTimeInterval(unavailable ? 60 : 900)))) + } + } +} + +#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]) + } +} diff --git a/crates/clash-verge-widget/native/Widget.xcodeproj/project.pbxproj b/crates/clash-verge-widget/native/Widget.xcodeproj/project.pbxproj new file mode 100644 index 000000000..5b8ca3896 --- /dev/null +++ b/crates/clash-verge-widget/native/Widget.xcodeproj/project.pbxproj @@ -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 = ""; }; + A00000000000000000000004 = {isa = PBXGroup; children = (A00000000000000000000009, A00000000000000000000022); name = Products; sourceTree = ""; }; + 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 = ""; }; + A00000000000000000000008 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bridge.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + A00000000000000000000041 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutingView.swift; sourceTree = ""; }; + A00000000000000000000042 = {isa = PBXBuildFile; fileRef = A00000000000000000000040; }; + A00000000000000000000043 = {isa = PBXBuildFile; fileRef = A00000000000000000000041; }; + A00000000000000000000044 = {isa = PBXBuildFile; fileRef = A00000000000000000000040; }; + A00000000000000000000045 = {isa = PBXBuildFile; fileRef = A00000000000000000000041; }; + }; + rootObject = A00000000000000000000001; +} diff --git a/crates/clash-verge-widget/native/Widget.xcodeproj/xcshareddata/xcschemes/PreviewHost.xcscheme b/crates/clash-verge-widget/native/Widget.xcodeproj/xcshareddata/xcschemes/PreviewHost.xcscheme new file mode 100644 index 000000000..a3577aed0 --- /dev/null +++ b/crates/clash-verge-widget/native/Widget.xcodeproj/xcshareddata/xcschemes/PreviewHost.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/clash-verge-widget/native/WidgetClient.swift b/crates/clash-verge-widget/native/WidgetClient.swift new file mode 100644 index 000000000..cee1770cd --- /dev/null +++ b/crates/clash-verge-widget/native/WidgetClient.swift @@ -0,0 +1,86 @@ +import Foundation +import Darwin + +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 bundle = code.flatMap { Bundle.main.path(forResource: $0, ofType: "lproj") }.flatMap(Bundle.init(path:)) ?? .main + return bundle.localizedString(forKey: key, value: nil, table: 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") + } + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return unavailable("widget.unavailable") } + defer { close(fd) } + var timeout = timeval(tv_sec: 8, tv_usec: 0) + var noSignal: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout))) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout))) + setsockopt(fd, 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.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(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard connected == 0 else { return unavailable("widget.unavailable") } + guard authenticPeer(fd, 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) + let sent = data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let count = write(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) + if count < 0 && errno == EINTR { continue } + guard count > 0 else { return false } + offset += count + } + return true + } + guard sent else { return unavailable("widget.failed") } + var response = Data(count: 4096) + var received = 0 + while received < 4096 { + let count = response.withUnsafeMutableBytes { bytes in + read(fd, bytes.baseAddress!.advanced(by: received), bytes.count - received) + } + if count < 0 && errno == EINTR { continue } + guard count > 0 else { return unavailable("widget.failed") } + let end = received + count + if let newline = response[received.. &'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, + pub tun: Option, + pub proxy: Option, + pub error: Option, + 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 + Send; + fn language(&self) -> impl Future + Send; + fn apply(&self, action: Action) -> impl Future> + Send; +} diff --git a/crates/clash-verge-widget/src/server.rs b/crates/clash-verge-widget/src/server.rs new file mode 100644 index 000000000..5e4a451d4 --- /dev/null +++ b/crates/clash-verge-widget/src/server.rs @@ -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 = OnceLock::new(); + +fn load_native() -> anyhow::Result { + 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>, +} + +#[derive(Deserialize)] +#[serde(transparent)] +struct RequestValue<'a>(#[serde(borrow)] Cow<'a, str>); + +impl Request<'_> { + fn action(&self) -> Result, &'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::(&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::().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:#}"); + } + }); +} diff --git a/eslint.config.ts b/eslint.config.ts index df7bd8deb..87370c1a9 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -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: { diff --git a/package.json b/package.json index 2153801bf..c561378a8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 000000000..b6fbb2cc0 --- /dev/null +++ b/scripts/build.mjs @@ -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() diff --git a/scripts/cleanup-unused-i18n.mjs b/scripts/cleanup-unused-i18n.mjs index 0515541b9..c7162527b 100644 --- a/scripts/cleanup-unused-i18n.mjs +++ b/scripts/cleanup-unused-i18n.mjs @@ -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']) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6b958ec24..e6b90b21e 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/core/handle.rs b/src-tauri/src/core/handle.rs index f89c4e877..2f62bd728 100644 --- a/src-tauri/src/core/handle.rs +++ b/src-tauri/src/core/handle.rs @@ -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; }; diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 2b3021bc1..247dc7a5c 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -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}; diff --git a/src-tauri/src/core/widget.rs b/src-tauri/src/core/widget.rs new file mode 100644 index 000000000..00f38c73d --- /dev/null +++ b/src-tauri/src/core/widget.rs @@ -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) }); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 19891eba3..31e7baddc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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") { diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 452008172..84c8e5ce6 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -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" } } }