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"。 +表面像"找不到扩展",实则是 三个叠加的工程配置 bugsysextd 在 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_LDFLAGSPacketTunnel 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,而 CFBundleIdentifiercom.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 网络系统扩展)

+
    +
  1. 先找同模型的能用实现对照(Tailscale 独立版 / Mullvad),在同一台机器上验证环境没问题,把范围锁到自己的包。
  2. +
  3. 扩展二进制 otool -L 必须@rpath 外部依赖(自包含)。警惕 CocoaPods/Flutter 的 OTHER_LDFLAGS 继承。
  4. +
  5. 扩展 bundle 名 = CFBundleIdentifier(设 PRODUCT_NAME = 标识符)。
  6. +
  7. 扩展 Info.plist 必须有 NSSystemExtensionUsageDescription(不是主 app 的)。
  8. +
  9. App Group 用 macOS 原生 <TeamID>.<name>;NEMachServiceName 以该 group 为前缀。
  10. +
  11. 沙箱扩展按需补 network.client/server、文件访问等能力。
  12. +
  13. 静态库 xcframework 只 LinkEmbed;动态 framework 才需 Embed + CodeSignOnCopy。
  14. +
  15. 排查时:log stream --debug --predicate 'process=="sysextd" OR process=="syspolicyd"',看卡在 realize / staging / validating_by_category 哪一步;category 校验失败会打出明确缺什么键。
  16. +
+ +

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