From 143ed48aa9bee4559aebd81a1aba44fb21294ad0 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:32:00 +0800 Subject: [PATCH] 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> --- .gitignore | 1 + Cargo.lock | 14 ++ Cargo.toml | 2 + crates/clash-verge-i18n/locales/ar.yml | 11 + crates/clash-verge-i18n/locales/de.yml | 11 + crates/clash-verge-i18n/locales/en.yml | 11 + crates/clash-verge-i18n/locales/es.yml | 11 + crates/clash-verge-i18n/locales/fa.yml | 11 + crates/clash-verge-i18n/locales/id.yml | 11 + crates/clash-verge-i18n/locales/jp.yml | 11 + crates/clash-verge-i18n/locales/ko.yml | 11 + crates/clash-verge-i18n/locales/ru.yml | 11 + crates/clash-verge-i18n/locales/tr.yml | 11 + crates/clash-verge-i18n/locales/tt.yml | 11 + crates/clash-verge-i18n/locales/zh.yml | 11 + crates/clash-verge-i18n/locales/zhtw.yml | 11 + crates/clash-verge-widget/.gitignore | 3 + crates/clash-verge-widget/Cargo.toml | 19 ++ crates/clash-verge-widget/build/bundle.mjs | 232 ++++++++++++++++++ crates/clash-verge-widget/build/localize.mjs | 37 +++ crates/clash-verge-widget/native/Bridge.swift | 48 ++++ crates/clash-verge-widget/native/Host.swift | 11 + .../clash-verge-widget/native/Preview.plist | 17 ++ .../native/RoutingView.swift | 191 ++++++++++++++ .../native/StylePreviews.swift | 192 +++++++++++++++ crates/clash-verge-widget/native/Widget.swift | 64 +++++ .../native/Widget.xcodeproj/project.pbxproj | 83 +++++++ .../xcschemes/PreviewHost.xcscheme | 77 ++++++ .../native/WidgetClient.swift | 86 +++++++ crates/clash-verge-widget/src/lib.rs | 57 +++++ crates/clash-verge-widget/src/server.rs | 219 +++++++++++++++++ eslint.config.ts | 3 +- package.json | 4 +- scripts/build.mjs | 62 +++++ scripts/cleanup-unused-i18n.mjs | 2 +- src-tauri/Cargo.toml | 1 + src-tauri/src/core/handle.rs | 6 + src-tauri/src/core/mod.rs | 2 + src-tauri/src/core/widget.rs | 83 +++++++ src-tauri/src/lib.rs | 2 + src-tauri/tauri.macos.conf.json | 12 +- 41 files changed, 1666 insertions(+), 7 deletions(-) create mode 100644 crates/clash-verge-widget/.gitignore create mode 100644 crates/clash-verge-widget/Cargo.toml create mode 100644 crates/clash-verge-widget/build/bundle.mjs create mode 100644 crates/clash-verge-widget/build/localize.mjs create mode 100644 crates/clash-verge-widget/native/Bridge.swift create mode 100644 crates/clash-verge-widget/native/Host.swift create mode 100644 crates/clash-verge-widget/native/Preview.plist create mode 100644 crates/clash-verge-widget/native/RoutingView.swift create mode 100644 crates/clash-verge-widget/native/StylePreviews.swift create mode 100644 crates/clash-verge-widget/native/Widget.swift create mode 100644 crates/clash-verge-widget/native/Widget.xcodeproj/project.pbxproj create mode 100644 crates/clash-verge-widget/native/Widget.xcodeproj/xcshareddata/xcschemes/PreviewHost.xcscheme create mode 100644 crates/clash-verge-widget/native/WidgetClient.swift create mode 100644 crates/clash-verge-widget/src/lib.rs create mode 100644 crates/clash-verge-widget/src/server.rs create mode 100644 scripts/build.mjs create mode 100644 src-tauri/src/core/widget.rs 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" } } }