From 447f3f494e125c6ea00c955b5033b5eebd8077f5 Mon Sep 17 00:00:00 2001
From: wangjia <809946525@qq.com>
Date: Sun, 21 Jun 2026 21:18:33 +0800
Subject: [PATCH 1/8] =?UTF-8?q?feat(client/macos):=20P1=20=E5=8E=9F?=
=?UTF-8?q?=E7=94=9F=E9=9A=A7=E9=81=93=E2=80=94=E2=80=94PacketTunnel=20?=
=?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=89=A9=E5=B1=95=E5=8F=AF=E5=8A=A0=E8=BD=BD?=
=?UTF-8?q?=20+=20libbox=20=E8=BF=90=E8=A1=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
经长链路排查(同机对照可工作的 Tailscale),修复 macOS 系统扩展 realize
失败(OSSystemExtensionErrorDomain code=4)与 libbox 运行时崩溃,使内嵌
sing-box 的系统扩展能在 macOS 15 上激活并启动隧道。
系统扩展 realize(三个叠加根因):
- 扩展自包含:PacketTunnel 加 OTHER_LDFLAGS="" 切断对项目级 CocoaPods 链接
标志的继承(原会把 flutter_secure_storage 链进扩展);Libbox.xcframework
改纯 Link(静态),从 Embed Frameworks 移除冗余内嵌
- bundle 名 = 标识符:PRODUCT_NAME 设为 com.pangolin.pangolin.PacketTunnel
- 扩展 Info.plist 补 NSSystemExtensionUsageDescription(网络扩展类别强制要求)
- App Group 改 macOS 原生格式 BYL4KQHMTN.com.pangolin.pangolin;NEMachServiceName
以其为前缀;扩展补 network.client/server;get-task-allow=false + 签名加 --timestamp
- CFBundleVersion 随构建递增(否则 sysextd 视为同版本不更新)
libbox 运行时:
- startOrReloadService(options:) 传 nil 致空指针 SIGSEGV → 传 LibboxOverrideOptions()
- 默认接口监控阻塞到首个 path 更新再返回,修 "no available network interface"
配套:
- scripts/local_test.sh:build/sign/notarize/copy/run 一条龙(Developer ID + 公证)
- client/macos/sign_libbox.sh:构建期以 Developer ID 重签内嵌 Libbox
- VpnChannel:401 自动刷新 token、详尽 os_log;auth/api 统一走 kApiBaseUrl
- docs/macos-sysext-realize-troubleshooting.html:完整踩坑复盘
WIP / 临时(后续清理):
- 隧道运行时仍在排查:剥离远程 rule-set 后 sing-box 启动卡点未定位
- 含临时诊断代码:main.swift stderr 重定向、box.log 输出、rule-set 剥离、debug 日志
- api_config 仍指向联调节点,发版前还原
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c
---
client/lib/bridge/vpn_bridge_provider.dart | 2 +-
client/lib/screens/account_page.dart | 9 +-
client/lib/services/api_config.dart | 5 +-
client/lib/services/auth_api.dart | 7 +-
client/lib/state/connection_provider.dart | 45 ++--
client/lib/widgets/auth_screen.dart | 9 +-
.../Flutter/GeneratedPluginRegistrant.swift | 6 +
client/macos/PacketTunnel/Info.plist | 11 +-
.../PacketTunnel/PacketTunnel.entitlements | 24 +-
.../PacketTunnel/PacketTunnelProvider.swift | 57 ++++-
client/macos/PacketTunnel/main.swift | 11 +
client/macos/Runner.xcodeproj/project.pbxproj | 105 +++++----
client/macos/Runner/DebugProfile.entitlements | 8 +
client/macos/Runner/Info.plist | 3 +
client/macos/Runner/Release.entitlements | 19 +-
client/macos/Runner/VpnChannel.swift | 46 +++-
client/macos/sign_libbox.sh | 13 ++
.../macos-sysext-realize-troubleshooting.html | 186 ++++++++++++++++
scripts/local_test.sh | 206 +++++++++++++-----
19 files changed, 629 insertions(+), 143 deletions(-)
create mode 100755 client/macos/sign_libbox.sh
create mode 100644 docs/macos-sysext-realize-troubleshooting.html
diff --git a/client/lib/bridge/vpn_bridge_provider.dart b/client/lib/bridge/vpn_bridge_provider.dart
index 063eab1..9eebecb 100644
--- a/client/lib/bridge/vpn_bridge_provider.dart
+++ b/client/lib/bridge/vpn_bridge_provider.dart
@@ -16,7 +16,7 @@ import 'dart:io' show Platform;
/// P1 方案B 开关:macOS 是否走原生 System Extension(VpnNativeBridge)而非 PoC 的
/// sudo 子进程(DesktopVpnBridge)。默认 false——待 PacketTunnel target/签名就绪、
/// 原生侧联调通过后置 true。见 docs/p1-macos-system-extension.md。
-const bool kUseNativeVpnMacOS = false;
+const bool kUseNativeVpnMacOS = true;
/// 全局单例 VpnBridge。Ref 生命周期内不变。
final vpnBridgeProvider = Provider((ref) {
diff --git a/client/lib/screens/account_page.dart b/client/lib/screens/account_page.dart
index ec8cda9..37358a5 100644
--- a/client/lib/screens/account_page.dart
+++ b/client/lib/screens/account_page.dart
@@ -7,6 +7,7 @@ import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../state/account_providers.dart';
import '../state/app_providers.dart';
+import '../state/auth_provider.dart';
import '../state/navigation_provider.dart';
import '../widgets/account_screens.dart';
import '../widgets/app_top_bar.dart';
@@ -93,7 +94,13 @@ class AccountPage extends ConsumerWidget {
]),
const SizedBox(height: 16),
_Card(children: [
- _NavRow(icon: PangolinIcons.logOut, title: t.signOut, danger: true, onTap: () {}),
+ _NavRow(
+ icon: PangolinIcons.logOut,
+ title: t.signOut,
+ danger: true,
+ // 清除本地令牌 → authProvider 置未登录 → 根据登录态回登录页。
+ onTap: () => ref.read(authProvider.notifier).logout(),
+ ),
]),
],
);
diff --git a/client/lib/services/api_config.dart b/client/lib/services/api_config.dart
index 0a20176..5fc33b8 100644
--- a/client/lib/services/api_config.dart
+++ b/client/lib/services/api_config.dart
@@ -1,8 +1,9 @@
// api_config.dart — 控制面 API 基址单源。
//
// 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里,
-// 由 --dart-define=PANGOLIN_API_URL 注入(默认本地 8080)。
+// 由 --dart-define=PANGOLIN_API_URL 注入。
+// TODO(联调临时): 默认值改成测试节点,避免 release 构建漏传 dart-define;发版前改回 localhost 或正式控制面域名。
const String kApiBaseUrl = String.fromEnvironment(
'PANGOLIN_API_URL',
- defaultValue: 'http://localhost:8080',
+ defaultValue: 'http://103.119.13.48:8080',
);
diff --git a/client/lib/services/auth_api.dart b/client/lib/services/auth_api.dart
index f34b161..8201f86 100644
--- a/client/lib/services/auth_api.dart
+++ b/client/lib/services/auth_api.dart
@@ -99,15 +99,20 @@ class AuthApi {
Future _post(String path, Map body) async {
final uri = Uri.parse('$baseUrl$path');
+ print('>>>AUTHLOG POST $uri (baseUrl=$baseUrl) 发起...');
+ final sw = Stopwatch()..start();
try {
- return await _client
+ final resp = await _client
.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
)
.timeout(const Duration(seconds: 15));
+ print('>>>AUTHLOG POST $uri -> HTTP ${resp.statusCode} (${sw.elapsedMilliseconds}ms)');
+ return resp;
} on Exception catch (e) {
+ print('>>>AUTHLOG POST $uri 失败 (${sw.elapsedMilliseconds}ms): ${e.runtimeType}: $e');
throw AuthApiException(
statusCode: -1,
messageZh: '网络请求失败,请检查连接后重试',
diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart
index 7e2e1a8..7c0d5be 100644
--- a/client/lib/state/connection_provider.dart
+++ b/client/lib/state/connection_provider.dart
@@ -12,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../bridge/vpn_bridge.dart';
import '../bridge/vpn_bridge_provider.dart';
import '../l10n/app_text.dart';
+import '../services/api_config.dart';
import '../services/connect_api.dart';
import 'app_providers.dart';
import 'auth_provider.dart';
@@ -25,12 +26,7 @@ const _kDeviceId = String.fromEnvironment(
defaultValue: 'mac-001',
);
-// ── API base URL ─────────────────────────────────────────────────
-
-const _kApiUrl = String.fromEnvironment(
- 'PANGOLIN_API_URL',
- defaultValue: 'http://localhost:8080',
-);
+// API base URL 统一用 api_config.dart 的 kApiBaseUrl(单一来源,勿再重复声明)。
// ── 连接阶段枚举 ──────────────────────────────────────────────────
@@ -103,7 +99,6 @@ class ConnectionController extends StateNotifier {
Future _connect() async {
state = const ConnectionState(phase: VpnPhase.connecting);
- final token = _ref.read(authProvider).accessToken ?? '';
final node = _ref.read(effectiveNodeProvider);
final zh = _ref.read(localeProvider) == AppLang.zh;
@@ -119,14 +114,7 @@ class ConnectionController extends StateNotifier {
}
try {
- _api?.dispose();
- _api = ConnectApi(baseUrl: _kApiUrl, authToken: token);
- final configJson = await _api!.fetchConfig(
- nodeId: node.uuid,
- deviceId: _kDeviceId,
- // smartRoute 偏好 → 国内分流(#5):国内 IP/域名直连,不走隧道。
- splitCN: _ref.read(settingsProvider).smartRoute,
- );
+ final configJson = await _fetchConfigWithRefresh(node.uuid);
// bridge.start() 不阻塞至连接建立;on 状态由 statusStream 回调驱动。
await _bridge.start(configJson);
} on ConnectApiException catch (e) {
@@ -142,6 +130,33 @@ class ConnectionController extends StateNotifier {
}
}
+ /// 取配置;access token 过期(401)时用 refresh token 续期后**重试一次**。
+ /// 续期失败(refresh 也过期 / 被拒)由 authProvider.refresh() 触发登出 → UI 回登录页。
+ Future _fetchConfigWithRefresh(String nodeUuid) async {
+ Future doFetch() {
+ final token = _ref.read(authProvider).accessToken ?? '';
+ _api?.dispose();
+ _api = ConnectApi(baseUrl: kApiBaseUrl, authToken: token);
+ return _api!.fetchConfig(
+ nodeId: nodeUuid,
+ deviceId: _kDeviceId,
+ // smartRoute 偏好 → 国内分流(#5):国内 IP/域名直连,不走隧道。
+ splitCN: _ref.read(settingsProvider).smartRoute,
+ );
+ }
+
+ try {
+ return await doFetch();
+ } on ConnectApiException catch (e) {
+ if (e.statusCode == 401) {
+ // token 过期 → 续期后重试一次;续期成功则用新 token 再取。
+ final ok = await _ref.read(authProvider.notifier).refresh();
+ if (ok) return await doFetch();
+ }
+ rethrow;
+ }
+ }
+
Future _disconnect() async {
_stopElapsed();
try {
diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart
index 097e1bd..a6384d1 100644
--- a/client/lib/widgets/auth_screen.dart
+++ b/client/lib/widgets/auth_screen.dart
@@ -8,17 +8,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
+import '../services/api_config.dart';
import '../services/auth_api.dart';
import '../state/auth_provider.dart';
import 'pangolin_button.dart';
import 'pangolin_icons.dart';
import 'pangolin_logo.dart';
-// API base URL(由 --dart-define 注入)
-const _kApiUrl = String.fromEnvironment(
- 'PANGOLIN_API_URL',
- defaultValue: 'http://localhost:8080',
-);
+// API base URL 统一用 api_config.dart 的 kApiBaseUrl(单一来源,勿再重复声明)。
const _easeOut = Cubic(0.22, 1, 0.36, 1);
@@ -55,7 +52,7 @@ class _AuthScreenState extends ConsumerState
duration: const Duration(milliseconds: 620),
);
- late final AuthApi _api = AuthApi(baseUrl: _kApiUrl);
+ late final AuthApi _api = AuthApi(baseUrl: kApiBaseUrl);
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift
index 16bb469..62fded1 100644
--- a/client/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -7,10 +7,16 @@ import Foundation
import flutter_secure_storage_macos
import package_info_plus
+import screen_retriever_macos
import shared_preferences_foundation
+import tray_manager
+import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
+ ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+ TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin"))
+ WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}
diff --git a/client/macos/PacketTunnel/Info.plist b/client/macos/PacketTunnel/Info.plist
index c788ed7..0828c9d 100644
--- a/client/macos/PacketTunnel/Info.plist
+++ b/client/macos/PacketTunnel/Info.plist
@@ -2,12 +2,21 @@
+
+ NSSystemExtensionUsageDescription
+ Pangolin 需要安装网络扩展以提供安全的网络连接。
NetworkExtension
+
NEMachServiceName
- $(TeamIdentifierPrefix)group.com.pangolin.pangolin
+ BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel
NEProviderClasses
com.apple.networkextension.packet-tunnel
diff --git a/client/macos/PacketTunnel/PacketTunnel.entitlements b/client/macos/PacketTunnel/PacketTunnel.entitlements
index 3482818..0bc1e86 100644
--- a/client/macos/PacketTunnel/PacketTunnel.entitlements
+++ b/client/macos/PacketTunnel/PacketTunnel.entitlements
@@ -2,20 +2,26 @@
-
+
+ com.apple.security.get-task-allow
+
+
+ com.apple.security.network.client
+
+ com.apple.security.network.server
+
+
com.apple.developer.networking.networkextension
- packet-tunnel-provider
+ packet-tunnel-provider-systemextension
-
+
com.apple.security.application-groups
- group.com.pangolin.pangolin
+ BYL4KQHMTN.com.pangolin.pangolin
diff --git a/client/macos/PacketTunnel/PacketTunnelProvider.swift b/client/macos/PacketTunnel/PacketTunnelProvider.swift
index 4205c9e..4f950fa 100644
--- a/client/macos/PacketTunnel/PacketTunnelProvider.swift
+++ b/client/macos/PacketTunnel/PacketTunnelProvider.swift
@@ -15,7 +15,9 @@ import NetworkExtension
import os
private let log = Logger(subsystem: "com.pangolin.pangolin.PacketTunnel", category: "provider")
-private let appGroup = "group.com.pangolin.pangolin"
+// macOS 原生 App Group 格式 .(非 iOS 的 group. 前缀)。
+// sysextd 校验请求方 app 时要求此格式;用 group. 式会在 realize 暂存前被拒。
+private let appGroup = "BYL4KQHMTN.com.pangolin.pangolin"
final class PacketTunnelProvider: NEPacketTunnelProvider {
private var commandServer: LibboxCommandServer?
@@ -25,7 +27,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
completionHandler: @escaping (Error?) -> Void) {
log.info("startTunnel")
do {
- let configContent = try resolveConfig(options)
+ let rawConfig = try resolveConfig(options)
guard let base = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
@@ -34,6 +36,10 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
let work = base.appendingPathComponent("work", isDirectory: true)
try? FileManager.default.createDirectory(at: work, withIntermediateDirectories: true)
+ // 【临时】剥掉远程 rule-set(#5);并把 sing-box 日志直写容器 box.log(debug)以定位启动卡点。
+ let configContent = Self.stripRemoteRuleSets(
+ rawConfig, logOutput: base.appendingPathComponent("box.log").path)
+
let setup = LibboxSetupOptions()
setup.basePath = base.path
setup.workingPath = work.path
@@ -50,13 +56,17 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
throw newErr ?? simpleError("LibboxNewCommandServer returned nil")
}
try server.start()
- try server.startOrReloadService(configContent, options: nil)
+ // 必须传非空 options:此版本 libbox 的 StartOrReloadService 会解引用 options,
+ // 传 nil 会在 command_server.go:175 触发 SIGSEGV(空指针),扩展进程崩溃。
+ try server.startOrReloadService(configContent, options: LibboxOverrideOptions())
self.commandServer = server
log.info("startTunnel: service started")
completionHandler(nil)
} catch {
- log.error("startTunnel failed: \(error.localizedDescription)")
+ // 【临时诊断】完整错误写进 stderr.log;并设 public 避免 os_log 脱敏成 。
+ FileHandle.standardError.write("startTunnel failed: \(error)\n".data(using: .utf8)!)
+ log.error("startTunnel failed: \(error.localizedDescription, privacy: .public)")
completionHandler(error)
}
}
@@ -90,13 +100,33 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
guard !content.isEmpty else { throw simpleError("empty sing-box config") }
return content
}
+
+ // 【临时】移除配置里的远程 rule-set 定义,以及 route.rules 中引用了 rule_set 的规则。
+ // 解析失败则原样返回。正式分流方案应改用本地 .srs(客户端预取)。
+ static func stripRemoteRuleSets(_ json: String, logOutput: String) -> String {
+ guard let data = json.data(using: .utf8),
+ var root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
+ var route = root["route"] as? [String: Any] else {
+ return json
+ }
+ if route["rule_set"] != nil { route.removeValue(forKey: "rule_set") }
+ if let rules = route["rules"] as? [[String: Any]] {
+ route["rules"] = rules.filter { $0["rule_set"] == nil }
+ }
+ root["route"] = route
+ // 【临时诊断】sing-box 日志直写容器 box.log(debug),定位启动卡点。
+ root["log"] = ["level": "debug", "timestamp": true, "output": logOutput]
+ guard let out = try? JSONSerialization.data(withJSONObject: root),
+ let s = String(data: out, encoding: .utf8) else { return json }
+ return s
+ }
}
// ── LibboxCommandServerHandler:服务端事件回调 ──────────────────────────────
extension PacketTunnelProvider: LibboxCommandServerHandlerProtocol {
func serviceReload() throws {
if let cfg = try? resolveConfig(nil) {
- try commandServer?.startOrReloadService(cfg, options: nil)
+ try commandServer?.startOrReloadService(cfg, options: LibboxOverrideOptions())
}
}
func serviceStop() throws { cancelTunnelWithError(nil) }
@@ -106,7 +136,15 @@ extension PacketTunnelProvider: LibboxCommandServerHandlerProtocol {
}
func setSystemProxyEnabled(_ enabled: Bool) throws {}
func writeStatus(_ message: LibboxStatusMessage?) {}
- func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {}
+ func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {
+ // 【临时诊断】把 sing-box 日志写进 stderr.log,定位启动卡点。
+ guard let it = messageList else { return }
+ while it.hasNext() {
+ guard let entry = it.next() else { continue }
+ let line = "[box] \(entry.message)\n"
+ FileHandle.standardError.write(line.data(using: .utf8) ?? Data())
+ }
+ }
func writeConnectionEvents(_ events: LibboxConnectionEvents?) {}
func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) {}
func writeDebugMessage(_ message: String?) {}
@@ -170,6 +208,11 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
func startDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws {
let m = NWPathMonitor()
monitor = m
+ // 必须阻塞到首个 path 更新再返回:否则 sing-box 紧接着下载远程 rule-set 时
+ // defaultInterfaceIndex 仍为 -1,autoDetectControl 跳过绑接口 → "no available
+ // network interface"。对齐 sing-box-for-apple 的实现。
+ let firstUpdate = DispatchSemaphore(value: 0)
+ var signaled = false
m.pathUpdateHandler = { [weak self] path in
guard let self else { return }
let iface = path.availableInterfaces.first { path.usesInterfaceType($0.type) }
@@ -180,8 +223,10 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
listener?.updateDefaultInterface(name, interfaceIndex: index,
isExpensive: path.isExpensive,
isConstrained: path.isConstrained)
+ if !signaled { signaled = true; firstUpdate.signal() }
}
m.start(queue: monitorQueue)
+ _ = firstUpdate.wait(timeout: .now() + 5)
}
func closeDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws {
stopMonitor()
diff --git a/client/macos/PacketTunnel/main.swift b/client/macos/PacketTunnel/main.swift
index c1fdc59..9680633 100644
--- a/client/macos/PacketTunnel/main.swift
+++ b/client/macos/PacketTunnel/main.swift
@@ -7,6 +7,17 @@
import Foundation
import NetworkExtension
+// 【临时诊断】把 stderr/stdout 重定向到 App Group 容器文件,捕获 libbox(Go)的
+// fatal error / panic——系统扩展默认不把 stderr 收进统一日志。诊断完移除。
+if let c = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: "BYL4KQHMTN.com.pangolin.pangolin") {
+ let p = c.appendingPathComponent("stderr.log").path
+ freopen(p, "a", stderr)
+ freopen(p, "a", stdout)
+ setvbuf(stderr, nil, _IONBF, 0)
+ FileHandle.standardError.write("=== boot \(Date()) ===\n".data(using: .utf8)!)
+}
+
autoreleasepool {
NEProvider.startSystemExtensionMode()
}
diff --git a/client/macos/Runner.xcodeproj/project.pbxproj b/client/macos/Runner.xcodeproj/project.pbxproj
index ce3f5c2..88e9832 100644
--- a/client/macos/Runner.xcodeproj/project.pbxproj
+++ b/client/macos/Runner.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 70;
+ objectVersion = 54;
objects = {
/* Begin PBXAggregateTarget section */
@@ -92,7 +92,6 @@
dstPath = "";
dstSubfolderSpec = 10;
files = (
- A17B79F72FE52309001ABF28 /* Libbox.xcframework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
@@ -137,7 +136,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
- A17B79EE2FE50746001ABF28 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
+ A17B79EE2FE50746001ABF28 /* Exceptions for "PacketTunnel" folder in "PacketTunnel" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -147,7 +146,18 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
- A17B79E22FE50746001ABF28 /* PacketTunnel */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A17B79EE2FE50746001ABF28 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = PacketTunnel; sourceTree = ""; };
+ A17B79E22FE50746001ABF28 /* PacketTunnel */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ A17B79EE2FE50746001ABF28 /* Exceptions for "PacketTunnel" folder in "PacketTunnel" target */,
+ );
+ explicitFileTypes = {
+ };
+ explicitFolders = (
+ );
+ path = PacketTunnel;
+ sourceTree = "";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -338,6 +348,20 @@
productReference = 33CC10ED2044A3C60003C045 /* pangolin_vpn.app */;
productType = "com.apple.product-type.application";
};
+ FACE0FF0FACE0FF0FACE0001 /* Sign Libbox (Developer ID) */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Sign Libbox (Developer ID)";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "bash \"${SRCROOT}/sign_libbox.sh\"\n";
+ };
A17B79DE2FE50745001ABF28 /* PacketTunnel */ = {
isa = PBXNativeTarget;
buildConfigurationList = A17B79EF2FE50746001ABF28 /* Build configuration list for PBXNativeTarget "PacketTunnel" */;
@@ -346,6 +370,7 @@
A17B79DC2FE50745001ABF28 /* Frameworks */,
A17B79DD2FE50745001ABF28 /* Resources */,
A17B79F82FE52309001ABF28 /* Embed Frameworks */,
+ FACE0FF0FACE0FF0FACE0001 /* Sign Libbox (Developer ID) */,
);
buildRules = (
);
@@ -355,8 +380,6 @@
A17B79E22FE50746001ABF28 /* PacketTunnel */,
);
name = PacketTunnel;
- packageProductDependencies = (
- );
productName = PacketTunnel;
productReference = A17B79DF2FE50745001ABF28 /* PacketTunnel.systemextension */;
productType = "com.apple.product-type.system-extension";
@@ -379,7 +402,6 @@
33CC10EC2044A3C60003C045 = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
- ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.Sandbox = {
enabled = 1;
@@ -405,7 +427,7 @@
);
mainGroup = 33CC10E42044A3C60003C045;
packageReferences = (
- 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = "";
@@ -536,14 +558,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -616,7 +634,7 @@
baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 7;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -631,7 +649,7 @@
baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 7;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -646,7 +664,7 @@
baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 7;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -712,11 +730,11 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
- ENABLE_HARDENED_RUNTIME = YES;
- "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
+ CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = BYL4KQHMTN;
+ ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -847,11 +865,11 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
- ENABLE_HARDENED_RUNTIME = YES;
- "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
+ CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = BYL4KQHMTN;
+ ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -870,17 +888,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
- ENABLE_HARDENED_RUNTIME = YES;
- "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
- CODE_SIGN_STYLE = Automatic;
+ CODE_SIGN_IDENTITY = "Developer ID Application";
+ CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = BYL4KQHMTN;
+ ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
- PROVISIONING_PROFILE_SPECIFIER = "";
+ OTHER_CODE_SIGN_FLAGS = "--timestamp";
+ PROVISIONING_PROFILE_SPECIFIER = "Pangolin App DevID";
SWIFT_VERSION = 5.0;
};
name = Release;
@@ -914,13 +933,13 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
- "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
+ CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
- ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -933,12 +952,14 @@
"@executable_path/../../../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MACOSX_DEPLOYMENT_TARGET = 26.2;
+ MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
+ OTHER_LDFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
- PRODUCT_NAME = "$(TARGET_NAME)";
+ PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel;
+ PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -962,13 +983,13 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
- "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
- CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CODE_SIGN_IDENTITY = "Developer ID Application";
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
- ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -981,11 +1002,14 @@
"@executable_path/../../../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MACOSX_DEPLOYMENT_TARGET = 26.2;
+ MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
+ OTHER_CODE_SIGN_FLAGS = "--timestamp";
+ OTHER_LDFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
- PRODUCT_NAME = "$(TARGET_NAME)";
+ PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel;
+ PROVISIONING_PROFILE_SPECIFIER = "Pangolin PacketTunnel DevID";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -1008,13 +1032,13 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
- "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
+ CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
- ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -1027,11 +1051,12 @@
"@executable_path/../../../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MACOSX_DEPLOYMENT_TARGET = 26.2;
+ MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
- PRODUCT_NAME = "$(TARGET_NAME)";
+ PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel;
+ PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -1097,7 +1122,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
- 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
diff --git a/client/macos/Runner/DebugProfile.entitlements b/client/macos/Runner/DebugProfile.entitlements
index e5ff371..ffa43a8 100644
--- a/client/macos/Runner/DebugProfile.entitlements
+++ b/client/macos/Runner/DebugProfile.entitlements
@@ -10,6 +10,14 @@
com.apple.security.network.server
+
+ com.apple.developer.system-extension.install
+
+
+ com.apple.developer.networking.networkextension
+
+ packet-tunnel-provider
+
keychain-access-groups
diff --git a/client/macos/Runner/Info.plist b/client/macos/Runner/Info.plist
index 4789daa..d43b0ba 100644
--- a/client/macos/Runner/Info.plist
+++ b/client/macos/Runner/Info.plist
@@ -28,5 +28,8 @@
MainMenu
NSPrincipalClass
NSApplication
+
+ NSSystemExtensionUsageDescription
+ Pangolin 需要安装系统扩展以提供安全的网络连接。
diff --git a/client/macos/Runner/Release.entitlements b/client/macos/Runner/Release.entitlements
index 348cec0..020cb74 100644
--- a/client/macos/Runner/Release.entitlements
+++ b/client/macos/Runner/Release.entitlements
@@ -2,20 +2,33 @@
+
+ com.apple.security.get-task-allow
+
com.apple.security.app-sandbox
com.apple.security.network.client
+
+ com.apple.developer.system-extension.install
+
+
+ com.apple.developer.networking.networkextension
+
+ packet-tunnel-provider-systemextension
+
keychain-access-groups
$(AppIdentifierPrefix)com.pangolin.pangolin
-
com.apple.security.application-groups
- group.com.pangolin.pangolin
+ BYL4KQHMTN.com.pangolin.pangolin
- -->
diff --git a/client/macos/Runner/VpnChannel.swift b/client/macos/Runner/VpnChannel.swift
index 55f8b29..0d9e295 100644
--- a/client/macos/Runner/VpnChannel.swift
+++ b/client/macos/Runner/VpnChannel.swift
@@ -13,9 +13,16 @@
import FlutterMacOS
import NetworkExtension
import SystemExtensions
+import os.log
-// NSLog 兼容老部署目标(Runner < macOS 11,os.Logger 不可用)。
-private func vpnLog(_ message: String) { NSLog("[pangolin/vpn] %@", message) }
+// os_log + %{public} —— 让日志在 Console.app / `log show` 里可见(NSLog 的 %@ 参数会被
+// 系统 redact 成 ,排障时看不到内容)。os_log(C API)自 macOS 10.12 起可用,
+// 兼容 Runner 的 10.15 部署目标。过滤:`log show --predicate 'subsystem == "com.pangolin.pangolin"'`。
+private let vpnLogObj = OSLog(subsystem: "com.pangolin.pangolin", category: "vpn")
+private func vpnLog(_ message: String) {
+ os_log("%{public}@", log: vpnLogObj, type: .default, message)
+ NSLog("[pangolin/vpn] %@", message) // 同时进 stderr,flutter run 控制台也能看到
+}
final class VpnChannel: NSObject {
private static let tunnelBundleId = "com.pangolin.pangolin.PacketTunnel"
@@ -68,16 +75,26 @@ final class VpnChannel: NSObject {
}
private func start(_ configJson: String, _ result: @escaping FlutterResult) async {
+ vpnLog("start() 收到调用, config 长度=\(configJson.count) bytes")
do {
+ vpnLog("step① 激活 System Extension …")
try await activateSystemExtensionIfNeeded()
+ vpnLog("step① System Extension 激活完成 ✓")
+
+ vpnLog("step② 装配 NETunnelProviderManager …")
let mgr = try await loadOrCreateManager()
self.manager = mgr
+ vpnLog("step② manager 就绪, 当前隧道状态=\(Self.statusString(mgr.connection.status))")
+
+ vpnLog("step③ startVPNTunnel(options: configContent) …")
try mgr.connection.startVPNTunnel(options: [
"configContent": configJson as NSString,
])
+ vpnLog("step③ startVPNTunnel 调用已返回(实际起停由 NEVPNStatus 流驱动)✓")
result(nil)
} catch {
- vpnLog("start failed: \(error.localizedDescription)")
+ let ns = error as NSError
+ vpnLog("start FAILED ✗ domain=\(ns.domain) code=\(ns.code) desc=\(ns.localizedDescription) userInfo=\(ns.userInfo)")
result(FlutterError(code: "start_failed", message: error.localizedDescription, details: nil))
}
}
@@ -90,6 +107,7 @@ final class VpnChannel: NSObject {
// ── NETunnelProviderManager 装配 ────────────────────────────────
private func loadOrCreateManager() async throws -> NETunnelProviderManager {
let all = try await NETunnelProviderManager.loadAllFromPreferences()
+ vpnLog(" loadAllFromPreferences: 已有 \(all.count) 个 VPN 配置")
let mgr = all.first ?? NETunnelProviderManager()
let proto = (mgr.protocolConfiguration as? NETunnelProviderProtocol) ?? NETunnelProviderProtocol()
proto.providerBundleIdentifier = Self.tunnelBundleId
@@ -97,14 +115,20 @@ final class VpnChannel: NSObject {
mgr.protocolConfiguration = proto
mgr.localizedDescription = "Pangolin"
mgr.isEnabled = true
+ vpnLog(" saveToPreferences(providerBundleId=\(Self.tunnelBundleId)) …")
try await mgr.saveToPreferences()
try await mgr.loadFromPreferences() // 保存后重载,拿到有效 connection
+ vpnLog(" manager 保存+重载完成 ✓")
return mgr
}
// 请求系统加载/更新 PacketTunnel System Extension。首启系统会弹「隐私与安全性」
// 让用户允许;允许后 didFinishWithResult 回来。已是最新则快速完成。
private func activateSystemExtensionIfNeeded() async throws {
+ vpnLog(" 提交 OSSystemExtensionRequest.activationRequest(id=\(Self.tunnelBundleId)) …")
+ vpnLog(" 主 bundle=\(Bundle.main.bundlePath)")
+ let sysextDir = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/SystemExtensions").path
+ vpnLog(" SystemExtensions 目录=\(sysextDir) 内容=\((try? FileManager.default.contentsOfDirectory(atPath: sysextDir)) ?? ["<读取失败>"])")
try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
let req = OSSystemExtensionRequest.activationRequest(
forExtensionWithIdentifier: Self.tunnelBundleId, queue: .main)
@@ -112,6 +136,7 @@ final class VpnChannel: NSObject {
self.sysextDelegate = delegate // 保活到回调结束
req.delegate = delegate
OSSystemExtensionManager.shared.submitRequest(req)
+ vpnLog(" submitRequest 已提交, 等待 sysextd 回调(didFinish / didFail / needsApproval)…")
}
}
@@ -121,7 +146,9 @@ final class VpnChannel: NSObject {
forName: .NEVPNStatusDidChange, object: nil, queue: .main
) { [weak self] note in
guard let conn = note.object as? NEVPNConnection else { return }
- self?.statusSink?(Self.statusString(conn.status))
+ let s = Self.statusString(conn.status)
+ vpnLog("NEVPNStatus 变化 → \(s) (raw=\(conn.status.rawValue))")
+ self?.statusSink?(s)
}
}
@@ -180,21 +207,28 @@ private final class SysExtActivationDelegate: NSObject, OSSystemExtensionRequest
func request(_ request: OSSystemExtensionRequest,
didFinishWithResult result: OSSystemExtensionRequest.Result) {
+ vpnLog("sysext didFinishWithResult ✓ result=\(result.rawValue) (0=completed, 1=willCompleteAfterReboot)")
guard !resumed else { return }
resumed = true
continuation.resume()
}
func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
+ let ns = error as NSError
+ // OSSystemExtensionErrorDomain code 速查:1 unknown,2 missingEntitlement,
+ // 3 unsupportedParentBundleLocation,4 extensionNotFound,8 codeSignatureInvalid,
+ // 9 validationFailed,10 forbiddenBySystemPolicy,13 authorizationRequired。
+ vpnLog("sysext didFailWithError ✗ domain=\(ns.domain) code=\(ns.code) desc=\(ns.localizedDescription)")
guard !resumed else { return }
resumed = true
continuation.resume(throwing: error)
}
func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
- vpnLog("system extension 待用户允许(系统设置 → 隐私与安全性)")
+ vpnLog("sysext requestNeedsUserApproval —— 需在 系统设置 → 隐私与安全性 点「允许」(等待中…)")
}
func request(_ request: OSSystemExtensionRequest,
actionForReplacingExtension existing: OSSystemExtensionProperties,
withExtension ext: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
- .replace
+ vpnLog("sysext 替换扩展: 已装 v\(existing.bundleVersion)/\(existing.bundleShortVersion) → 新 v\(ext.bundleVersion)/\(ext.bundleShortVersion), 选择 replace")
+ return .replace
}
}
diff --git a/client/macos/sign_libbox.sh b/client/macos/sign_libbox.sh
new file mode 100755
index 0000000..aea1c88
--- /dev/null
+++ b/client/macos/sign_libbox.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+# 构建期重签 PacketTunnel 内嵌的 Libbox.framework 为当前签名身份(Developer ID)。
+# 原因:Libbox 是 gomobile xcframework,内部二进制是 adhoc/linker-signed,Xcode 的
+# CodeSignOnCopy 不会替换它 → 公证会拒。本脚本在 sysext 被 Xcode 封签之前重签 Libbox,
+# 使其 Developer ID + 带时间戳,避免事后手动重签(那会破坏框架对扩展的校验)。
+set -e
+LIBBOX="${CODESIGNING_FOLDER_PATH}/Contents/Frameworks/Libbox.framework"
+[ -d "$LIBBOX" ] || { echo "sign_libbox: 未找到 $LIBBOX,跳过"; exit 0; }
+[ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ] || { echo "sign_libbox: 无签名身份,跳过"; exit 0; }
+echo "sign_libbox: 用 ${EXPANDED_CODE_SIGN_IDENTITY_NAME:-$EXPANDED_CODE_SIGN_IDENTITY} 重签 Libbox"
+/usr/bin/codesign --force --options runtime --timestamp -s "${EXPANDED_CODE_SIGN_IDENTITY}" "$LIBBOX/Versions/A/Libbox"
+/usr/bin/codesign --force --options runtime --timestamp -s "${EXPANDED_CODE_SIGN_IDENTITY}" "$LIBBOX"
+echo "sign_libbox: 完成"
diff --git a/docs/macos-sysext-realize-troubleshooting.html b/docs/macos-sysext-realize-troubleshooting.html
new file mode 100644
index 0000000..af7e451
--- /dev/null
+++ b/docs/macos-sysext-realize-troubleshooting.html
@@ -0,0 +1,186 @@
+
+
+
+
+
+macOS PacketTunnel 系统扩展 realize 失败(code=4)踩坑复盘
+
+
+
+
+
+
macOS PacketTunnel 系统扩展 realize 失败(OSSystemExtensionErrorDomain code=4)踩坑复盘
+
Pangolin 客户端 · P1 原生隧道 · 2026-06-21 · 在 macOS 15.3.2 / 26 上排查与修复
+
+
+一句话结论: 客户端内嵌的 PacketTunnel 系统扩展(NEPacketTunnelProvider)一直无法激活,报
+code=4 "Extension not found in App bundle. Unable to find any matched extension"。
+表面像"找不到扩展",实则是 三个叠加的工程配置 bug 让 sysextd 在 realize/暂存/分类校验三个不同阶段先后拒绝。
+逐个修复后扩展成功 activated enabled、隧道连通。
+
+
+
1. 现象
+
客户端点"连接"时,主 app 调 OSSystemExtensionRequest.activationRequest 激活内嵌的 packet-tunnel 系统扩展,但回调恒为失败:
+
sysext didFailWithError ✗ domain=OSSystemExtensionErrorDomain code=4
+desc=Extension not found in App bundle. Unable to find any matched extension
+ with identifier: com.pangolin.pangolin.PacketTunnel
+
诡异之处:
+
+ - 扩展明明在
Contents/Library/SystemExtensions/ 里,sysextd 日志也打了 attempting to realize(说明找到了)。
+ - 签名、公证、staple、Gatekeeper 全部通过;
codesign --verify --deep --strict 通过。
+ sysextd 在验签通过后 ~10ms 内静默 xpc_connection_cancel,自身不打印任何拒绝原因。
+ - 从不弹"允许扩展"批准框——在批准阶段之前就被拒。
+
+
+
2. 走过的弯路(全部排除)
+
以下都查过并确认不是根因,记录下来免得后人重复:
+
+ | 假设 | 结论 |
+ | 签名 / 公证 / 时间戳 / hardened runtime / get-task-allow | 全部正确 |
+ entitlements 变体(packet-tunnel-provider-systemextension) | 正确,且描述文件已授权 |
+ Info.plist 用 NetworkExtension/NEProviderClasses(而非 appex 的 NSExtension) | 正确(sysext 就该用这个) |
+ | Xcode 26 beta / macOS 26.2 SDK 工具链太新 | 证伪:Tailscale 用更新的 SDK 26.5 照样能用 |
+ | App 文件归属(user vs root:wheel) | 改 root:wheel 无效 |
+ | App 不在 /Applications / LaunchServices 注册 / 翻译重定位 | 都正常,无效 |
+ systemextensionsctl reset 清缓存 | 无效 |
+ App Group 用 iOS 式 group. 前缀 | 确实该改成 macOS 原生格式,但单独改它仍失败 |
+
+
+
3. 破局方法:在同一台机器上对照一个"能用的"开源同类
+
关键转折是不再盲猜,而是拿一个已知能用、且分发模型完全相同的开源 app 在同一台机器上对照。我们选了 Tailscale 独立版(io.tailscale.ipn.macsys):同样是 Developer ID 公证 + NEPacketTunnelProvider 系统扩展分发(非 App Store appex)。
+
+
把本机能用的 Tailscale.app 直接拷到出问题的测试机(cara,干净 macOS 15.3.2,Intel),它的扩展一路走完 validating → staging → validating_by_category → activated_waiting_for_user 并弹出"允许扩展"框。
+
这一步同时证明了两件事:
+
+- cara 的环境完全正常——能 realize 第三方 Developer ID 系统扩展。
+- 所以 100% 是我们自己的包有问题(用户的直觉:"是我们代码的问题")。
+
+
+
附:也对照了 Shadowrocket,但它是 App Store 的 appex 模型(NSExtension、系统预信任、不走 sysextd 批准流),与我们不是一个机制,不能直接类比。要对照必须找同为 Developer ID system extension 的实现。
+
+
4. 三个根因(按 sysextd 处理阶段排序)
+
+
+
根因 ① 扩展不自包含 staging 前被拒
+
这是 Flutter + 扩展工程的经典坑。工程用 CocoaPods,项目级配置经 Release.xcconfig 注入了链接所有 pod 框架的 OTHER_LDFLAGS。PacketTunnel target 没有自己的 OTHER_LDFLAGS,于是继承了项目级的,把主 app 的 Flutter 插件 flutter_secure_storage_macos.framework 也链进了扩展——而该框架并不在扩展 bundle 内:
+
$ otool -L PacketTunnel.systemextension/Contents/MacOS/PacketTunnel
+ @rpath/flutter_secure_storage_macos.framework/.../flutter_secure_storage_macos ← 悬空依赖!
+ /System/Library/Frameworks/...
+
同时,gomobile 产出的 Libbox.xcframework 实为静态库(ar archive),已被静态链进扩展二进制,却又被 Embed Frameworks 冗余地塞了一份畸形的 Libbox.framework 进扩展。
+
system extension 必须自包含(realize 时会被拷到 /Library/SystemExtensions/<UUID>/ 独立运行,相对 rpath 回不到主 app)。
+
修复:
+
+- 给
PacketTunnel 的 Debug/Release 配置加 OTHER_LDFLAGS = "";,切断对项目级 pod 链接标志的继承。
+- 从 Embed Frameworks 移除
Libbox.xcframework(它是静态库,只需 Link,不需 Embed)。
+
+
验证:otool -L 扩展二进制应只剩 /System/... 与 /usr/lib/...,无任何 @rpath;Contents/ 下不再有 Frameworks/ 目录(与 Tailscale 一致)。
+
+
+
+
根因 ② 扩展 bundle 名 ≠ bundle 标识符 staging 阶段
+
我们扩展的产物名是 PacketTunnel.systemextension,而 CFBundleIdentifier 是 com.pangolin.pangolin.PacketTunnel。Tailscale 的产物名 = 标识符(io.tailscale.ipn.macsys.network-extension.systemextension)。
+
在受影响的 macOS 上,sysextd 必须能按标识符把 bundle 拷进暂存区;名字对不上时,在 attempting to realize 之后直接静默 cancel,进不了 staging。改名后日志立刻出现 [Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension。
+
修复: 把 PacketTunnel target 的 PRODUCT_NAME 从 $(TARGET_NAME) 改为 com.pangolin.pangolin.PacketTunnel(三个配置 Debug/Release/Profile 都改)。
+
连带:PRODUCT_MODULE_NAME 会变成 com_pangolin_pangolin_PacketTunnel,而 Info.plist 的 NEProviderClasses 用 $(PRODUCT_MODULE_NAME).PacketTunnelProvider 自动跟随,无需手改。
+
+
+
+
根因 ③ 扩展 Info.plist 缺 NSSystemExtensionUsageDescription category 校验阶段
+
过了 staging 后,日志终于给出明确原因:
+
com.pangolin.pangolin.PacketTunnel: extension failed category property check:
+extensions belonging to the com.apple.system_extension.network_extension category
+require the presence of the 'NSSystemExtensionUsageDescription' property.
+→ uninstalling invalid extension
+
网络扩展类别强制要求扩展自己的 Info.plist 里有 NSSystemExtensionUsageDescription。我们之前只在主 app 的 Info.plist 加了——不顶用。Tailscale 的扩展 Info.plist 里就有这个键。
+
修复: 在 PacketTunnel/Info.plist 顶层加:
+
<key>NSSystemExtensionUsageDescription</key>
+<string>Pangolin 需要安装网络扩展以提供安全的网络连接。</string>
+
+
+
附带一并修正的项
+
+- App Group 格式:
group.com.pangolin.pangolin(iOS 式)→ macOS 原生 BYL4KQHMTN.com.pangolin.pangolin(主 app + 扩展 entitlements + Swift appGroup 常量三处同步)。
+- NEMachServiceName:必须以扩展所属 App Group 为前缀 →
BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel。
+- 扩展网络权限:沙箱扩展补
com.apple.security.network.client / network.server(packet tunnel 要对外建连)。
+- 公证前置:
get-task-allow=false、Xcode 签名加 --timestamp(OTHER_CODE_SIGN_FLAGS)、构建期把静态 Libbox 以 Developer ID 重签。
+
+
+
5. 成功的 sysextd 日志长这样
+
attempting to realize extension with identifier com.pangolin.pangolin.PacketTunnel
+[Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension
+advancing state from staging to validating
+advancing state from validating to validating_by_category
+Category delegate com.apple.system_extension.network_extension returned error (null)
+advancing state ... to activated_waiting_for_user
+observer 'notify user' reached a success state # ← 弹"允许扩展"框
+
+# 用户在 系统设置 → 隐私与安全性 点允许后:
+sysext didFinishWithResult ✓ result=0 (completed)
+$ systemextensionsctl list
+ * * BYL4KQHMTN com.pangolin.pangolin.PacketTunnel (1.0/1) [activated enabled]
+
+
6. 给后人的排查 checklist(Developer ID 网络系统扩展)
+
+- 先找同模型的能用实现对照(Tailscale 独立版 / Mullvad),在同一台机器上验证环境没问题,把范围锁到自己的包。
+- 扩展二进制
otool -L 必须零 @rpath 外部依赖(自包含)。警惕 CocoaPods/Flutter 的 OTHER_LDFLAGS 继承。
+- 扩展 bundle 名 = CFBundleIdentifier(设
PRODUCT_NAME = 标识符)。
+- 扩展 Info.plist 必须有
NSSystemExtensionUsageDescription(不是主 app 的)。
+- App Group 用 macOS 原生
<TeamID>.<name>;NEMachServiceName 以该 group 为前缀。
+- 沙箱扩展按需补
network.client/server、文件访问等能力。
+- 静态库 xcframework 只 Link 不 Embed;动态 framework 才需 Embed + CodeSignOnCopy。
+- 排查时:
log stream --debug --predicate 'process=="sysextd" OR process=="syspolicyd"',看卡在 realize / staging / validating_by_category 哪一步;category 校验失败会打出明确缺什么键。
+
+
+
7. 已知环境坑:macOS 26 (Tahoe) 回归
+
+
在 macOS 26 上,即使包完全正确,sysextd 仍可能报
+no policy, cannot allow apps outside /Applications(app 明明在 /Applications)。这是 Apple 在 Tahoe 的已知回归(开发者论坛多人复现、非 MDM 个人机),不是我们能修的,也不影响 macOS 15/14 的真实用户。开发机若是 26,本机调试可考虑关 SIP 后开
+systemextensionsctl developer on,或在 15/14 机器上验证。
+
+
+
+
改动文件:client/macos/PacketTunnel/Info.plist · client/macos/PacketTunnel/PacketTunnel.entitlements · client/macos/Runner/Release.entitlements · client/macos/PacketTunnel/PacketTunnelProvider.swift · client/macos/Runner.xcodeproj/project.pbxproj(OTHER_LDFLAGS/PRODUCT_NAME/移除 Embed Libbox/加 timestamp)。验证机:cara,干净 macOS 15.3.2 Intel。
+
+
+
+
diff --git a/scripts/local_test.sh b/scripts/local_test.sh
index c481ef4..eb9f157 100755
--- a/scripts/local_test.sh
+++ b/scripts/local_test.sh
@@ -1,65 +1,167 @@
#!/usr/bin/env bash
-# local_test.sh —— 一键构建并运行【release】版 macOS 客户端,连真实节点做端到端验证。
#
-# 用真正的 release 包(不是 dev 模拟),前台运行以便直接看内核(sing-box)日志。
-# 改完代码直接跑此脚本即可。
+# local_test.sh — macOS 客户端本地联调一条龙(Developer ID 重签 + 装 + 跑)。
#
-# bash scripts/local_test.sh # 构建 release 并运行
-# bash scripts/local_test.sh --no-build # 跳过构建,直接跑上次的产物(快速重跑)
+# 背景:flutter build 出来的包是 Apple Development 签名;要让 System Extension
+# 能加载(且不依赖 SIP/dev mode),需重签成 Developer ID。本脚本封装这套流程。
#
-# 可选环境变量:
-# PANGOLIN_API_URL 控制面地址(默认连 racknerd 节点)
-# SINGBOX_BIN sing-box 路径(默认 brew 的 /opt/homebrew/bin/sing-box)
+# 子命令:
+# build flutter build macos --release(注入联调节点地址)
+# sign Developer ID 内向外重签整个 app + PacketTunnel sysext(嵌 DevID profile)
+# copy 覆盖安装到 /Applications
+# run 直跑 /Applications 版本(绕 Gatekeeper,实时输出日志,并打印测试账号)
+# all 依次执行 build → sign → copy → run
#
-# 前置:flutter、sing-box(brew install sing-box)、已在 Xcode 配好签名团队。
-# TUN 需 root → 脚本会装一条 NOPASSWD sudoers(一次性,可能让你输一次电脑密码)。
-# 正式版会用 SMJobBless 特权 Helper 取代 sudo(BACKLOG-11D-HELPER)。
+# 用法: scripts/local_test.sh all | scripts/local_test.sh build ...
set -euo pipefail
-REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+# ─────────── 配置(按需改)───────────
+API_URL="http://103.119.13.48:8080" # 联调控制面;发版改这里或走默认
+SIGN_ID="Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)"
+APP_PROFILE_NAME="Pangolin App DevID" # 主 app 的 Developer ID 描述文件名
+SE_PROFILE_NAME="Pangolin PacketTunnel DevID" # PacketTunnel 的描述文件名
+TEST_EMAIL="wang880812@gmail.com" # 联调测试账号
+TEST_PASSWORD="pangolin2026"
+NOTARY_PROFILE="pangolin-notary" # notarytool 凭据(已存入钥匙串)
+# 注:DevID profile 已含 system-extension.install + NE(-systemextension 变体);
+# app/sysext entitlements 与之对齐(见 write_entitlements)。
+
+# ─────────── 路径推导 ───────────
+SRC="${BASH_SOURCE[0]}"
+DIR="${SRC%/*}"; [ "$DIR" = "$SRC" ] && DIR="."
+cd "$DIR/.."; REPO_ROOT="$PWD"
CLIENT="$REPO_ROOT/client"
-API_URL="${PANGOLIN_API_URL:-http://107.172.55.251:8080}"
-SINGBOX="${SINGBOX_BIN:-/opt/homebrew/bin/sing-box}"
-NO_BUILD=0
-[ "${1:-}" = "--no-build" ] && NO_BUILD=1
+APP="$CLIENT/build/macos/Build/Products/Release/pangolin_vpn.app"
+SE="$APP/Contents/Library/SystemExtensions/PacketTunnel.systemextension"
+LIBFW="$SE/Contents/Frameworks/Libbox.framework"
+PROF_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
+WORK="${TMPDIR:-/tmp}/pangolin_local_test"; mkdir -p "$WORK"
-command -v flutter >/dev/null 2>&1 || { echo "✗ 需要 flutter"; exit 1; }
-[ -x "$SINGBOX" ] || { echo "✗ 找不到 sing-box: $SINGBOX(brew install sing-box,或设 SINGBOX_BIN)"; exit 1; }
+log(){ printf '\033[1;33m== %s ==\033[0m\n' "$*"; }
+die(){ printf '\033[1;31m❌ %s\033[0m\n' "$*" >&2; exit 1; }
-# ── 1. TUN 免密 sudoers(已生效则跳过,避免反复要密码)─────────────────────────
-echo "==> [1/3] sing-box 免密 sudo(TUN 需 root)"
-if sudo -n "$SINGBOX" version >/dev/null 2>&1; then
- echo " 已生效,跳过(无需密码)"
-else
- SUDO_FILE=/etc/sudoers.d/pangolin-singbox
- WANT="$(whoami) ALL=(root) NOPASSWD: $SINGBOX"
- TMP="$(mktemp)"
- printf '%s\n' "$WANT" > "$TMP"
- if sudo visudo -cf "$TMP" >/dev/null 2>&1; then
- sudo install -m 440 -o root -g wheel "$TMP" "$SUDO_FILE"
- echo " 已写入 $SUDO_FILE: $WANT"
- else
- echo "✗ sudoers 语法校验失败"; rm -f "$TMP"; exit 1
- fi
- rm -f "$TMP"
-fi
+# 按 Name 在描述文件目录里定位 .provisionprofile(结果写入全局 FOUND)。
+FOUND=""
+find_profile(){
+ local want="$1" f
+ for f in "$PROF_DIR"/*.provisionprofile; do
+ [ -e "$f" ] || continue
+ if security cms -D -i "$f" 2>/dev/null | grep -q ">$want<"; then
+ FOUND="$f"; return 0
+ fi
+ done
+ return 1
+}
-# ── 2. 构建 release(--no-build 跳过,直接跑上次产物)─────────────────────────
-if [ "$NO_BUILD" = "1" ]; then
- echo "==> [2/3] 跳过构建(--no-build)"
-else
- echo "==> [2/3] flutter build macos --release (API=$API_URL)"
- ( cd "$CLIENT" && flutter build macos --release --dart-define=PANGOLIN_API_URL="$API_URL" )
-fi
+write_entitlements(){
+ cat > "$WORK/app.entitlements" <<'PLIST'
+
+
+
+ com.apple.application-identifierBYL4KQHMTN.com.pangolin.pangolin
+ com.apple.developer.team-identifierBYL4KQHMTN
+ com.apple.developer.system-extension.install
+ com.apple.developer.networking.networkextension
+ packet-tunnel-provider-systemextension
+ com.apple.security.app-sandbox
+ com.apple.security.network.client
+ com.apple.security.network.server
+ keychain-access-groups
+ BYL4KQHMTN.com.pangolin.pangolin
+
+PLIST
+ cat > "$WORK/sysext.entitlements" <<'PLIST'
+
+
+
+ com.apple.application-identifierBYL4KQHMTN.com.pangolin.pangolin.PacketTunnel
+ com.apple.developer.team-identifierBYL4KQHMTN
+ com.apple.developer.networking.networkextension
+ packet-tunnel-provider-systemextension
+ com.apple.security.app-sandbox
+ com.apple.security.application-groups
+ group.com.pangolin.pangolin
+
+PLIST
+}
-APP="$(ls -d "$CLIENT"/build/macos/Build/Products/Release/*.app 2>/dev/null | head -1)"
-[ -n "$APP" ] || { echo "✗ 未找到构建产物 .app"; exit 1; }
-EXE="$APP/Contents/MacOS/$(basename "$APP" .app)"
+cs(){ codesign --force --options runtime --timestamp "$@"; }
-# ── 3. 前台运行(日志直出)────────────────────────────────────────────────────
-echo "==> [3/3] 运行 release 包(前台,日志直出;Ctrl+C 退出)"
-echo " app : $APP"
-echo " 内核: 客户端会解析到 $SINGBOX(已加进默认查找路径)"
-echo " 连上后另开终端验证出口: curl https://api.ipify.org → 期望 ${API_URL#http://}"
-echo "------------------------------------------------------------------"
-exec "$EXE"
+cmd_build(){
+ log "构建 release(API=$API_URL)"
+ cd "$CLIENT"
+ flutter build macos --release --dart-define="PANGOLIN_API_URL=$API_URL"
+ [ -d "$APP" ] || die "构建产物不存在: $APP"
+}
+
+cmd_sign(){
+ [ -d "$APP" ] || die "先 build:找不到 $APP"
+ log "Developer ID 重签"
+ write_entitlements
+ find_profile "$APP_PROFILE_NAME" || die "找不到描述文件: $APP_PROFILE_NAME"
+ local app_prof="$FOUND"
+ find_profile "$SE_PROFILE_NAME" || die "找不到描述文件: $SE_PROFILE_NAME"
+ local se_prof="$FOUND"
+ echo " app profile : $app_prof"
+ echo " sysext prof : $se_prof"
+
+ xattr -cr "$APP"
+ cp "$app_prof" "$APP/Contents/embedded.provisionprofile"
+ cp "$se_prof" "$SE/Contents/embedded.provisionprofile"
+
+ # 内向外:Libbox 真二进制 → Libbox.framework → sysext → app 各 framework → app 主体
+ cs -s "$SIGN_ID" "$LIBFW/Versions/A/Libbox"
+ cs -s "$SIGN_ID" "$LIBFW"
+ cs --entitlements "$WORK/sysext.entitlements" -s "$SIGN_ID" "$SE"
+ local item
+ for item in "$APP/Contents/Frameworks/"*; do
+ [ -e "$item" ] && cs -s "$SIGN_ID" "$item"
+ done
+ cs --entitlements "$WORK/app.entitlements" -s "$SIGN_ID" "$APP"
+
+ codesign --verify --deep --strict "$APP" || die "深度校验失败"
+ echo " ✅ 重签 + 校验通过($SIGN_ID)"
+}
+
+cmd_notarize(){
+ [ -d "$APP" ] || die "先 build/sign:找不到 $APP"
+ log "公证(notarytool submit --wait,通常 1-5 分钟)"
+ local zip="$WORK/pangolin_vpn.zip"
+ rm -f "$zip"
+ ditto -c -k --keepParent "$APP" "$zip"
+ xcrun notarytool submit "$zip" --keychain-profile "$NOTARY_PROFILE" --wait
+ log "staple 票据到 app"
+ xcrun stapler staple "$APP"
+ xcrun stapler validate "$APP" && echo " ✅ 已公证 + staple"
+}
+
+cmd_copy(){
+ [ -d "$APP" ] || die "先 build/sign:找不到 $APP"
+ log "安装到 /Applications"
+ osascript -e 'quit app "pangolin_vpn"' 2>/dev/null || true
+ pkill -x pangolin_vpn 2>/dev/null || true
+ sleep 1
+ rm -rf /Applications/pangolin_vpn.app
+ cp -R "$APP" /Applications/
+ echo " ✅ /Applications/pangolin_vpn.app"
+}
+
+cmd_run(){
+ log "启动 /Applications/pangolin_vpn.app(直跑,日志见下)"
+ echo ""
+ echo " 测试账号: $TEST_EMAIL"
+ echo " 测试密码: $TEST_PASSWORD"
+ echo ""
+ exec /Applications/pangolin_vpn.app/Contents/MacOS/pangolin_vpn
+}
+
+case "${1:-}" in
+ build) cmd_build ;;
+ sign) cmd_sign ;; # 旧:手动 Developer ID 重签(现已由 Xcode 工程配置直接签,通常不需要)
+ notarize) cmd_notarize ;;
+ copy) cmd_copy ;;
+ run) cmd_run ;;
+ # Xcode 工程已配 Developer ID 手动签名(Release 配置),build 即出 DevID 包,无需 sign。
+ all) cmd_build; cmd_notarize; cmd_copy; cmd_run ;;
+ *) echo "用法: $0 {all|build|sign|notarize|copy|run}"; exit 2 ;;
+esac
From 833912bb588b4fccbb406d9dd02aca748051f100 Mon Sep 17 00:00:00 2001
From: wangjia <809946525@qq.com>
Date: Sun, 21 Jun 2026 21:34:22 +0800
Subject: [PATCH 2/8] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E5=BA=9F?=
=?UTF-8?q?=E5=BC=83=20skill=20.claude/skills/pangolin-design.md?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
设计还原统一走全局 design-distill skill,项目内副本不再维护。
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c
---
.claude/skills/pangolin-design.md | 48 -------------------------------
1 file changed, 48 deletions(-)
delete mode 100644 .claude/skills/pangolin-design.md
diff --git a/.claude/skills/pangolin-design.md b/.claude/skills/pangolin-design.md
deleted file mode 100644
index 2ae62ea..0000000
--- a/.claude/skills/pangolin-design.md
+++ /dev/null
@@ -1,48 +0,0 @@
----
-name: pangolin-design
-description: Use this skill to generate well-branded interfaces and assets for 穿山甲 (Pangolin) — for production (Flutter client, Go backend, web) or throwaway prototypes/mocks. Contains the full design system (tokens, type, brand assets), four React UI kits (mobile / desktop / website / usercenter), a ready-to-run Flutter token+widget package, and a backend architecture blueprint. Invoke whenever building or reproducing any Pangolin surface.
-user-invocable: true
----
-
-# 穿山甲 · Pangolin — 设计与实现技能
-
-这套技能让你(含 Claude Code)**完全还原**穿山甲的设计并落地为产品。UI 以 `ui_kits/` 的 React 原型为像素基准;客户端生产代码用 Flutter;后端按 `server/ARCHITECTURE.md` 蓝本实现。
-
-## 第一步:必读(顺序固定)
-1. **`CLAUDE.md`** — 设计铁律 13 条(§1)、套餐口径单一来源(§7)、页面清单(§5)、**实施工作步骤(§9)**。先读它,§9 就是工作流。
-2. **`README.md`** — 品牌语境、内容基础、视觉基础、图标系统、文件索引。
-3. 按需:`colors_and_type.css`(令牌真相源)、`flutter/README.md`(Flutter 接入)、`server/ARCHITECTURE.md`(后端)。
-
-## 这是什么品牌
-**穿山甲 / Pangolin** — 极简、轻量、亲和的跨平台消费级**网络加速应用**(对外一律不用"VPN"等红线词,见 CLAUDE.md 铁律 13)。暖大地色(穿山甲鳞甲)+ 大量留白 + 双语单显 + 深浅双主题。核心卖点:一键连接、智能选线、即开即用。
-
-## 仓库地图
-| 路径 | 内容 | 用途 |
-|---|---|---|
-| `CLAUDE.md` | 铁律 / 套餐口径 / 页面清单 / **工作步骤 §9** | 一切工作的总纲 |
-| `colors_and_type.css` | CSS 令牌(唯一真相源) | HTML/Web 直接链入 |
-| `flutter/` | Dart 令牌镜像 + widgets + pubspec + main.dart | Flutter 客户端起步包,拿来即跑 |
-| `ui_kits/mobile/` | 移动 App 完整原型(登录/引导/4 Tab/账户子页/滑动切换) | 客户端像素验收标准 |
-| `ui_kits/desktop/` | 桌面客户端(920×600 侧栏布局 + 登录/引导) | 同上(桌面) |
-| `ui_kits/website/` | 官网(产品/定价/下载/文档/Blog,响应式+i18n) | 官网迁移样板 |
-| `ui_kits/usercenter/` | Web 用户中心(概览/订阅导入/兑换/邀请/设置含 2FA,移动适配) | 用户中心样板 |
-| `server/ARCHITECTURE.md` | Go 控制面 + WireGuard 数据面蓝本(数据模型/API/流程/实现顺序) | 后端实现总纲 |
-| `assets/` `preview/` | 品牌 SVG / 设计系统 specimen 卡 | 资产与规范预览 |
-
-## 工作步骤(交给 Claude Code 的执行计划)
-完整版见 **CLAUDE.md §9**,摘要:
-
-1. **进场必读**:CLAUDE.md §1 → §7 → §5;再读 `colors_and_type.css` + 目标端的 UI Kit 源码。
-2. **Flutter 客户端**:用 `flutter/` 起步包建工程(pubspec 覆盖 → 拷 theme/widgets/main → 配字体 → `flutter run` 即出演示态);再对照 `ui_kits/mobile/` 逐屏补齐(智能选择推荐卡、免费额度卡+看广告解锁、Tab 滑动切换);先演示数据,后按 `server/ARCHITECTURE.md` §3 契约接 API。
-3. **后端(Go)**:按 `server/ARCHITECTURE.md` §7 模块顺序:openapi → auth → codes → devices → nodes → usage → 管理端。每模块:单测 + OpenAPI 同步 + 双语错误文案 + 脱敏。
-4. **官网 / 用户中心**:以 `ui_kits/website/`、`ui_kits/usercenter/` 为样板迁移到正式框架,保留 i18n 单显、响应式与脱敏文案;用户中心接 me/redeem/devices,2FA 用 TOTP。
-5. **每个界面提交前自查**(完整清单见 CLAUDE.md §9 第 4 步):语义 token 无硬编码色 / 明暗+中英四态验证 / 无红线词 / 套餐数字与 §7 一致 / Lucide 图标无 emoji / 连接键三态与原型一致 / 无 App 内支付。
-
-## 铁律摘要(完整 13 条见 CLAUDE.md §1)
-暖大地色调,**绝不纯黑纯白大面积填充**;主色 clay `#B96A3D`,**绝不蓝紫渐变**;圆角偏大;阴影柔和暖调;**单语言显示**(中/英切换,不并排);状态用色点+文字胶囊(无 emoji);国家用 2 字母码块(无 emoji 国旗);Lucide 细线图标;**App 内无支付**(兑换码+外部渠道:发卡店/USDT/TG/LINE/邮箱);品牌母题=行走穿山甲(拷 `assets/*.svg`,绝不重绘);不堆砌、留白即设计;**全站脱敏**(红线词清单见铁律 13)。
-
-## 套餐口径(单一来源 = CLAUDE.md §7,改数字先改那里)
-注册享 **7 天免费试用**(不限时长节点)→ 之后免费版 **1 个基础节点 + 每日 10 分钟 + 每日使用前看激励视频解锁**;PRO ¥25/月(年付 ¥20/月),80+ 线路,5 设备;团队版 ¥99/月 10 席位。
-
-## 无指令时
-若用户只调用技能未说要做什么:问他要建/设计什么,问几个聚焦问题,作为本品牌专家输出 —— 按需产出 HTML 原型、Flutter 生产代码或后端模块。
From dae321a3f0381e5582259eda0a20ce8613e9d73c Mon Sep 17 00:00:00 2001
From: wangjia <809946525@qq.com>
Date: Sun, 21 Jun 2026 21:34:22 +0800
Subject: [PATCH 3/8] =?UTF-8?q?fix(web):=20clay-500=20=E5=93=81=E7=89=8C?=
=?UTF-8?q?=E4=B8=BB=E8=89=B2=20#FF0000=20=E2=86=92=20#B96A3D=20+=20?=
=?UTF-8?q?=E5=9B=BA=E5=AE=9A=20next=2014.2.35?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- token 重生成:--clay-500 品牌主色从占位红 #FF0000 还原为 clay #B96A3D
(usercenter public + website tokens.gen)
- usercenter package-lock:next / eslint-config-next 从 <14.3.0 固定到 14.2.35
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c
---
web/usercenter/package-lock.json | 4 ++--
web/usercenter/public/colors_and_type.css | 2 +-
web/website/src/styles/tokens.gen.css | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/web/usercenter/package-lock.json b/web/usercenter/package-lock.json
index 05f2231..303acba 100644
--- a/web/usercenter/package-lock.json
+++ b/web/usercenter/package-lock.json
@@ -8,7 +8,7 @@
"name": "pangolin-usercenter",
"version": "0.1.0",
"dependencies": {
- "next": "<14.3.0",
+ "next": "14.2.35",
"react": "18.3.1",
"react-dom": "18.3.1"
},
@@ -17,7 +17,7 @@
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"eslint": "8.57.0",
- "eslint-config-next": "<14.3.0",
+ "eslint-config-next": "14.2.35",
"typescript": "5.5.3"
}
},
diff --git a/web/usercenter/public/colors_and_type.css b/web/usercenter/public/colors_and_type.css
index de641ca..3b05fd7 100644
--- a/web/usercenter/public/colors_and_type.css
+++ b/web/usercenter/public/colors_and_type.css
@@ -23,7 +23,7 @@
--clay-200: #E6C7AC;
--clay-300: #D9A982;
--clay-400: #CC8B5C;
- --clay-500: #FF0000; /* ← brand primary */
+ --clay-500: #B96A3D; /* ← brand primary */
--clay-600: #9E5630;
--clay-700: #7E4426;
--clay-800: #5E331D;
diff --git a/web/website/src/styles/tokens.gen.css b/web/website/src/styles/tokens.gen.css
index bc4b980..43d106b 100644
--- a/web/website/src/styles/tokens.gen.css
+++ b/web/website/src/styles/tokens.gen.css
@@ -23,7 +23,7 @@
--clay-200: #E6C7AC;
--clay-300: #D9A982;
--clay-400: #CC8B5C;
- --clay-500: #FF0000; /* ← brand primary */
+ --clay-500: #B96A3D; /* ← brand primary */
--clay-600: #9E5630;
--clay-700: #7E4426;
--clay-800: #5E331D;
From cebc9a1c4fc6e86a0eb4c50766589eeb7c922c66 Mon Sep 17 00:00:00 2001
From: wangjia <809946525@qq.com>
Date: Mon, 22 Jun 2026 08:57:55 +0800
Subject: [PATCH 4/8] =?UTF-8?q?fix(client/macos+server):=20=E9=9A=A7?=
=?UTF-8?q?=E9=81=93=E8=BF=90=E8=A1=8C=E6=97=B6=E6=89=93=E9=80=9A=E2=80=94?=
=?UTF-8?q?=E2=80=94DNS=20=E5=8A=AB=E6=8C=81=20+=20=E6=AD=BB=E9=94=81/?=
=?UTF-8?q?=E7=A9=BA=E6=8C=87=E9=92=88=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
接续 447f3f4(系统扩展可加载),修复"扩展能起但连上无法上网"的运行时问题,
现已在 macOS 15(cara)端到端连通:出口=节点 IP、国外站可达、DNS 经隧道解析。
服务端(clientconfig.go):
- route.rules 首条加 {"action":"hijack-dns","port":[53]}(排在 LAN 直连规则前)。
否则发往隧道 DNS(172.19.0.2:53)的查询被 172.16.0.0/12 吞去直连,域名解析失败。
sing-box 1.13 按端口劫持(protocol:dns 需先 sniff,不稳)。
客户端——保留三个真 bug 修复:
- startTunnel 的 libbox 启动移到后台队列:避免在 provider 队列同步阻塞,与
openTun→setTunnelNetworkSettings 回调三方死锁(隧道永远卡 connecting)。
- startOrReloadService(options:) 传非空 LibboxOverrideOptions():传 nil 致空指针 SIGSEGV。
- startDefaultInterfaceMonitor 阻塞到首个 path 更新再返回:修 "no available network interface"。
- 清除排障期临时诊断代码;CFBundleVersion 递增(sysextd 按版本去重)。
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c
---
.../PacketTunnel/PacketTunnelProvider.swift | 110 +++++++-----------
client/macos/PacketTunnel/main.swift | 11 --
client/macos/Runner.xcodeproj/project.pbxproj | 12 +-
server/internal/httpapi/clientconfig.go | 7 +-
4 files changed, 53 insertions(+), 87 deletions(-)
diff --git a/client/macos/PacketTunnel/PacketTunnelProvider.swift b/client/macos/PacketTunnel/PacketTunnelProvider.swift
index 4f950fa..02ba177 100644
--- a/client/macos/PacketTunnel/PacketTunnelProvider.swift
+++ b/client/macos/PacketTunnel/PacketTunnelProvider.swift
@@ -26,48 +26,48 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
override func startTunnel(options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void) {
log.info("startTunnel")
- do {
- let rawConfig = try resolveConfig(options)
+ // libbox 启动必须放后台队列:startOrReloadService 会同步回调 openTun →
+ // setTunnelNetworkSettings,其完成回调要在 provider 队列上投递;若在 provider
+ // 队列(NE 调 startTunnel 所在队列)同步跑 startOrReloadService 会三方死锁。
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ guard let self else { return }
+ do {
+ let configContent = try self.resolveConfig(options)
- guard let base = FileManager.default
- .containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
- throw simpleError("no app group container")
+ guard let base = FileManager.default
+ .containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
+ throw simpleError("no app group container")
+ }
+ let work = base.appendingPathComponent("work", isDirectory: true)
+ try? FileManager.default.createDirectory(at: work, withIntermediateDirectories: true)
+
+ let setup = LibboxSetupOptions()
+ setup.basePath = base.path
+ setup.workingPath = work.path
+ setup.tempPath = NSTemporaryDirectory()
+ var setupErr: NSError?
+ LibboxSetup(setup, &setupErr)
+ if let setupErr { throw setupErr }
+
+ let platform = PangolinPlatformInterface(provider: self)
+ self.platform = platform
+
+ var newErr: NSError?
+ guard let server = LibboxNewCommandServer(self, platform, &newErr) else {
+ throw newErr ?? simpleError("LibboxNewCommandServer returned nil")
+ }
+ try server.start()
+ // 必须传非空 options:此版本 libbox 的 StartOrReloadService 会解引用 options,
+ // 传 nil 会在 command_server.go:175 触发 SIGSEGV(空指针)。
+ try server.startOrReloadService(configContent, options: LibboxOverrideOptions())
+ self.commandServer = server
+
+ log.info("startTunnel: service started")
+ completionHandler(nil)
+ } catch {
+ log.error("startTunnel failed: \(error.localizedDescription, privacy: .public)")
+ completionHandler(error)
}
- let work = base.appendingPathComponent("work", isDirectory: true)
- try? FileManager.default.createDirectory(at: work, withIntermediateDirectories: true)
-
- // 【临时】剥掉远程 rule-set(#5);并把 sing-box 日志直写容器 box.log(debug)以定位启动卡点。
- let configContent = Self.stripRemoteRuleSets(
- rawConfig, logOutput: base.appendingPathComponent("box.log").path)
-
- let setup = LibboxSetupOptions()
- setup.basePath = base.path
- setup.workingPath = work.path
- setup.tempPath = NSTemporaryDirectory()
- var setupErr: NSError?
- LibboxSetup(setup, &setupErr)
- if let setupErr { throw setupErr }
-
- let platform = PangolinPlatformInterface(provider: self)
- self.platform = platform
-
- var newErr: NSError?
- guard let server = LibboxNewCommandServer(self, platform, &newErr) else {
- throw newErr ?? simpleError("LibboxNewCommandServer returned nil")
- }
- try server.start()
- // 必须传非空 options:此版本 libbox 的 StartOrReloadService 会解引用 options,
- // 传 nil 会在 command_server.go:175 触发 SIGSEGV(空指针),扩展进程崩溃。
- try server.startOrReloadService(configContent, options: LibboxOverrideOptions())
- self.commandServer = server
-
- log.info("startTunnel: service started")
- completionHandler(nil)
- } catch {
- // 【临时诊断】完整错误写进 stderr.log;并设 public 避免 os_log 脱敏成 。
- FileHandle.standardError.write("startTunnel failed: \(error)\n".data(using: .utf8)!)
- log.error("startTunnel failed: \(error.localizedDescription, privacy: .public)")
- completionHandler(error)
}
}
@@ -100,26 +100,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
guard !content.isEmpty else { throw simpleError("empty sing-box config") }
return content
}
-
- // 【临时】移除配置里的远程 rule-set 定义,以及 route.rules 中引用了 rule_set 的规则。
- // 解析失败则原样返回。正式分流方案应改用本地 .srs(客户端预取)。
- static func stripRemoteRuleSets(_ json: String, logOutput: String) -> String {
- guard let data = json.data(using: .utf8),
- var root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
- var route = root["route"] as? [String: Any] else {
- return json
- }
- if route["rule_set"] != nil { route.removeValue(forKey: "rule_set") }
- if let rules = route["rules"] as? [[String: Any]] {
- route["rules"] = rules.filter { $0["rule_set"] == nil }
- }
- root["route"] = route
- // 【临时诊断】sing-box 日志直写容器 box.log(debug),定位启动卡点。
- root["log"] = ["level": "debug", "timestamp": true, "output": logOutput]
- guard let out = try? JSONSerialization.data(withJSONObject: root),
- let s = String(data: out, encoding: .utf8) else { return json }
- return s
- }
}
// ── LibboxCommandServerHandler:服务端事件回调 ──────────────────────────────
@@ -136,15 +116,7 @@ extension PacketTunnelProvider: LibboxCommandServerHandlerProtocol {
}
func setSystemProxyEnabled(_ enabled: Bool) throws {}
func writeStatus(_ message: LibboxStatusMessage?) {}
- func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {
- // 【临时诊断】把 sing-box 日志写进 stderr.log,定位启动卡点。
- guard let it = messageList else { return }
- while it.hasNext() {
- guard let entry = it.next() else { continue }
- let line = "[box] \(entry.message)\n"
- FileHandle.standardError.write(line.data(using: .utf8) ?? Data())
- }
- }
+ func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {}
func writeConnectionEvents(_ events: LibboxConnectionEvents?) {}
func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) {}
func writeDebugMessage(_ message: String?) {}
diff --git a/client/macos/PacketTunnel/main.swift b/client/macos/PacketTunnel/main.swift
index 9680633..c1fdc59 100644
--- a/client/macos/PacketTunnel/main.swift
+++ b/client/macos/PacketTunnel/main.swift
@@ -7,17 +7,6 @@
import Foundation
import NetworkExtension
-// 【临时诊断】把 stderr/stdout 重定向到 App Group 容器文件,捕获 libbox(Go)的
-// fatal error / panic——系统扩展默认不把 stderr 收进统一日志。诊断完移除。
-if let c = FileManager.default.containerURL(
- forSecurityApplicationGroupIdentifier: "BYL4KQHMTN.com.pangolin.pangolin") {
- let p = c.appendingPathComponent("stderr.log").path
- freopen(p, "a", stderr)
- freopen(p, "a", stdout)
- setvbuf(stderr, nil, _IONBF, 0)
- FileHandle.standardError.write("=== boot \(Date()) ===\n".data(using: .utf8)!)
-}
-
autoreleasepool {
NEProvider.startSystemExtensionMode()
}
diff --git a/client/macos/Runner.xcodeproj/project.pbxproj b/client/macos/Runner.xcodeproj/project.pbxproj
index 88e9832..aca38ec 100644
--- a/client/macos/Runner.xcodeproj/project.pbxproj
+++ b/client/macos/Runner.xcodeproj/project.pbxproj
@@ -634,7 +634,7 @@
baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
- CURRENT_PROJECT_VERSION = 7;
+ CURRENT_PROJECT_VERSION = 13;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -649,7 +649,7 @@
baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
- CURRENT_PROJECT_VERSION = 7;
+ CURRENT_PROJECT_VERSION = 13;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -664,7 +664,7 @@
baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
- CURRENT_PROJECT_VERSION = 7;
+ CURRENT_PROJECT_VERSION = 13;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -935,7 +935,7 @@
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 7;
+ CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -985,7 +985,7 @@
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
CODE_SIGN_IDENTITY = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 7;
+ CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -1034,7 +1034,7 @@
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 7;
+ CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
diff --git a/server/internal/httpapi/clientconfig.go b/server/internal/httpapi/clientconfig.go
index 0882398..246ce13 100644
--- a/server/internal/httpapi/clientconfig.go
+++ b/server/internal/httpapi/clientconfig.go
@@ -126,8 +126,13 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
"tolerance": 50,
}
- // Route: LAN direct;(可选)国内 IP/域名直连;其余 via auto。
+ // Route: DNS 劫持 → LAN direct →(可选)国内直连 → 其余 via auto。
routeRules := []any{
+ // DNS 劫持(sing-box 1.13 action=hijack-dns,按目的端口 53 匹配,不依赖 sniff):
+ // 把发往隧道 DNS(172.19.0.2:53)的查询交给 sing-box DNS 模块解析。必须排在 LAN
+ // 直连规则之前——否则 172.19.x 落在下面的 172.16.0.0/12 里,DNS 会被吞去直连、解析
+ // 失败导致打不开网站(TUN 模式 DNS 劫持是必需项,缺失则隧道连上也无法上网)。
+ map[string]any{"action": "hijack-dns", "port": []int{53}},
map[string]any{
"ip_cidr": []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8"},
"outbound": "direct",
From 2d2baf887cbd005dce3092f82dd6e55d05180fbf Mon Sep 17 00:00:00 2001
From: wangjia <809946525@qq.com>
Date: Mon, 22 Jun 2026 08:58:10 +0800
Subject: [PATCH 5/8] =?UTF-8?q?docs+test:=20macOS=20=E9=9A=A7=E9=81=93?=
=?UTF-8?q?=E8=B8=A9=E5=9D=91=E5=A4=8D=E7=9B=98/=E8=A7=84=E5=88=99=20+=20V?=
=?UTF-8?q?PN=20=E6=B5=8B=E8=AF=95=E8=B0=83=E7=A0=94=20+=20=E9=BB=91?=
=?UTF-8?q?=E7=9B=92=E6=B5=8B=E8=AF=95=E5=B7=A5=E5=85=B7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- docs/macos-sysext-realize-troubleshooting.html:补"运行时"章节(libbox 空指针、
接口时序、队列死锁、DNS 劫持)+ 运行时 checklist。
- docs/vpn-testing-research.md:VPN 测试调研(行业方法论 + GFW 专项 + Pangolin
分层测试项 + 三层测试工具设计)。
- CLAUDE.md:新增「client/ macOS 原生隧道」铁律章节(构建发版/系统扩展 realize 要求/
libbox+NE 集成/配置由服务端渲染/已知坑)。
- scripts/vpn_test.sh:客户端侧黑盒测试工具(出口 IP/DNS/可达性/延迟/吞吐/IPv6 泄漏,
PASS/WARN/FAIL,可远程跑)。
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c
---
CLAUDE.md | 32 +++++
.../macos-sysext-realize-troubleshooting.html | 57 +++++++-
docs/vpn-testing-research.md | 124 +++++++++++++++++
scripts/vpn_test.sh | 131 ++++++++++++++++++
4 files changed, 343 insertions(+), 1 deletion(-)
create mode 100644 docs/vpn-testing-research.md
create mode 100755 scripts/vpn_test.sh
diff --git a/CLAUDE.md b/CLAUDE.md
index 334c57a..e2d6fef 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -82,3 +82,35 @@ cd web/website && npm run gen:tokens
- `client/lib/pangolin_theme.dart` — 只含实现层(`PangolinScheme`/`PangolinText`/`PangolinTheme`),不含 token 数值。
- `design/flutter/` 已删除;Flutter 组件 canonical 实现在 `client/lib/widgets/`,规格在 `design/preview/`。
- **禁止**再向 `design/` 提交 Dart/TS 组件代码副本(会漂移)。
+
+## client/ macOS 原生隧道(PacketTunnel 系统扩展 + 内嵌 libbox)
+
+内嵌 sing-box(`Libbox.xcframework`)的 `NEPacketTunnelProvider` **系统扩展**(站外 Developer ID
+分发)。让它能被 `sysextd` 加载并真正连通踩了一长串坑,**改这块前必读**
+`docs/macos-sysext-realize-troubleshooting.html`。以下是铁律:
+
+**构建 / 发版**
+- 一律走 `scripts/local_test.sh`(`build`/`notarize`/`copy`/`run`):Developer ID 签名 + 公证 + staple。
+- **每次构建必递增 `CFBundleVersion`**(`CURRENT_PROJECT_VERSION`)——否则 `sysextd` 视为同版本**不更新**,装上去跑的还是旧扩展。
+- SIP 开启的机器只接受**已公证**的 sysext;`client/macos/sign_libbox.sh` 在构建期以 Developer ID 重签内嵌 Libbox。
+
+**系统扩展能被 realize 的硬性要求**(缺一即 `code=4` / 静默拒)
+- **自包含**:`PacketTunnel` target 设 `OTHER_LDFLAGS = ""`(切断继承项目级 CocoaPods 链接标志,否则会把 `flutter_secure_storage` 链进扩展);`Libbox.xcframework` **只 Link 不 Embed**(它是静态库)。验证:`otool -L` 扩展二进制应**零 `@rpath` 外部依赖**。
+- **bundle 名 = 标识符**:`PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel`。
+- 扩展 `Info.plist` 必须有 **`NSSystemExtensionUsageDescription`**(网络扩展类别强制,主 app 的不顶用)。
+- **App Group 用 macOS 原生格式 `.`**(`BYL4KQHMTN.com.pangolin.pangolin`,非 iOS 的 `group.` 前缀);`NEMachServiceName` 以其为前缀。
+- 沙箱扩展补 `network.client` / `network.server`;`get-task-allow=false` + 签名加 `--timestamp`。
+
+**libbox / NetworkExtension 集成铁律**(改 `PacketTunnelProvider.swift` 注意)
+- `startTunnel` **必须在后台队列**执行 libbox 启动(`DispatchQueue.global().async`)——否则 `startOrReloadService` 在 provider 队列同步阻塞,与 `openTun → setTunnelNetworkSettings` 回调**三方死锁**(隧道卡 connecting 永不完成)。
+- `startOrReloadService(options:)` **传非空** `LibboxOverrideOptions()`(传 `nil` → libbox 解引用空指针 SIGSEGV,扩展进程崩溃)。
+- `startDefaultInterfaceMonitor` 要**阻塞到首个 path 更新再返回**(否则 sing-box 启动期拿不到默认接口,报 `no available network interface`)。
+
+**配置由服务端渲染,客户端不拼**
+- sing-box 客户端配置由 `server/internal/httpapi/clientconfig.go::BuildClientConfig` 渲染、原样下发。
+- **TUN 模式必须有 DNS 劫持**:`route.rules` 首条 `{"action":"hijack-dns","port":[53]}`(排在 LAN 直连规则前)——否则发往隧道 DNS(172.19.0.2:53)的查询被 `172.16.0.0/12` 吞去直连,域名解析失败,**隧道连上也打不开网站**。
+- REALITY 数据口走 **节点 `endpoint` 的端口**(当前 443;受限网络常封高位端口如 11443,优先 443)。
+
+**已知坑**
+- 开发机若是 **macOS 26 (Tahoe)**:`sysextd` 报 `no policy, cannot allow apps outside /Applications`(app 在 /Applications 也报)是 **Apple 回归**,本机调试需关 SIP 后 `systemextensionsctl developer on`;真实用户(macOS 14/15)不受影响。
+- #5 国内分流:客户端 `smartRoute` → `?split_cn=1` 下发远程 rule-set;**TODO 改本地 `.srs` 预取**,避免启动期下载。
diff --git a/docs/macos-sysext-realize-troubleshooting.html b/docs/macos-sysext-realize-troubleshooting.html
index af7e451..86eb80b 100644
--- a/docs/macos-sysext-realize-troubleshooting.html
+++ b/docs/macos-sysext-realize-troubleshooting.html
@@ -159,6 +159,51 @@ sysext didFinishWithResult ✓ result=0 (completed)
$ systemextensionsctl list
* * BYL4KQHMTN com.pangolin.pangolin.PacketTunnel (1.0/1) [activated enabled]
+5.5 加载之后:从"扩展能起"到"真正连通"(运行时)
+系统扩展能 realize/激活只是第一步。让内嵌的 libbox(sing-box)真正建起隧道、能上网,又踩了四个坑(均在 PacketTunnelProvider.swift / 服务端配置):
+
+
+
运行时 ① libbox startOrReloadService(options:) 传 nil → 空指针崩溃 扩展进程 SIGABRT
+
扩展进程启动 360ms 后自杀(SIGABRT)。把 stderr 重定向到 App Group 容器文件后抓到 Go panic:
+
panic: runtime error: invalid memory address or nil pointer dereference
+libbox.(*CommandServer).StartOrReloadService(server, {config}, 0x0) command_server.go:175
+
此版本 libbox 会解引用第三个 options 参数(虽标 _Nullable)。修复:传非空 LibboxOverrideOptions() 而非 nil。
+
+
+
+
运行时 ② 默认接口监控异步返回 → no available network interface 启动报错
+
startDefaultInterfaceMonitor 启动 NWPathMonitor 后立即返回,但首个 path 回调是异步晚到的;sing-box 紧接着的网络操作拿到的默认接口索引还是 -1。修复:用信号量**阻塞到首个 path 更新再返回**(带 5s 超时),对齐 sing-box-for-apple。
+
+
+
+
运行时 ③ provider 队列三方死锁 → 隧道永远卡 connecting 最隐蔽
+
openTun 打点显示卡在 setTunnelNetworkSettings 的信号量等待,回调永不触发:
+
+- NE 在 provider 队列上调
startTunnel → 同步调 startOrReloadService(阻塞该队列)
+- sing-box 在 Go 线程调 openTun →
sem.wait() 等 setTunnelNetworkSettings 完成
+- 而该完成回调**正要在被阻塞的 provider 队列上投递** → 死锁
+
+
修复:把 libbox 启动整段放进后台队列(DispatchQueue.global().async),startTunnel 立即返回、provider 队列腾出来投递回调。
+
+
+
+
运行时 ④ 缺 DNS 劫持 → 隧道连上但打不开网站 最后一关
+
隧道起来了、TCP 能经 REALITY 出海,但域名打不开。box.log 显示发往隧道 DNS 的查询走了直连:
+
inbound packet connection to 172.19.0.2:53
+router: match ip_cidr=[..172.16.0.0/12..] => route(direct) # DNS 被 LAN 规则吞去直连 → 解析失败
+
隧道 DNS 地址 172.19.0.2 落在 LAN 直连规则 172.16/12 内,被路由成直连(发往不存在的主机)→ 解析全失败。修复(服务端 clientconfig.go):route.rules 首条加 {"action":"hijack-dns","port":[53]}(排在 LAN 规则之前),把 :53 查询交给 sing-box DNS 模块。
+
注:sing-box 1.13 的 protocol:"dns" 匹配需先 sniff;按目的端口 53 匹配最稳、不依赖 sniff。
+
+
+
+
另一个发版必知:每次构建必递增 CFBundleVersion。 sysextd 按(标识符, 版本)去重;同版本号重装**不会替换**已激活的旧扩展,跑的还是旧代码(排查时极易被误导)。
+
+
+连通验证(从命令行):
+$ curl https://api.ipify.org → 103.119.13.48 # 出口=节点 IP,流量走隧道
+$ curl -o/dev/null -w '%{http_code}' https://github.com → 200
+box.log: router: match[0] port=53 => hijack-dns → dns: exchanged A github.com 140.82.116.3
+
6. 给后人的排查 checklist(Developer ID 网络系统扩展)
- 先找同模型的能用实现对照(Tailscale 独立版 / Mullvad),在同一台机器上验证环境没问题,把范围锁到自己的包。
@@ -178,8 +223,18 @@ $ systemextensionsctl list
systemextensionsctl developer on,或在 15/14 机器上验证。
+8. 运行时 checklist(libbox + NE 集成)
+
+- libbox
startOrReloadService(options:) 传**非空** LibboxOverrideOptions(),别传 nil。
+startTunnel 里的 libbox 启动放**后台队列**(DispatchQueue.global().async),别在 provider 队列同步跑(死锁)。
+startDefaultInterfaceMonitor **阻塞到首个 path 更新再返回**。
+- TUN 配置必须有 **DNS 劫持**(
action:hijack-dns,按 port 53),排在 LAN/分流规则之前。
+- **每次构建递增
CFBundleVersion**,否则 sysextd 不更新已激活的扩展。
+- 看 libbox 自身日志:配置
log.output 指向容器文件(writeLogs 回调启动期不触发);Go fatal 走 stderr,需把扩展 stderr 重定向到文件才看得到。
+
+
-改动文件:client/macos/PacketTunnel/Info.plist · client/macos/PacketTunnel/PacketTunnel.entitlements · client/macos/Runner/Release.entitlements · client/macos/PacketTunnel/PacketTunnelProvider.swift · client/macos/Runner.xcodeproj/project.pbxproj(OTHER_LDFLAGS/PRODUCT_NAME/移除 Embed Libbox/加 timestamp)。验证机:cara,干净 macOS 15.3.2 Intel。
+改动文件:扩展 Info.plist/entitlements/main.swift/PacketTunnelProvider.swift · Runner/Release.entitlements · Runner.xcodeproj/project.pbxproj(OTHER_LDFLAGS/PRODUCT_NAME/移除 Embed Libbox/--timestamp/版本号) · scripts/local_test.sh · client/macos/sign_libbox.sh · 服务端 server/internal/httpapi/clientconfig.go(DNS 劫持)。验证机:cara,干净 macOS 15.3.2 Intel,出口 IP=节点、国外站可达。