Merge branch 'main' into worktree-monitor-debounce

# Conflicts:
#	scripts/vpn_test.py
This commit is contained in:
wangjia
2026-06-22 15:08:11 +08:00
56 changed files with 2312 additions and 298 deletions
-48
View File
@@ -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 生产代码或后端模块。
+9
View File
@@ -67,3 +67,12 @@ client/pubspec.lock
/server/server
/server/nodectl
/server/agent
# 字体子集生成的中间产物(源字体/临时字表;产物 client/fonts/ 才入库)
tools/fonts/_*
# golden 测试失败时生成的对比图(临时产物)
client/test/golden/failures/
# Inno Setup 编译产物(安装包,本地生成不入库)
client/windows/installer/Output/
+32
View File
@@ -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 原生格式 `<TeamID>.<name>`**(`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` 预取**,避免启动期下载。
+27 -35
View File
@@ -32,7 +32,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Wintun ZIP SHA256(对应 WINTUN_VERSION=0.14.1)─────────────────────────
# 更新 WINTUN_VERSION 时须同步更新此值:
# curl -sL https://www.wintun.net/builds/wintun-<ver>.zip | sha256sum
WINTUN_SHA256="90a6c2a17e62685b571c9afc2d5a6e7dc59c74d3fde3ad66b4ab4e2e4b0d649a"
WINTUN_SHA256="07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51"
# ── 参数解析 ─────────────────────────────────────────────────────────────────
if [[ $# -lt 2 ]]; then
@@ -82,7 +82,19 @@ ARCHIVE_FILE="${ARCHIVE_STEM}.${ARCHIVE_EXT}"
GITHUB_BASE="https://github.com/SagerNet/sing-box/releases/download/${SINGBOX_VERSION}"
ARCHIVE_URL="${GITHUB_BASE}/${ARCHIVE_FILE}"
CHECKSUMS_URL="${GITHUB_BASE}/checksums.txt"
# ── sing-box 压缩包 SHA256(内置 pin)──────────────────────────────────────────
# sing-box 自 v1.13 起的 Release 不再发布 checksums.txt,故改为脚本内置各压缩包
# 的 SHA256。更新 SINGBOX_VERSION 时必须同步更新下表(case 键含版本号,未更新会
# 命中 *) 分支并报错,以防用错版本的哈希):
# curl -fsSL "${GITHUB_BASE}/<archive>" | shasum -a 256
EXPECTED_HASH=""
case "${ARCHIVE_FILE}" in
sing-box-1.13.12-windows-amd64.zip) EXPECTED_HASH="e93fc531134eb1beb4efa3c74990a24e48456098a31c03b60d5ddf17f223cf98" ;;
sing-box-1.13.12-darwin-arm64.tar.gz) EXPECTED_HASH="43eef86f0ea4a79c3696974f397a963c46a457ee46d1ffac9aa913944a5fc986" ;;
sing-box-1.13.12-darwin-amd64.tar.gz) EXPECTED_HASH="f3275316451bf1983bc059599c69c8ed0232d53a619d15cfd535f95cc9a4477a" ;;
sing-box-1.13.12-linux-amd64.tar.gz) EXPECTED_HASH="1540533adb3df24f5ad5f14b5c7ca3dbc2401b10a1c1eb278fcadcada47ec6c4" ;;
esac
OUT_BIN="${DIST_DIR}/${BIN_NAME}"
@@ -104,23 +116,12 @@ _sha256() {
fi
}
# ── 幂等检查(已有产物则校验 SHA256)────────────────────────────────────────
# ── 幂等检查(已有产物则跳过;--force 强制重下)────────────────────────────────
if [[ "${FORCE}" == false && -f "${OUT_BIN}" ]]; then
CACHED_SUMS="${DIST_DIR}/checksums.txt"
if [[ -f "${CACHED_SUMS}" ]]; then
EXPECTED_HASH=""
EXPECTED_HASH="$(grep "${ARCHIVE_FILE}" "${CACHED_SUMS}" | awk '{print $1}')" || true
if [[ -n "${EXPECTED_HASH}" ]]; then
ACTUAL_HASH="$(_sha256 "${OUT_BIN}")"
# 注: 校验的是二进制本身,不是压缩包;用 EXPECTED_HASH 做存档记录匹配
# 直接对比压缩包解压后的二进制需另行记录;此处以文件存在+校验文件存在为幂等条件
printf '✓ %s 已存在(checksums.txt 已缓存),跳过下载\n' "${OUT_BIN}"
printf ' 当前 SHA256: %s\n' "${ACTUAL_HASH}"
printf ' (传 --force 强制重新下载并校验压缩包)\n'
exit 0
fi
fi
printf '✓ %s 已存在但无校验缓存,继续下载验证…\n' "${OUT_BIN}"
printf '✓ %s 已存在,跳过下载\n' "${OUT_BIN}"
printf ' 当前 SHA256: %s\n' "$(_sha256 "${OUT_BIN}")"
printf ' (传 --force 强制重新下载并校验压缩包)\n'
exit 0
fi
# ── 创建目录 ─────────────────────────────────────────────────────────────────
@@ -128,13 +129,14 @@ mkdir -p "${DIST_DIR}"
mkdir -p "${CACHE_DIR}"
ARCHIVE_CACHE="${CACHE_DIR}/${ARCHIVE_FILE}"
CHECKSUMS_CACHE="${CACHE_DIR}/checksums-${VERSION_BARE}.txt"
# ── 下载 checksums.txt ───────────────────────────────────────────────────────
printf '==> 下载 checksums.txt for %s…\n' "${SINGBOX_VERSION}"
curl -fSL --retry 3 --retry-delay 2 \
-o "${CHECKSUMS_CACHE}" \
"${CHECKSUMS_URL}"
# 未在内置 pin 表中找到该压缩包的 SHA256(多半是 SINGBOX_VERSION 升级后没同步哈希)
if [[ -z "${EXPECTED_HASH}" ]]; then
printf '✗ 脚本内未内置 %s 的 SHA256\n' "${ARCHIVE_FILE}" >&2
printf ' 升级 SINGBOX_VERSION 后需在本脚本 case 表补该压缩包哈希:\n' >&2
printf ' curl -fsSL "%s" | shasum -a 256\n' "${ARCHIVE_URL}" >&2
exit 1
fi
# ── 下载二进制压缩包 ──────────────────────────────────────────────────────────
printf '==> 下载 %s…\n' "${ARCHIVE_FILE}"
@@ -142,16 +144,9 @@ curl -fSL --retry 3 --retry-delay 2 \
-o "${ARCHIVE_CACHE}" \
"${ARCHIVE_URL}"
# ── SHA256 校验(对压缩包)───────────────────────────────────────────────────
# ── SHA256 校验(对压缩包,比对内置 pin──────────────────────────────────────
printf '==> 校验 SHA256…\n'
EXPECTED_HASH=""
EXPECTED_HASH="$(grep "${ARCHIVE_FILE}" "${CHECKSUMS_CACHE}" | awk '{print $1}')"
if [[ -z "${EXPECTED_HASH}" ]]; then
printf '✗ checksums.txt 中未找到 %s 的校验值\n' "${ARCHIVE_FILE}" >&2
exit 1
fi
ACTUAL_HASH="$(_sha256 "${ARCHIVE_CACHE}")"
if [[ "${ACTUAL_HASH}" != "${EXPECTED_HASH}" ]]; then
printf '✗ SHA256 不匹配!\n' >&2
@@ -187,9 +182,6 @@ fi
cp "${INNER_DIR}/${BIN_NAME}" "${OUT_BIN}"
chmod 755 "${OUT_BIN}"
# ── 缓存 checksums.txt 到产物目录(供幂等复查)────────────────────────────────
cp "${CHECKSUMS_CACHE}" "${DIST_DIR}/checksums.txt"
printf '\n✓ sing-box 二进制: %s\n' "${OUT_BIN}"
printf ' 版本验证: %s version\n' "${OUT_BIN}"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -213,8 +213,8 @@ class DesktopVpnBridge implements VpnBridge {
_reconnectTimer = null;
_kernelStatusSub?.cancel();
_kernelStatusSub = null;
if (_kernel is DesktopKernelProcess) {
(_kernel as DesktopKernelProcess).dispose();
if (_kernel case final DesktopKernelProcess k) {
k.dispose();
}
}
@@ -350,7 +350,7 @@ class DesktopVpnBridge implements VpnBridge {
Future<Directory> _resolveConfigDir() async {
if (_configDirOverride != null) {
return Directory(_configDirOverride!);
return Directory(_configDirOverride);
}
return _defaultConfigDir();
}
+3
View File
@@ -121,6 +121,9 @@ class VpnBridgeMock implements VpnBridge {
_killSwitch = on;
}
/// 测试可读:mock 记录的 kill-switch 状态。
bool get killSwitchEnabled => _killSwitch;
@override
Stream<VpnStatus> get statusStream => _statusCtrl.stream;
+1 -1
View File
@@ -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<VpnBridge>((ref) {
+6
View File
@@ -5,6 +5,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart';
import 'bridge/vpn_bridge_provider.dart';
import 'pangolin_theme.dart';
@@ -25,6 +26,11 @@ Future<void> main() async {
exit(0); // 已有实例在跑(已唤起其窗口),本进程退出。
}
await TrayService.instance.init();
// 桌面窗口尺寸:默认 920×640;最小宽 720(低于 form_factor 的 680 阈值会
// 掉进 MobileShell,侧栏消失)。init() 内已 windowManager.ensureInitialized()。
await windowManager.setMinimumSize(const Size(720, 560));
await windowManager.setSize(const Size(920, 640));
await windowManager.center();
}
runApp(const ProviderScope(child: PangolinApp()));
}
+16 -12
View File
@@ -6,7 +6,7 @@
// · PangolinScheme — ThemeExtension(语义色映射 + light/dark 实例)
// · PangolinContext — BuildContext 扩展
// · PangolinFonts — 字族标签常量
// · PangolinText — google_fonts 运行时字型
// · PangolinText — 本地打包字型(生产 useBundled,离线;测试经 FontLoader
// · PangolinTheme — ThemeData 组装
//
// Usage:
@@ -144,9 +144,10 @@ extension PangolinContext on BuildContext {
}
/// ── Font families ───────────────────────────────────────────────────
/// 生产用 google_fonts 运行时加载;测试态设 [useBundled]=true,改用本地
/// FontLoader 注册的同名 family(避免 google_fonts 在无网/禁拉取时抛异常,
/// 让 golden 截图回归可跑)。封装函数是 PangolinText/_build 的唯一字体入口。
/// 生产用本地打包字体([useBundled]=truepubspec fonts: 段,离线);保留的
/// [useBundled]=false 分支走 google_fonts 运行时加载,仅作回退/对照。测试态由
/// flutter_test_config 经 FontLoader 注册同名 family。封装函数是 PangolinText/
/// _build 的唯一字体入口。
class PangolinFonts {
PangolinFonts._();
static const display = 'Sora';
@@ -154,12 +155,14 @@ class PangolinFonts {
static const cjk = 'Noto Sans SC';
static const mono = 'JetBrains Mono';
/// 测试钩子:true 时用 bundled familytest/fonts/ 经 FontLoader 注册),不走 google_fonts
static bool useBundled = false;
/// 生产默认 true:用本地打包 ttfpubspec fonts: 段),离线可用,不走 google_fonts
/// 运行时拉取。测试态由 flutter_test_config.dart 经 FontLoader 注册 test/fonts/。
/// 中文经 [cjk]Noto Sans SC 子集)走 fontFamilyFallback;子集外字符再兜系统 CJK 字体。
static bool useBundled = true;
static TextStyle sora({double? fontSize, FontWeight? fontWeight, double? height, double? letterSpacing}) =>
useBundled
? TextStyle(fontFamily: display, fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing)
? TextStyle(fontFamily: display, fontFamilyFallback: const [cjk], fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing)
: GoogleFonts.sora(fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing);
static TextStyle manrope(
@@ -167,6 +170,7 @@ class PangolinFonts {
useBundled
? TextStyle(
fontFamily: sans,
fontFamilyFallback: const [cjk],
fontSize: fontSize,
fontWeight: fontWeight,
height: height,
@@ -177,17 +181,17 @@ class PangolinFonts {
static TextStyle jetBrainsMono({double? fontSize, FontWeight? fontWeight, List<FontFeature>? fontFeatures}) =>
useBundled
? TextStyle(fontFamily: mono, fontSize: fontSize, fontWeight: fontWeight, fontFeatures: fontFeatures)
? TextStyle(fontFamily: mono, fontFamilyFallback: const [cjk], fontSize: fontSize, fontWeight: fontWeight, fontFeatures: fontFeatures)
: GoogleFonts.jetBrainsMono(fontSize: fontSize, fontWeight: fontWeight, fontFeatures: fontFeatures);
static TextTheme manropeTextTheme(TextTheme base) =>
useBundled ? base.apply(fontFamily: sans) : GoogleFonts.manropeTextTheme(base);
useBundled ? base.apply(fontFamily: sans, fontFamilyFallback: const [cjk]) : GoogleFonts.manropeTextTheme(base);
}
/// ── Type scale ──────────────────────────────────────────────────────
/// Uses google_fonts for runtime font loading (no local ttf required).
/// All PangolinText members are static getters (not const) so they can
/// reference google_fonts, which registers fonts dynamically.
/// Uses locally-bundled fonts via [PangolinFonts] (production `useBundled=true`,
/// offline; tests register fonts via FontLoader). Members are static getters
/// (not const) so they route through the [PangolinFonts] wrapper functions.
class PangolinText {
PangolinText._();
+8 -1
View File
@@ -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(),
),
]),
],
);
+3 -2
View File
@@ -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',
);
+6 -1
View File
@@ -99,15 +99,20 @@ class AuthApi {
Future<http.Response> _post(String path, Map<String, dynamic> 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: '网络请求失败,请检查连接后重试',
+30 -15
View File
@@ -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<ConnectionState> {
Future<void> _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<ConnectionState> {
}
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<ConnectionState> {
}
}
/// 取配置;access token 过期(401)时用 refresh token 续期后**重试一次**。
/// 续期失败(refresh 也过期 / 被拒)由 authProvider.refresh() 触发登出 → UI 回登录页。
Future<String> _fetchConfigWithRefresh(String nodeUuid) async {
Future<String> 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<void> _disconnect() async {
_stopElapsed();
try {
+3 -6
View File
@@ -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<AuthScreen>
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);
-1
View File
@@ -8,7 +8,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../pangolin_theme.dart';
import '../state/app_providers.dart';
import '../state/navigation_provider.dart';
import 'pangolin_icons.dart';
import 'pangolin_logo.dart';
import 'plan_badge_card.dart';
@@ -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"))
}
+10 -1
View File
@@ -2,12 +2,21 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 网络扩展类别强制要求:扩展自己的 Info.plist 必须有此键,否则
sysextd category 校验失败并卸载("require the presence of the
'NSSystemExtensionUsageDescription' property")。主 app 的同名键不顶用。 -->
<key>NSSystemExtensionUsageDescription</key>
<string>Pangolin 需要安装网络扩展以提供安全的网络连接。</string>
<!-- 自定义键:声明 NEPacketTunnelProvider。其余标准 CFBundle* 键由
GENERATE_INFOPLIST_FILE=YES 自动生成并合并,此处不重复写以免冲突。 -->
<key>NetworkExtension</key>
<dict>
<!-- NEMachServiceName 必须以扩展所属的某个 App Group 为前缀(Apple 规则)。
App Group 用 macOS 原生格式 BYL4KQHMTN.com.pangolin.pangolin,mach 名 = 该 group
+ .PacketTunnel。参照可工作的 Tailscale:group=<Team>.io.tailscale.ipn.macsys、
mach=该 group + .network-extension。 -->
<key>NEMachServiceName</key>
<string>$(TeamIdentifierPrefix)group.com.pangolin.pangolin</string>
<string>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</string>
<key>NEProviderClasses</key>
<dict>
<key>com.apple.networkextension.packet-tunnel</key>
@@ -2,20 +2,26 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 开发期:App Extension 变体,Apple Development 自动签名即可编/签/本机调试。
⚠️ 上线(站外 Developer ID 分发)时改为 System Extension:
- 把本值换成 packet-tunnel-provider-systemextension
- target 产物类型转 System Extension(.systemextension)
- 用 Developer ID 手动签名 + 公证;首启 systemextensionsctl developer on
Swift 代码两形态一致,仅打包/签名不同。详见 docs/p1-macos-system-extension.md。 -->
<!-- Developer ID 分发 + 公证:禁止调试附加。 -->
<key>com.apple.security.get-task-allow</key>
<false/>
<!-- 沙箱内的 packet tunnel 需显式网络权限才能对外建连/收发(对照可工作的 Tailscale)。 -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- System Extension 形态(站外 Developer ID 分发):用 -systemextension 变体,
与 Developer ID profile(Pangolin PacketTunnel DevID)授权一致。
详见 docs/p1-macos-system-extension.md。 -->
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider</string>
<string>packet-tunnel-provider-systemextension</string>
</array>
<!-- 与主 app 共享配置/状态(同一 App Group) -->
<!-- 与主 app 共享配置/状态(同一 App Group)。macOS 原生格式 <TeamID>.<name>,
非 iOS 的 group. 前缀——sysextd realize 前校验请求方/扩展时要求此格式。 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.pangolin.pangolin</string>
<string>BYL4KQHMTN.com.pangolin.pangolin</string>
</array>
</dict>
</plist>
@@ -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 <TeamID>.<name>( iOS group. )
// sysextd app ; group. realize
private let appGroup = "BYL4KQHMTN.com.pangolin.pangolin"
final class PacketTunnelProvider: NEPacketTunnelProvider {
private var commandServer: LibboxCommandServer?
@@ -24,40 +26,48 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
override func startTunnel(options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void) {
log.info("startTunnel")
do {
let configContent = 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)
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()
try server.startOrReloadService(configContent, options: nil)
self.commandServer = server
log.info("startTunnel: service started")
completionHandler(nil)
} catch {
log.error("startTunnel failed: \(error.localizedDescription)")
completionHandler(error)
}
}
@@ -96,7 +106,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
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) }
@@ -170,6 +180,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 +195,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()
+65 -40
View File
@@ -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 = "<group>"; };
A17B79E22FE50746001ABF28 /* PacketTunnel */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
A17B79EE2FE50746001ABF28 /* Exceptions for "PacketTunnel" folder in "PacketTunnel" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = PacketTunnel;
sourceTree = "<group>";
};
/* 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 = 13;
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 = 13;
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 = 13;
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 = 13;
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 = 13;
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 = 13;
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;
};
@@ -10,6 +10,14 @@
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- P1 方案B:主 app 经 OSSystemExtensionRequest 安装 PacketTunnel sysext,需此权限。 -->
<key>com.apple.developer.system-extension.install</key>
<true/>
<!-- 主 app 经 NETunnelProviderManager 管理 packet-tunnel,需 NE 权限(App ID 已开 Network Extensions)。 -->
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider</string>
</array>
<!-- flutter_secure_storage: Data Protection Keychain 需要此 entitlement,否则返回 -34018 -->
<key>keychain-access-groups</key>
<array>
+3
View File
@@ -28,5 +28,8 @@
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<!-- 安装 PacketTunnel System Extension 的必需键:OSSystemExtensionRequest 审批时展示给用户。 -->
<key>NSSystemExtensionUsageDescription</key>
<string>Pangolin 需要安装系统扩展以提供安全的网络连接。</string>
</dict>
</plist>
+16 -3
View File
@@ -2,20 +2,33 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Developer ID 分发 + 公证:禁止调试附加(否则公证会拒;也避免 Xcode 注入 =true)。 -->
<key>com.apple.security.get-task-allow</key>
<false/>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<!-- P1 方案B:主 app 经 OSSystemExtensionRequest 安装 PacketTunnel sysext,需此权限。 -->
<key>com.apple.developer.system-extension.install</key>
<true/>
<!-- 主 app 经 NETunnelProviderManager 管理 packet-tunnel(System Extension 形态)。
Developer ID profile 授权的是 -systemextension 变体,故用它(非 plain)。 -->
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider-systemextension</string>
</array>
<!-- flutter_secure_storage: Data Protection Keychain 需要此 entitlement,否则返回 -34018 -->
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.pangolin.pangolin</string>
</array>
<!-- P1 方案B:接 System Extension 时在此加(注册 App Group 后,见 p1-macos-system-extension.md §3):
<!-- 与 PacketTunnel sysext 共享 App Group。必须用 macOS 原生格式 <TeamID>.<name>
(BYL4KQHMTN.com.pangolin.pangolin),非 iOS 的 group. 前缀——否则 sysextd 在 realize
暂存前校验请求方 app 时认定 app group 非法而拒绝(参照可工作的 Tailscale)。 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.pangolin.pangolin</string>
<string>BYL4KQHMTN.com.pangolin.pangolin</string>
</array>
-->
</dict>
</plist>
+40 -6
View File
@@ -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 <private>,)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<Void, Error>) 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
}
}
+13
View File
@@ -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: 完成"
+27 -2
View File
@@ -46,5 +46,30 @@ flutter:
- assets/app-icon.svg
- assets/tray_icon.png
# ── 字体(由 google_fonts 运行时加载,无需本地 ttf)──
# 若需离线/打包字体,下载 ttf 放 fonts/,并按 pubspec 模板恢复 fonts: 段。
# ── 字体(本地打包,全平台离线)──
# 拉丁全量(Sora/Manrope/JetBrains Mono)+ Noto Sans SC 子集(GB2312 6763 字,单 Regular
# 权重;CJK 粗体由引擎合成)。子集由 tools/fonts/make-cjk-subset.sh 可复现生成。
# 生产经 PangolinFonts.useBundled=true 走这些 family;中文经 fontFamilyFallback 兜底。
fonts:
- family: Sora
fonts:
- asset: fonts/Sora-Regular.ttf
- asset: fonts/Sora-SemiBold.ttf
weight: 600
- asset: fonts/Sora-Bold.ttf
weight: 700
- family: Manrope
fonts:
- asset: fonts/Manrope-Regular.ttf
- asset: fonts/Manrope-Medium.ttf
weight: 500
- asset: fonts/Manrope-SemiBold.ttf
weight: 600
- asset: fonts/Manrope-Bold.ttf
weight: 700
- family: JetBrains Mono
fonts:
- asset: fonts/JetBrainsMono-Regular.ttf
- family: Noto Sans SC
fonts:
- asset: fonts/NotoSansSC-Regular-subset.otf
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+54
View File
@@ -0,0 +1,54 @@
// fonts_test.dart — 本地打包字体接线回归。
//
// 锁定 Task 3 的字体改造:生产走 bundled family(非 google_fonts 运行时拉取),
// 且每个字体函数都把中文兜到 Noto Sans SCfontFamilyFallback: [cjk])。
// flutter_test_config.dart 在测试启动已置 PangolinFonts.useBundled = true。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/pangolin_theme.dart';
void main() {
group('PangolinFonts 本地打包接线', () {
test('测试态(useBundled=true)由 flutter_test_config 设置', () {
// 该 hook 由 test/flutter_test_config.dart 在所有测试前置 true
// 生产代码默认值也是 true(见 pangolin_theme.dart)。
expect(PangolinFonts.useBundled, isTrue);
});
test('cjk family 为 Noto Sans SC(与 pubspec fonts: 段一致)', () {
expect(PangolinFonts.cjk, 'Noto Sans SC');
});
test('sora() 用 bundled display family 且中文兜底到 cjk', () {
final s = PangolinFonts.sora(fontSize: 16);
expect(s.fontFamily, PangolinFonts.display);
expect(s.fontFamilyFallback, contains(PangolinFonts.cjk));
});
test('manrope() 用 bundled sans family 且中文兜底到 cjk', () {
final s = PangolinFonts.manrope(fontSize: 16);
expect(s.fontFamily, PangolinFonts.sans);
expect(s.fontFamilyFallback, contains(PangolinFonts.cjk));
});
test('jetBrainsMono() 用 bundled mono family 且中文兜底到 cjk', () {
final s = PangolinFonts.jetBrainsMono(fontSize: 14);
expect(s.fontFamily, PangolinFonts.mono);
expect(s.fontFamilyFallback, contains(PangolinFonts.cjk));
});
test('manropeTextTheme 应用 sans family 且中文兜底到 cjk', () {
const base = TextTheme(bodyMedium: TextStyle(fontSize: 16));
final t = PangolinFonts.manropeTextTheme(base);
expect(t.bodyMedium?.fontFamily, PangolinFonts.sans);
expect(t.bodyMedium?.fontFamilyFallback, contains(PangolinFonts.cjk));
});
test('PangolinText 标度经由 bundled 接线(中文兜底存在)', () {
// 抽查几个常用标度,确保未绕过 PangolinFonts 封装。
expect(PangolinText.h1.fontFamilyFallback, contains(PangolinFonts.cjk));
expect(PangolinText.body.fontFamilyFallback, contains(PangolinFonts.cjk));
expect(PangolinText.mono.fontFamilyFallback, contains(PangolinFonts.cjk));
});
});
}
+81 -22
View File
@@ -4,24 +4,39 @@ Windows 版与 macOS 桌面同路线:Flutter 应用 + `sing-box.exe` 子进程
接管整机流量(全局代理)。提权方式:应用 manifest 设了 `requireAdministrator`
启动时 UAC 提权,`sing-box.exe` 子进程继承管理员权以创建 TUN 适配器。
> 状态:代码就绪,**未在真 Windows 机器上构建/验证过**(开发在 macOS)。以下为
> 在 Windows 上首次构建运行的步骤
UI 复用桌面 shell`DesktopShell`,原生标题栏 + 侧栏 + 内容区),无 Windows 专属页面。
字体随应用本地打包(拉丁全量 + Noto Sans SC GB2312 子集),离线可用,不依赖联网拉字体
> 状态(2026-06-22):**已在真 Windows 11(23H2)机器上 `flutter build windows` 构建通过**
> 产物结构、内核二进制捆绑、`requireAdministrator` 清单均验证无误;Inno Setup 安装包
> 编译通过。**登录→连接→出网的运行期端到端验证尚需在交互桌面 + 后端节点下人工完成**
> (见末尾「待人工验证」)。
## 前置
- Windows 10/11,已装 Flutter(含 Windows 桌面支持)、Visual StudioC++ 桌面负载)。
- 拉取内核二进制(在仓库根,Git Bash / WSL 下跑;脚本会下载 sing-box.exe + wintun.dll
并校验 SHA256):
- Windows 10/11,已装 Flutter(含 Windows 桌面支持)、Visual Studio**Build Tools**
2022C++ 桌面负载)。
- **必须含 ATL 组件**`flutter_secure_storage_windows` 依赖 `atlstr.h`。缺则链接报
`C1083: atlstr.h`。安装:Visual Studio Installer → 修改 → 单个组件 → 勾「C++ ATL
for latest v143 build tools (x86 & x64)」;或命令行(管理员):
```powershell
& "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vs_installer.exe" modify `
--installPath "C:\BuildTools" --add Microsoft.VisualStudio.Component.VC.ATL --quiet --norestart
```
- 拉取内核二进制(仓库根,Git Bash 下跑;脚本下载 sing-box.exe + wintun.dll 并按内置
SHA256 校验):
```bash
app/kernel/fetch-desktop-bin.sh windows amd64
# 产物:app/kernel/dist/desktop/windows-amd64/{sing-box.exe, wintun.dll}
```
> 注:sing-box 自 v1.13 起 Release 不再发布 `checksums.txt`,脚本已改为内置各压缩包
> SHA256 校验(升级 `app/kernel/VERSION` 的 `SINGBOX_VERSION` 时须同步更新脚本内 case 表)。
> 国内网络若下载失败:开代理重试,或手动放置二进制到该目录。
## 开发运行(flutter run
从仓库内直接跑时,`kernel_process.dart` 的 `_resolveBinaryPath` 会从可执行目录向上
查找 `app/kernel/dist/desktop/windows-amd64/sing-box.exe`,无需手动放置:
`kernel_process.dart` 的 `_resolveBinaryPath` 会从可执行目录向上查找
`app/kernel/dist/desktop/windows-amd64/sing-box.exe`,无需手动放置:
```powershell
# 必须用「管理员」终端(TUN 需要管理员权)
@@ -34,25 +49,69 @@ flutter run -d windows --dart-define=PANGOLIN_API_URL=http://<后端IP>:8080
## 打包(release
`flutter build windows` 后,CMake 的 install 步骤会自动把
`app/kernel/dist/desktop/windows-*/{sing-box.exe, wintun.dll}` 拷到 app `.exe` 同级目录
(见 `windows/CMakeLists.txt` 的 “Pangolin sing-box kernel” 段)。**wintun.dll 必须与
sing-box.exe 同目录**,否则 sing-box 无法创建 TUN。
**推荐:一键脚本**(拉内核 → 带 dart-define 构建 → 出安装包,自动避开漏 `--dart-define` 的坑):
## 端到端验证(连通出网)
```powershell
powershell -ExecutionPolicy Bypass -File client\windows\build.ps1 -ApiUrl http://<后端IP>:8080
# 只构建不出安装包:加 -SkipInstaller
```
1. 管理员终端 `flutter run -d windows`(或运行打包后的 `.exe`,会弹 UAC)。
2. 登录 → 选节点 → connect。
3. 验证全局代理生效:
或手动分步:
```powershell
curl https://api.ipify.org # 出口 IP 应为节点公网 IP
```
```powershell
cd client
# 必须带 --dart-define 指定后端,否则默认连 localhost:8080,登录会「网络请求失败」
flutter build windows --dart-define=PANGOLIN_API_URL=http://<后端IP>:8080
```
> ⚠️ `kApiBaseUrl`(见 `lib/services/api_config.dart`)当前默认值是联调测试节点(临时,发版前会改回 localhost/正式域名)。
> 正式 Release 包**务必显式** `--dart-define=PANGOLIN_API_URL=...` 指定后端,构建期烘进二进制;
> 一旦默认值改回 localhost,漏传 dart-define 就会复现登录「网络请求失败」。
## 已知限制 / BACKLOG
CMake 的 install 步骤会自动把 `app/kernel/dist/desktop/windows-*/{sing-box.exe, wintun.dll}`
拷到 app `.exe` 同级目录(见 `windows/CMakeLists.txt` 的「Pangolin sing-box kernel」段)。
**wintun.dll 必须与 sing-box.exe 同目录**,否则 sing-box 无法创建 TUN。
产物:`client\build\windows\x64\runner\Release\`(含 `pangolin_vpn.exe`、Flutter 运行时、
插件 DLL、`sing-box.exe``wintun.dll``data\`(含打包字体))。
## 出安装包(Inno Setup
需装 **Inno Setup 6**。脚本在 `client/windows/installer/pangolin.iss`,简中语言包
`ChineseSimplified.isl` 随仓库附带(本机 Inno Setup 若未含官方简中包,脚本相对引用之)。
```powershell
# 先 flutter build windows(Release),再编译安装包
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" client\windows\installer\pangolin.iss
# 产物:client\windows\installer\Output\pangolin-setup-<ver>.exe(约 24MB
```
安装包:装到 `Program Files\Pangolin`,建开始菜单 + 桌面快捷方式,卸载清理
`%LOCALAPPDATA%\Pangolin`
## 待人工验证(交互桌面 + 后端节点)
以下需在 Windows 桌面前操作(SSH 无交互/UAC 桌面,无法替代):
1. 管理员运行 `flutter run -d windows`(或装好的安装包),弹一次 UAC → 进主界面。
2. 登录 → 选节点 → connect → `curl https://api.ipify.org` 出口应为节点公网 IP。
3. 停止/重连/kill-switch/托盘(关闭最小化、显示、退出)/开机自启;退出后
`tasklist | findstr sing-box` 无残留、网络适配器无残留 wintun TUN。
4. 安装包安装→运行→卸载干净;高分屏(125%/150%)界面不糊不溢出。
## 已知坑 / 构建排错(真机踩过)
- **`C1083: atlstr.h`**:VS 缺 ATL 组件,按「前置」装上。
- **`LNK1327` / `mt.exe c1010001 "level not equal"`**`runner.exe.manifest`
`requireAdministrator` 与链接器默认注入的 `asInvoker` UAC 清单冲突。已在
`windows/runner/CMakeLists.txt``/MANIFESTUAC:NO` 修复(仅用 manifest 文件的 level)。
- **`jni` 出现在 `generated_plugins.cmake` 的 FFI 列表**:经 `path_provider_android`
传递引入,其 Windows CMake 用 `find_package(JNI)`(非 REQUIRED),无 JVM 时优雅产出空
bundled libs**不影响 Windows 构建**(已验证)。
- **fetch 脚本 404**:见「前置」——sing-box 停发 checksums.txt,已改内置 SHA pin。
## BACKLOG
- 提权用 `requireAdministrator`(每次启动弹一次 UAC)。正式版可换**特权 helper 服务**
避免每次 UAC,类比 macOS 的 SMJobBless。见 `runner/runner.exe.manifest`。
- 未做 Inno Setup 安装包 / 代码签名 / CI Windows job(后续切片)
避免每次 UAC,类比 macOS 的 SMJobBless。
- 代码签名 / CI Windows job 未做
- 停止用 `Process.kill`Dart 在 Windows 映射为 TerminateProcess);wintun 适配器随
进程退出自动移除。
+97
View File
@@ -0,0 +1,97 @@
<#
.SYNOPSIS
Pangolin Windows 客户端一键构建脚本(拉内核 → 带 dart-define 构建 → 出安装包)。
.DESCRIPTION
串起 client/windows/README.md 里的三步手动流程,避免漏掉 --dart-define
(漏了会默认连 localhost:8080,登录「网络请求失败」)。
步骤:
1. bash app/kernel/fetch-desktop-bin.sh windows <arch> (拉 sing-box.exe + wintun.dllSHA 校验)
2. flutter build windows --release --dart-define=PANGOLIN_API_URL=<ApiUrl>
3. ISCC.exe installer\pangolin.iss (除非 -SkipInstaller
.PARAMETER ApiUrl
必填。后端地址,会在构建期烘进二进制,例:http://103.119.13.48:8080
.PARAMETER Arch
CPU 架构,默认 amd64。
.PARAMETER SkipInstaller
只构建,不出 Inno Setup 安装包。
.EXAMPLE
powershell -ExecutionPolicy Bypass -File client\windows\build.ps1 -ApiUrl http://103.119.13.48:8080
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ApiUrl,
[string]$Arch = "amd64",
[switch]$SkipInstaller
)
$ErrorActionPreference = "Stop"
# 路径解析:本脚本在 client/windows/ 下
$WindowsDir = $PSScriptRoot
$ClientDir = Split-Path -Parent $WindowsDir
$RepoRoot = Split-Path -Parent $ClientDir
function Step([string]$msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan }
# ── 1. 拉内核(sing-box.exe + wintun.dll)─────────────────────────────────────
Step "拉取 sing-box.exe + wintun.dll (windows $Arch)"
$bash = Get-Command bash -ErrorAction SilentlyContinue
if (-not $bash) {
throw "未找到 bashfetch 脚本需 Git Bash)。请安装 Git for Windows 后重试。"
}
$fetchScript = Join-Path $RepoRoot "app/kernel/fetch-desktop-bin.sh"
& $bash.Source $fetchScript windows $Arch
if ($LASTEXITCODE -ne 0) { throw "内核拉取失败 (exit $LASTEXITCODE)" }
# ── 2. Flutter 构建(必须带 dart-define)──────────────────────────────────────
Step "flutter build windows --release (PANGOLIN_API_URL=$ApiUrl)"
Push-Location $ClientDir
try {
flutter build windows --release --dart-define=PANGOLIN_API_URL=$ApiUrl
if ($LASTEXITCODE -ne 0) { throw "flutter build 失败 (exit $LASTEXITCODE)" }
}
finally {
Pop-Location
}
$releaseDir = Join-Path $ClientDir "build\windows\x64\runner\Release"
Write-Host "构建产物:$releaseDir" -ForegroundColor Green
# ── 3. 出安装包(Inno Setup)──────────────────────────────────────────────────
if ($SkipInstaller) {
Step "跳过安装包(-SkipInstaller"
return
}
Step "编译 Inno Setup 安装包"
$iscc = $null
foreach ($p in @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe")) {
if (Test-Path $p) { $iscc = $p; break }
}
if (-not $iscc) {
$cmd = Get-Command ISCC.exe -ErrorAction SilentlyContinue
if ($cmd) { $iscc = $cmd.Source }
}
if (-not $iscc) {
Write-Warning "未找到 ISCC.exeInno Setup 6 未安装)。已完成构建,跳过安装包。"
Write-Warning "装好 Inno Setup 6 后可手动:ISCC.exe `"$WindowsDir\installer\pangolin.iss`""
return
}
$iss = Join-Path $WindowsDir "installer\pangolin.iss"
& $iscc $iss
if ($LASTEXITCODE -ne 0) { throw "ISCC 编译失败 (exit $LASTEXITCODE)" }
$output = Join-Path $WindowsDir "installer\Output"
Write-Host "`n✓ 全部完成。安装包在:$output" -ForegroundColor Green
@@ -7,8 +7,17 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <tray_manager/tray_manager_plugin.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
TrayManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("TrayManagerPlugin"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}
@@ -4,6 +4,9 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
screen_retriever_windows
tray_manager
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -0,0 +1,418 @@
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
;
; To download user-contributed translations of this file, go to:
; https://jrsoftware.org/files/istrans/
;
; Note: When translating this text, do not add periods (.) to the end of
; messages that didn't have them already, because on those messages Inno
; Setup adds the periods automatically (appending a period would result in
; two periods being displayed).
;
; Maintained by Zhenghan Yang
; Email: 847320916@QQ.com
; Translation based on network resource
; The latest Translation is on https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
;
[LangOptions]
; The following three entries are very important. Be sure to read and
; understand the '[LangOptions] section' topic in the help file.
LanguageName=简体中文
; If Language Name display incorrect, uncomment next line
; LanguageName=<7B80><4F53><4E2D><6587>
; About LanguageID, to reference link:
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
LanguageID=$0804
; About CodePage, to reference link:
; https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers
LanguageCodePage=936
; If the language you are translating to requires special font faces or
; sizes, uncomment any of the following entries and change them accordingly.
;DialogFontName=
;DialogFontSize=9
;DialogFontBaseScaleWidth=7
;DialogFontBaseScaleHeight=15
;WelcomeFontName=Segoe UI
;WelcomeFontSize=14
[Messages]
; *** 应用程序标题
SetupAppTitle=安装
SetupWindowTitle=安装 - %1
UninstallAppTitle=卸载
UninstallAppFullTitle=%1 卸载
; *** Misc. common
InformationTitle=信息
ConfirmTitle=确认
ErrorTitle=错误
; *** SetupLdr messages
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
HelpTextNote=
; *** 启动错误消息
LastErrorMessage=%1。%n%n错误 %2: %3
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
InvalidParameter=无效的命令行参数:%n%n%1
SetupAlreadyRunning=安装程序正在运行。
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
NotOnThisPlatform=此程序不能在 %1 上运行。
OnlyOnThisPlatform=此程序只能在 %1 上运行。
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或有权限的用户组身份登录。
SetupAppRunningError=安装程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
UninstallAppRunningError=卸载程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
; *** 启动问题
PrivilegesRequiredOverrideTitle=选择安装程序模式
PrivilegesRequiredOverrideInstruction=选择安装模式
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A) (建议选项)
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M) (建议选项)
; *** 其他错误
ErrorCreatingDir=安装程序无法创建目录“%1”
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件
; *** 安装程序公共消息
ExitSetupTitle=退出安装程序
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
AboutSetupMenuItem=关于安装程序(&A)...
AboutSetupTitle=关于安装程序
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
AboutSetupNote=
TranslatorNote=简体中文翻译由Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; *** 按钮
ButtonBack=< 上一步(&B)
ButtonNext=下一步(&N) >
ButtonInstall=安装(&I)
ButtonOK=确定
ButtonCancel=取消
ButtonYes=是(&Y)
ButtonYesToAll=全是(&A)
ButtonNo=否(&N)
ButtonNoToAll=全否(&O)
ButtonFinish=完成(&F)
ButtonBrowse=浏览(&B)...
ButtonWizardBrowse=浏览(&R)...
ButtonNewFolder=新建文件夹(&M)
; *** “选择语言”对话框消息
SelectLanguageTitle=选择安装语言
SelectLanguageLabel=选择安装时使用的语言。
; *** 公共向导文字
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
BeveledLabel=
BrowseDialogTitle=浏览文件夹
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
NewFolderName=新建文件夹
; *** “欢迎”向导页
WelcomeLabel1=欢迎使用 [name] 安装向导
WelcomeLabel2=现在将安装 [name/ver] 到您的电脑中。%n%n建议您在继续安装前关闭所有其他应用程序。
; *** “密码”向导页
WizardPassword=密码
PasswordLabel1=这个安装程序有密码保护。
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
PasswordEditLabel=密码(&P)
IncorrectPassword=您输入的密码不正确,请重新输入。
; *** “许可协议”向导页
WizardLicense=许可协议
LicenseLabel=请在继续安装前阅读以下重要信息。
LicenseLabel3=请仔细阅读下列许可协议。在继续安装前您必须同意这些协议条款。
LicenseAccepted=我同意此协议(&A)
LicenseNotAccepted=我不同意此协议(&D)
; *** “信息”向导页
WizardInfoBefore=信息
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
WizardInfoAfter=信息
InfoAfterLabel=请在继续安装前阅读以下重要信息。
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
; *** “用户信息”向导页
WizardUserInfo=用户信息
UserInfoDesc=请输入您的信息。
UserInfoName=用户名(&U)
UserInfoOrg=组织(&O)
UserInfoSerial=序列号(&S)
UserInfoNameRequired=您必须输入用户名。
; *** “选择目标目录”向导页
WizardSelectDir=选择目标位置
SelectDirDesc=您想将 [name] 安装在哪里?
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
InvalidPath=您必须输入一个带驱动器卷标的完整路径,例如:%n%nC:\APP%n%n或UNC路径:%n%n\\server\share
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
DiskSpaceWarningTitle=磁盘空间不足
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您一定要继续吗?
DirNameTooLong=文件夹名称或路径太长。
InvalidDirName=文件夹名称无效。
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
DirExistsTitle=文件夹已存在
DirExists=文件夹:%n%n%1%n%n已经存在。您一定要安装到这个文件夹中吗?
DirDoesntExistTitle=文件夹不存在
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
; *** “选择组件”向导页
WizardSelectComponents=选择组件
SelectComponentsDesc=您想安装哪些程序组件?
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
FullInstallation=完全安装
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
CompactInstallation=简洁安装
CustomInstallation=自定义安装
NoUninstallWarningTitle=组件已存在
NoUninstallWarning=安装程序检测到下列组件已安装在您的电脑中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n确定要继续吗?
ComponentSize1=%1 KB
ComponentSize2=%1 MB
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
; *** “选择附加任务”向导页
WizardSelectTasks=选择附加任务
SelectTasksDesc=您想要安装程序执行哪些附加任务?
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
; *** “选择开始菜单文件夹”向导页
WizardSelectProgramGroup=选择开始菜单文件夹
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
MustEnterGroupName=您必须输入一个文件夹名。
GroupNameTooLong=文件夹名或路径太长。
InvalidGroupName=无效的文件夹名字。
BadGroupName=文件夹名不能包含下列任何字符:%n%n%1
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
; *** “准备安装”向导页
WizardReady=准备安装
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的电脑。
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新考虑或修改任何设置,点击“上一步”。
ReadyLabel2b=点击“安装”继续此安装程序。
ReadyMemoUserInfo=用户信息:
ReadyMemoDir=目标位置:
ReadyMemoType=安装类型:
ReadyMemoComponents=已选择组件:
ReadyMemoGroup=开始菜单文件夹:
ReadyMemoTasks=附加任务:
; *** TExtractionWizardPage 向导页面与 ExtractArchive
ExtractingLabel=正在解压文件...
ButtonStopExtraction=停止解压(&S)
StopExtraction=您确定要停止解压吗?
ErrorExtractionAborted=解压已中止
ErrorExtractionFailed=解压失败:%1
; *** 压缩文件解压失败详情
ArchiveIncorrectPassword=压缩文件密码不正确
ArchiveIsCorrupted=压缩文件已损坏
ArchiveUnsupportedFormat=不支持的压缩文件格式
; *** TDownloadWizardPage 向导页面和 DownloadTemporaryFile
DownloadingLabel2=正在下载文件...
ButtonStopDownload=停止下载(&S)
StopDownload=您确定要停止下载吗?
ErrorDownloadAborted=下载已中止
ErrorDownloadFailed=下载失败:%1 %2
ErrorDownloadSizeFailed=获取下载大小失败:%1 %2
ErrorProgress=无效的进度:%1 / %2
ErrorFileSize=文件大小错误:预期 %1,实际 %2
; *** “正在准备安装”向导页
WizardPreparing=正在准备安装
PreparingDesc=安装程序正在准备安装 [name] 到您的电脑。
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,您需要重启您的电脑以完成。%n%n在重启电脑后,再次运行安装程序以完成 [name] 的安装。
CannotContinue=安装程序不能继续。请点击“取消”退出。
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
CloseApplications=自动关闭应用程序(&A)
DontCloseApplications=不要关闭应用程序(&D)
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n是否立即重新启动?
; *** “正在安装”向导页
WizardInstalling=正在安装
InstallingLabel=安装程序正在安装 [name] 到您的电脑,请稍候。
; *** “安装完成”向导页
FinishedHeadingLabel=[name] 安装完成
FinishedLabelNoIcons=安装程序已在您的电脑中安装了 [name]。
FinishedLabel=安装程序已在您的电脑中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
ClickFinish=点击“完成”退出安装程序。
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的电脑。要立即重启吗?
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的电脑。%n%n要立即重启吗?
ShowReadmeCheck=是,我想查阅自述文件
YesRadio=是,立即重启电脑(&Y)
NoRadio=否,稍后重启电脑(&N)
; used for example as 'Run MyProg.exe'
RunEntryExec=运行 %1
; used for example as 'View Readme.txt'
RunEntryShellExec=查阅 %1
; *** “安装程序需要下一张磁盘”提示
ChangeDiskTitle=安装程序需要下一张磁盘
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
PathLabel=路径(&P)
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
SelectDirectoryLabel=请指定下一张磁盘的位置。
; *** 安装阶段消息
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
AbortRetryIgnoreSelectAction=选择操作
AbortRetryIgnoreRetry=重试(&T)
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
AbortRetryIgnoreCancel=关闭安装程序
RetryCancelSelectAction=选择操作
RetryCancelRetry=重试(&T)
RetryCancelCancel=取消(&C)
; *** 安装状态消息
StatusClosingApplications=正在关闭应用程序...
StatusCreateDirs=正在创建目录...
StatusExtractFiles=正在提取文件...
StatusDownloadFiles=正在下载文件...
StatusCreateIcons=正在创建快捷方式...
StatusCreateIniEntries=正在创建 INI 条目...
StatusCreateRegistryEntries=正在创建注册表条目...
StatusRegisterFiles=正在注册文件...
StatusSavingUninstall=正在保存卸载信息...
StatusRunProgram=正在完成安装...
StatusRestartingApplications=正在重启应用程序...
StatusRollback=正在撤销更改...
; *** 其他错误
ErrorInternal2=内部错误:%1
ErrorFunctionFailedNoCode=%1 失败
ErrorFunctionFailed=%1 失败;错误代码 %2
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3
ErrorExecutingProgram=无法执行文件:%n%1
; *** 注册表错误
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
; *** INI 错误
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
; *** 文件复制错误
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S) (不推荐)
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I) (不推荐)
SourceIsCorrupted=源文件已损坏
SourceDoesntExist=源文件“%1”不存在
SourceVerificationFailed=源文件验证失败: %1
VerificationSignatureDoesntExist=签名文件“%1”不存在
VerificationSignatureInvalid=签名文件“%1”无效
VerificationKeyNotFound=签名文件“%1”使用了未知密钥
VerificationFileNameIncorrect=文件名不正确
VerificationFileTagIncorrect=文件标签不正确
VerificationFileSizeIncorrect=文件大小不正确
VerificationFileHashIncorrect=文件哈希值不正确
ExistingFileReadOnly2=无法替换现有文件,它是只读的。
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
ExistingFileReadOnlyKeepExisting=保留现有文件(&K)
ErrorReadingExistingDest=尝试读取现有文件时出错:
FileExistsSelectAction=选择操作
FileExists2=文件已经存在。
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
FileExistsKeepExisting=保留现有的文件(&K)
FileExistsOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
ExistingFileNewerSelectAction=选择操作
ExistingFileNewer2=现有的文件比安装程序将要安装的文件还要新。
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
ExistingFileNewerKeepExisting=保留现有的文件(&K) (推荐)
ExistingFileNewerOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
ErrorChangingAttr=尝试更改下列现有文件的属性时出错:
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
ErrorReadingSource=尝试读取下列源文件时出错:
ErrorCopying=尝试复制下列文件时出错:
ErrorDownloading=下载文件时出错:
ErrorExtracting=解压压缩文件时出错:
ErrorReplacingExistingFile=尝试替换现有文件时出错:
ErrorRestartReplace=重启并替换失败:
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
ErrorRegisterServer=无法注册 DLL/OCX%1
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1
ErrorRegisterTypeLib=无法注册类库:%1
; *** 卸载显示名字标记
; used for example as 'My Program (32-bit)'
UninstallDisplayNameMark=%1 (%2)
; used for example as 'My Program (32-bit, All users)'
UninstallDisplayNameMarks=%1 (%2, %3)
UninstallDisplayNameMark32Bit=32 位
UninstallDisplayNameMark64Bit=64 位
UninstallDisplayNameMarkAllUsers=所有用户
UninstallDisplayNameMarkCurrentUser=当前用户
; *** 安装后错误
ErrorOpeningReadme=尝试打开自述文件时出错。
ErrorRestartingComputer=安装程序无法重启电脑,请手动重启。
; *** 卸载消息
UninstallNotFound=文件“%1”不存在。无法卸载。
UninstallOpenError=文件“%1”不能被打开。无法卸载。
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载
UninstallUnknownEntry=卸载日志中遇到一个未知条目 (%1)
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
UninstallStatusLabel=正在从您的电脑中移除 %1,请稍候。
UninstalledAll=已顺利从您的电脑中移除 %1。
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的电脑。%n%n立即重启电脑吗?
UninstallDataCorrupted=文件“%1”已损坏。无法卸载
; *** 卸载状态消息
ConfirmDeleteSharedFileTitle=删除共享的文件吗?
ConfirmDeleteSharedFile2=系统表示下列共享的文件已不有其他程序使用。您希望卸载程序删除这些共享的文件吗?%n%n如果删除这些文件,但仍有程序在使用这些文件,则这些程序可能出现异常。如果您不能确定,请选择“否”,在系统中保留这些文件以免引发问题。
SharedFileNameLabel=文件名:
SharedFileLocationLabel=位置:
WizardUninstalling=卸载状态
StatusUninstalling=正在卸载 %1...
; *** Shutdown block reasons
ShutdownBlockReasonInstallingApp=正在安装 %1。
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
; The custom messages below aren't used by Setup itself, but if you make
; use of them in your scripts, you'll want to translate them.
[CustomMessages]
NameAndVersion=%1 版本 %2
AdditionalIcons=附加快捷方式:
CreateDesktopIcon=创建桌面快捷方式(&D)
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
ProgramOnTheWeb=%1 网站
UninstallProgram=卸载 %1
LaunchProgram=运行 %1
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
AutoStartProgramGroupDescription=启动:
AutoStartProgram=自动启动 %1
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您要继续吗?
+49
View File
@@ -0,0 +1,49 @@
; 穿山甲 Pangolin Windows 安装包 (Inno Setup 6)
; 编译: ISCC.exe pangolin.iss (或用 Inno Setup Compiler 打开)
; 前置: 先在 client/ 跑 `flutter build windows`(Release),产物在
; ..\..\build\windows\x64\runner\Release
#define MyAppName "穿山甲 Pangolin"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "Pangolin"
#define MyAppExeName "pangolin_vpn.exe"
#define BuildDir "..\..\build\windows\x64\runner\Release"
[Setup]
AppId={{B2F1B0A0-7A3C-4C2E-9E5A-1A2B3C4D5E6F}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\Pangolin
DefaultGroupName=Pangolin
DisableProgramGroupPage=yes
OutputBaseFilename=pangolin-setup-{#MyAppVersion}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
; 安装需管理员(写 Program Files;app 本身 requireAdministrator)
PrivilegesRequired=admin
ArchitecturesInstallIn64BitMode=x64compatible
[Languages]
; ChineseSimplified.isl 随仓库附带(此 Inno Setup 安装未含官方简中语言包),与本 .iss 同目录
Name: "chinesesimplified"; MessagesFile: "ChineseSimplified.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Files]
; 整个 Release 目录(含 pangolin_vpn.exe / flutter_windows.dll / data\ /
; 插件 dll / sing-box.exe / wintun.dll)
Source: "{#BuildDir}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
[UninstallDelete]
; 卸载清理运行期数据(kernel 配置等)
Type: filesandordirs; Name: "{localappdata}\Pangolin"
+5
View File
@@ -36,5 +36,10 @@ target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
# runner.exe.manifest 已声明 requestedExecutionLevel=requireAdministrator(UAC 提权,
# sing-box 子进程继承管理员权建 TUN)。关闭链接器自动注入的 UAC 清单(默认 asInvoker),
# 否则与 runner.exe.manifest 的 level 冲突,mt.exe 报 c1010001 → LINK : LNK1327。
target_link_options(${BINARY_NAME} PRIVATE "/MANIFESTUAC:NO")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>macOS PacketTunnel 系统扩展 realize 失败(code=4)踩坑复盘</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:21px;margin:48px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16px;margin:28px 0 8px;color:var(--accent2)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
pre .c{color:#6b7385}
pre .r{color:var(--bad)}
pre .g{color:var(--ok)}
pre .y{color:var(--warn)}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
.card.root{border-left:3px solid var(--accent)}
.card h3{margin-top:0}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--fg2);font-weight:600;font-size:13px}
td code{font-size:.85em}
.ok-c{color:var(--ok)} .bad-c{color:var(--bad)} .warn-c{color:var(--warn)}
ul,ol{padding-left:22px;margin:10px 0}
li{margin:5px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.kbd{font-family:var(--mono);font-size:.85em;color:var(--accent)}
.small{color:var(--fg2);font-size:13px}
.step{counter-increment:step;position:relative;padding-left:6px}
hr{border:none;border-top:1px solid var(--border);margin:40px 0}
a{color:var(--accent2)}
</style>
</head>
<body>
<div class="wrap">
<h1>macOS PacketTunnel 系统扩展 realize 失败(<code>OSSystemExtensionErrorDomain code=4</code>)踩坑复盘</h1>
<p class="sub">Pangolin 客户端 · P1 原生隧道 · 2026-06-21 · 在 macOS 15.3.2 / 26 上排查与修复</p>
<div class="lead">
<strong>一句话结论:</strong> 客户端内嵌的 <code>PacketTunnel</code> 系统扩展(<code>NEPacketTunnelProvider</code>)一直无法激活,报
<code>code=4 "Extension not found in App bundle. Unable to find any matched extension"</code>
表面像"找不到扩展",实则是 <b>三个叠加的工程配置 bug</b><code>sysextd</code> 在 realize/暂存/分类校验三个不同阶段先后拒绝。
逐个修复后扩展成功 <code>activated enabled</code>、隧道连通。
</div>
<h2>1. 现象</h2>
<p>客户端点"连接"时,主 app 调 <code>OSSystemExtensionRequest.activationRequest</code> 激活内嵌的 packet-tunnel 系统扩展,但回调恒为失败:</p>
<pre><span class="r">sysext didFailWithError ✗ domain=OSSystemExtensionErrorDomain code=4</span>
desc=Extension not found in App bundle. Unable to find any matched extension
with identifier: com.pangolin.pangolin.PacketTunnel</pre>
<p>诡异之处:</p>
<ul>
<li>扩展明明在 <code>Contents/Library/SystemExtensions/</code> 里,<code>sysextd</code> 日志也打了 <code>attempting to realize</code>(说明找到了)。</li>
<li>签名、公证、staple、Gatekeeper 全部通过;<code>codesign --verify --deep --strict</code> 通过。</li>
<li><code>sysextd</code> 在验签通过后 <b>~10ms 内静默 <code>xpc_connection_cancel</code></b>,自身不打印任何拒绝原因。</li>
<li><b>从不弹"允许扩展"批准框</b>——在批准阶段之前就被拒。</li>
</ul>
<h2>2. 走过的弯路(全部排除)</h2>
<p>以下都查过并确认<strong>不是</strong>根因,记录下来免得后人重复:</p>
<table>
<tr><th>假设</th><th>结论</th></tr>
<tr><td>签名 / 公证 / 时间戳 / hardened runtime / get-task-allow</td><td class="ok-c">全部正确</td></tr>
<tr><td>entitlements 变体(<code>packet-tunnel-provider-systemextension</code>)</td><td class="ok-c">正确,且描述文件已授权</td></tr>
<tr><td>Info.plist 用 <code>NetworkExtension</code>/<code>NEProviderClasses</code>(而非 appex 的 <code>NSExtension</code>)</td><td class="ok-c">正确(sysext 就该用这个)</td></tr>
<tr><td>Xcode 26 beta / macOS 26.2 SDK 工具链太新</td><td class="bad-c">证伪:Tailscale 用更新的 SDK 26.5 照样能用</td></tr>
<tr><td>App 文件归属(user vs root:wheel)</td><td class="bad-c">改 root:wheel 无效</td></tr>
<tr><td>App 不在 /Applications / LaunchServices 注册 / 翻译重定位</td><td class="bad-c">都正常,无效</td></tr>
<tr><td><code>systemextensionsctl reset</code> 清缓存</td><td class="bad-c">无效</td></tr>
<tr><td>App Group 用 iOS 式 <code>group.</code> 前缀</td><td class="warn-c">确实该改成 macOS 原生格式,但单独改它仍失败</td></tr>
</table>
<h2>3. 破局方法:在同一台机器上对照一个"能用的"开源同类</h2>
<p>关键转折是<strong>不再盲猜,而是拿一个已知能用、且分发模型完全相同的开源 app 在同一台机器上对照</strong>。我们选了 <b>Tailscale 独立版</b>(<code>io.tailscale.ipn.macsys</code>):同样是 Developer ID 公证 + <code>NEPacketTunnelProvider</code> <b>系统扩展</b>分发(非 App Store appex)。</p>
<div class="card">
<p>把本机能用的 <code>Tailscale.app</code> 直接拷到出问题的测试机(cara,干净 macOS 15.3.2,Intel),它的扩展<strong>一路走完</strong> <code>validating → staging → validating_by_category → activated_waiting_for_user</code> 并弹出"允许扩展"框。</p>
<p><strong>这一步同时证明了两件事:</strong></p>
<ul>
<li>cara 的环境完全正常——能 realize 第三方 Developer ID 系统扩展。</li>
<li>所以 100% 是<strong>我们自己的包</strong>有问题(用户的直觉:"是我们代码的问题")。</li>
</ul>
</div>
<p class="small">附:也对照了 Shadowrocket,但它是 App Store 的 <b>appex</b> 模型(<code>NSExtension</code>、系统预信任、不走 sysextd 批准流),与我们不是一个机制,不能直接类比。要对照必须找<strong>同为 Developer ID system extension</strong> 的实现。</p>
<h2>4. 三个根因(按 sysextd 处理阶段排序)</h2>
<div class="card root">
<h3>根因 ① 扩展不自包含 <span class="tag bad">staging 前被拒</span></h3>
<p>这是 Flutter + 扩展工程的经典坑。工程用 CocoaPods,<b>项目级</b>配置经 <code>Release.xcconfig</code> 注入了链接所有 pod 框架的 <code>OTHER_LDFLAGS</code><code>PacketTunnel</code> target 没有自己的 <code>OTHER_LDFLAGS</code>,于是<strong>继承了项目级的</strong>,把主 app 的 Flutter 插件 <code>flutter_secure_storage_macos.framework</code> 也链进了扩展——而该框架并不在扩展 bundle 内:</p>
<pre>$ otool -L PacketTunnel.systemextension/Contents/MacOS/PacketTunnel
<span class="r">@rpath/flutter_secure_storage_macos.framework/.../flutter_secure_storage_macos</span> ← 悬空依赖!
/System/Library/Frameworks/...</pre>
<p>同时,gomobile 产出的 <code>Libbox.xcframework</code> 实为<strong>静态库</strong>(<code>ar archive</code>),已被静态链进扩展二进制,却又被 <b>Embed Frameworks</b> 冗余地塞了一份畸形的 <code>Libbox.framework</code> 进扩展。</p>
<p><b>system extension 必须自包含</b>(realize 时会被拷到 <code>/Library/SystemExtensions/&lt;UUID&gt;/</code> 独立运行,相对 rpath 回不到主 app)。</p>
<p><strong>修复:</strong></p>
<ul>
<li><code>PacketTunnel</code> 的 Debug/Release 配置加 <code class="kbd">OTHER_LDFLAGS = "";</code>,切断对项目级 pod 链接标志的继承。</li>
<li><b>Embed Frameworks</b> 移除 <code>Libbox.xcframework</code>(它是静态库,只需 <b>Link</b>,不需 Embed)。</li>
</ul>
<p class="small">验证:<code>otool -L</code> 扩展二进制应只剩 <code>/System/...</code><code>/usr/lib/...</code>,无任何 <code>@rpath</code>;<code>Contents/</code> 下不再有 <code>Frameworks/</code> 目录(与 Tailscale 一致)。</p>
</div>
<div class="card root">
<h3>根因 ② 扩展 bundle 名 ≠ bundle 标识符 <span class="tag bad">staging 阶段</span></h3>
<p>我们扩展的产物名是 <code>PacketTunnel.systemextension</code>,而 <code>CFBundleIdentifier</code><code>com.pangolin.pangolin.PacketTunnel</code>。Tailscale 的产物名 = 标识符(<code>io.tailscale.ipn.macsys.network-extension.systemextension</code>)。</p>
<p>在受影响的 macOS 上,<code>sysextd</code> 必须能按标识符把 bundle 拷进暂存区;名字对不上时,在 <code>attempting to realize</code> 之后直接静默 cancel,<b>进不了 staging</b>。改名后日志立刻出现 <code>[Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension</code></p>
<p><strong>修复:</strong><code>PacketTunnel</code> target 的 <code class="kbd">PRODUCT_NAME</code><code>$(TARGET_NAME)</code> 改为 <code>com.pangolin.pangolin.PacketTunnel</code>(三个配置 Debug/Release/Profile 都改)。</p>
<p class="small">连带:<code>PRODUCT_MODULE_NAME</code> 会变成 <code>com_pangolin_pangolin_PacketTunnel</code>,而 Info.plist 的 <code>NEProviderClasses</code><code>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</code> 自动跟随,无需手改。</p>
</div>
<div class="card root">
<h3>根因 ③ 扩展 Info.plist 缺 <code>NSSystemExtensionUsageDescription</code> <span class="tag bad">category 校验阶段</span></h3>
<p>过了 staging 后,日志终于给出<strong>明确原因</strong>:</p>
<pre><span class="r">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.</span>
→ uninstalling invalid extension</pre>
<p>网络扩展类别<strong>强制要求扩展自己的 Info.plist</strong> 里有 <code>NSSystemExtensionUsageDescription</code>。我们之前只在<strong>主 app</strong> 的 Info.plist 加了——<b>不顶用</b>。Tailscale 的扩展 Info.plist 里就有这个键。</p>
<p><strong>修复:</strong><code>PacketTunnel/Info.plist</code> 顶层加:</p>
<pre>&lt;key&gt;NSSystemExtensionUsageDescription&lt;/key&gt;
&lt;string&gt;Pangolin 需要安装网络扩展以提供安全的网络连接。&lt;/string&gt;</pre>
</div>
<h3>附带一并修正的项</h3>
<ul>
<li><b>App Group 格式</b>:<code>group.com.pangolin.pangolin</code>(iOS 式)→ macOS 原生 <code>BYL4KQHMTN.com.pangolin.pangolin</code>(主 app + 扩展 entitlements + Swift <code>appGroup</code> 常量三处同步)。</li>
<li><b>NEMachServiceName</b>:必须以扩展所属 App Group 为前缀 → <code>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</code></li>
<li><b>扩展网络权限</b>:沙箱扩展补 <code>com.apple.security.network.client</code> / <code>network.server</code>(packet tunnel 要对外建连)。</li>
<li><b>公证前置</b>:<code>get-task-allow=false</code>、Xcode 签名加 <code>--timestamp</code>(<code>OTHER_CODE_SIGN_FLAGS</code>)、构建期把静态 Libbox 以 Developer ID 重签。</li>
</ul>
<h2>5. 成功的 sysextd 日志长这样</h2>
<pre>attempting to realize extension with identifier com.pangolin.pangolin.PacketTunnel
[Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension
advancing state from staging to <span class="g">validating</span>
advancing state from validating to <span class="g">validating_by_category</span>
<span class="g">Category delegate com.apple.system_extension.network_extension returned error (null)</span>
advancing state ... to <span class="g">activated_waiting_for_user</span>
observer 'notify user' reached a success state <span class="c"># ← 弹"允许扩展"框</span>
<span class="c"># 用户在 系统设置 → 隐私与安全性 点允许后:</span>
sysext didFinishWithResult ✓ result=0 (completed)
$ systemextensionsctl list
* * BYL4KQHMTN com.pangolin.pangolin.PacketTunnel (1.0/1) <span class="g">[activated enabled]</span></pre>
<h2>5.5 加载之后:从"扩展能起"到"真正连通"(运行时)</h2>
<p>系统扩展能 realize/激活只是第一步。让内嵌的 libbox(sing-box)真正建起隧道、能上网,又踩了四个坑(均在 <code>PacketTunnelProvider.swift</code> / 服务端配置):</p>
<div class="card root">
<h3>运行时 ① libbox <code>startOrReloadService(options:)</code> 传 nil → 空指针崩溃 <span class="tag bad">扩展进程 SIGABRT</span></h3>
<p>扩展进程启动 360ms 后自杀(SIGABRT)。把 stderr 重定向到 App Group 容器文件后抓到 Go panic:</p>
<pre><span class="r">panic: runtime error: invalid memory address or nil pointer dereference</span>
libbox.(*CommandServer).StartOrReloadService(server, {config}, <span class="r">0x0</span>) command_server.go:175</pre>
<p>此版本 libbox 会解引用第三个 <code>options</code> 参数(虽标 <code>_Nullable</code>)。<strong>修复:</strong>传非空 <code>LibboxOverrideOptions()</code> 而非 <code>nil</code></p>
</div>
<div class="card root">
<h3>运行时 ② 默认接口监控异步返回 → <code>no available network interface</code> <span class="tag bad">启动报错</span></h3>
<p><code>startDefaultInterfaceMonitor</code> 启动 <code>NWPathMonitor</code> 后立即返回,但首个 path 回调是异步晚到的;sing-box 紧接着的网络操作拿到的默认接口索引还是 -1。<strong>修复:</strong>用信号量**阻塞到首个 path 更新再返回**(带 5s 超时),对齐 sing-box-for-apple。</p>
</div>
<div class="card root">
<h3>运行时 ③ provider 队列三方死锁 → 隧道永远卡 connecting <span class="tag bad">最隐蔽</span></h3>
<p>openTun 打点显示卡在 <code>setTunnelNetworkSettings</code> 的信号量等待,回调永不触发:</p>
<ul>
<li>NE 在 <b>provider 队列</b>上调 <code>startTunnel</code> → 同步调 <code>startOrReloadService</code>(阻塞该队列)</li>
<li>sing-box 在 Go 线程调 openTun → <code>sem.wait()</code><code>setTunnelNetworkSettings</code> 完成</li>
<li>而该完成回调**正要在被阻塞的 provider 队列上投递** → 死锁</li>
</ul>
<p><strong>修复:</strong>把 libbox 启动整段放进后台队列(<code>DispatchQueue.global().async</code>),<code>startTunnel</code> 立即返回、provider 队列腾出来投递回调。</p>
</div>
<div class="card root">
<h3>运行时 ④ 缺 DNS 劫持 → 隧道连上但打不开网站 <span class="tag bad">最后一关</span></h3>
<p>隧道起来了、TCP 能经 REALITY 出海,但域名打不开。box.log 显示发往隧道 DNS 的查询走了直连:</p>
<pre>inbound packet connection to <span class="y">172.19.0.2:53</span>
router: match ip_cidr=[..<span class="r">172.16.0.0/12</span>..] => route(<span class="r">direct</span>) <span class="c"># DNS 被 LAN 规则吞去直连 → 解析失败</span></pre>
<p>隧道 DNS 地址 172.19.0.2 落在 LAN 直连规则 172.16/12 内,被路由成直连(发往不存在的主机)→ 解析全失败。<strong>修复(服务端 <code>clientconfig.go</code>):</strong>route.rules 首条加 <code>{"action":"hijack-dns","port":[53]}</code>(排在 LAN 规则之前),把 :53 查询交给 sing-box DNS 模块。</p>
<p class="small">注:sing-box 1.13 的 <code>protocol:"dns"</code> 匹配需先 sniff;按目的端口 53 匹配最稳、不依赖 sniff。</p>
</div>
<div class="card">
<p><strong>另一个发版必知:每次构建必递增 <code>CFBundleVersion</code></strong> <code>sysextd</code> 按(标识符, 版本)去重;同版本号重装**不会替换**已激活的旧扩展,跑的还是旧代码(排查时极易被误导)。</p>
</div>
<p><strong>连通验证(从命令行):</strong></p>
<pre>$ curl https://api.ipify.org → <span class="g">103.119.13.48</span> <span class="c"># 出口=节点 IP,流量走隧道</span>
$ curl -o/dev/null -w '%{http_code}' https://github.com → <span class="g">200</span>
box.log: router: match[0] port=53 => <span class="g">hijack-dns</span> → dns: exchanged A github.com 140.82.116.3</pre>
<h2>6. 给后人的排查 checklist(Developer ID 网络系统扩展)</h2>
<ol>
<li><b>先找同模型的能用实现对照</b>(Tailscale 独立版 / Mullvad),<u>在同一台机器</u>上验证环境没问题,把范围锁到自己的包。</li>
<li>扩展二进制 <code>otool -L</code> 必须<strong><code>@rpath</code> 外部依赖</strong>(自包含)。警惕 CocoaPods/Flutter 的 <code>OTHER_LDFLAGS</code> 继承。</li>
<li>扩展 <b>bundle 名 = CFBundleIdentifier</b>(设 <code>PRODUCT_NAME</code> = 标识符)。</li>
<li>扩展 Info.plist <strong>必须有 <code>NSSystemExtensionUsageDescription</code></strong>(不是主 app 的)。</li>
<li>App Group 用 macOS 原生 <code>&lt;TeamID&gt;.&lt;name&gt;</code>;<code>NEMachServiceName</code> 以该 group 为前缀。</li>
<li>沙箱扩展按需补 <code>network.client/server</code>、文件访问等能力。</li>
<li>静态库 xcframework 只 <b>Link</b><b>Embed</b>;动态 framework 才需 Embed + CodeSignOnCopy。</li>
<li>排查时:<code>log stream --debug --predicate 'process=="sysextd" OR process=="syspolicyd"'</code>,看卡在 <code>realize / staging / validating_by_category</code> 哪一步;category 校验失败会打出明确缺什么键。</li>
</ol>
<h2>7. 已知环境坑:macOS 26 (Tahoe) 回归</h2>
<div class="card">
<p>在 macOS 26 上,即使包完全正确,<code>sysextd</code> 仍可能报
<code>no policy, cannot allow apps outside /Applications</code>(app 明明在 /Applications)。这是 <b>Apple 在 Tahoe 的已知回归</b>(开发者论坛多人复现、非 MDM 个人机),<u>不是我们能修的</u>,也不影响 macOS 15/14 的真实用户。开发机若是 26,本机调试可考虑关 SIP 后开
<code>systemextensionsctl developer on</code>,或在 15/14 机器上验证。</p>
</div>
<h2>8. 运行时 checklist(libbox + NE 集成)</h2>
<ol>
<li>libbox <code>startOrReloadService(options:)</code> 传**非空** <code>LibboxOverrideOptions()</code>,别传 nil。</li>
<li><code>startTunnel</code> 里的 libbox 启动放**后台队列**(<code>DispatchQueue.global().async</code>),别在 provider 队列同步跑(死锁)。</li>
<li><code>startDefaultInterfaceMonitor</code> **阻塞到首个 path 更新再返回**。</li>
<li>TUN 配置必须有 **DNS 劫持**(<code>action:hijack-dns</code>,按 port 53),排在 LAN/分流规则之前。</li>
<li>**每次构建递增 <code>CFBundleVersion</code>**,否则 sysextd 不更新已激活的扩展。</li>
<li>看 libbox 自身日志:配置 <code>log.output</code> 指向容器文件(<code>writeLogs</code> 回调启动期不触发);Go fatal 走 stderr,需把扩展 stderr 重定向到文件才看得到。</li>
</ol>
<hr>
<p class="small">改动文件:扩展 <code>Info.plist</code>/<code>entitlements</code>/<code>main.swift</code>/<code>PacketTunnelProvider.swift</code> · <code>Runner/Release.entitlements</code> · <code>Runner.xcodeproj/project.pbxproj</code>(<code>OTHER_LDFLAGS</code>/<code>PRODUCT_NAME</code>/移除 Embed Libbox/<code>--timestamp</code>/版本号) · <code>scripts/local_test.sh</code> · <code>client/macos/sign_libbox.sh</code> · 服务端 <code>server/internal/httpapi/clientconfig.go</code>(DNS 劫持)。验证机:cara,干净 macOS 15.3.2 Intel,出口 IP=节点、国外站可达。</p>
</div>
</body>
</html>
@@ -0,0 +1,593 @@
# Windows 客户端 bring-up + 安装包 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让已写好但从未在真 Windows 上构建/运行的 Windows 客户端跑通端到端(登录→选节点→connect→全局代理出网),并产出 Inno Setup 安装包;顺带把字体改为全平台本地打包。
**Architecture:** 沿用既有「整 app UAC 提权 + `sing-box.exe` 子进程继承管理员权 + wintun TUN 全局代理」子进程方案(`DesktopVpnBridge`),不引入 native helper。UI 复用 `DesktopShell`(原生 Windows 标题栏)。字体从 `google_fonts` 运行时拉取改为本地 ttf 打包(拉丁全量 + Noto Sans SC 7000 字子集 + `fontFamilyFallback`),生产 `PangolinFonts.useBundled=true`
**Tech Stack:** Flutter (desktop/Windows)、Dart、Riverpod、sing-box + wintun、`window_manager`/`tray_manager`、CMake/MSVC、Inno Setup、fonttools(`pyftsubset`)。
## Global Constraints
- Flutter SDK `>=3.24.0`Dart `^3.5.0`(来自 `client/pubspec.yaml`)。
- 目标平台 Windows 10/11app `requireAdministrator`manifest 已设,勿降权)。
- 颜色/间距等设计 token 来自 `design/colors_and_type.css` 单源,**禁止硬编码 hex**;字体 family 名沿用 `PangolinFonts`Sora / Manrope / Noto Sans SC / JetBrains Mono)。
- 字体改动对**全平台生效**macOS/Windows/Linux),不得只在 Windows 分支改。
- 真机相关任务(Task 4–6, 7 的打包验证)由**用户在 Windows 机器执行**Claude 提供逐条 PowerShell 指令并据回贴日志迭代;Claude 在 macOS 无法构建/运行 Windows。
- 提交信息结尾附 `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
---
## 文件结构(本计划新建/修改)
- `client/lib/pangolin_theme.dart`(改)— `PangolinFonts.useBundled` 生产默认 trueCJK 经 `fontFamilyFallback` 接入。
- `client/lib/main.dart`(改)— 桌面窗口默认/最小尺寸。
- `client/pubspec.yaml`(改)— 恢复 `fonts:` 段,声明 4 个 family。
- `client/fonts/`(新建)— 生产 ttf:拉丁 8 个静态权重(复用 `test/fonts/`+ `NotoSansSC-subset.ttf`
- `tools/fonts/make-cjk-subset.sh`(新建)— 生成 Noto Sans SC 子集的可复现脚本。
- `tools/fonts/common-hanzi.txt`(新建)— 子集字表(通用规范汉字 + l10n 实际用字)。
- `client/windows/flutter/generated_plugins.cmake`(视 Task 1 结论可能改,但属生成文件,优先改 pubspec 依赖根因)。
- `client/windows/installer/pangolin.iss`(新建)— Inno Setup 脚本。
- `client/windows/README.md`(改)— 打包/出安装包步骤。
---
## Task 1: 排查并处理 Windows 端 `jni` FFI 插件
**Files:**
- Inspect: `client/windows/flutter/generated_plugins.cmake``client/pubspec.yaml``client/pubspec.lock`(运行 `flutter pub get` 后生成)
- Modify(视结论): `client/pubspec.yaml`
**Interfaces:**
- Consumes: 无
- Produces: 一份明确结论——`jni` 是否需要、是否影响 Windows 构建;若需移除则给出 `pubspec.yaml` 的依赖处置。
- [ ] **Step 1: 装依赖并定位 `jni` 来源**
Run(在 `client/`):
```bash
flutter pub get
flutter pub deps --style=compact | grep -B3 -i "jni" || echo "no jni in dep tree"
```
Expected: 看到哪个直接依赖传递引入了 `jni`(常见于某些原生插件)。记下引入链。
- [ ] **Step 2: 判断 `jni` 是否支持/需要 Windows**
Run
```bash
cat client/windows/flutter/generated_plugins.cmake
ls client/.dart_tool/flutter_build/ 2>/dev/null; flutter pub deps --json | grep -i jni
```
判定规则:
-`jni` 仅被某 Android-only 功能使用、且该功能 Windows 不需要 → 该依赖应在 Windows 不生效;`generated_plugins.cmake` 把它列进 `FLUTTER_FFI_PLUGIN_LIST` 多半是误纳或上游缺 platform 限定。
- 若属误纳:根因在引入它的依赖。能去掉该依赖(Windows 不需要)则在 `pubspec.yaml` 移除/用条件依赖;不能去掉则记录为「Task 4 构建时若报错再处理」的已知风险。
- [ ] **Step 3: 验证 macOS 侧不回归**
Run(在 `client/`):
```bash
flutter analyze
flutter test
```
Expected: analyze 无 error;测试全绿(确认动依赖没破坏现有功能)。
- [ ] **Step 4: 提交(仅当本任务确有改动)**
```bash
git add client/pubspec.yaml client/pubspec.lock
git commit -m "build(client/windows): 处理 jni FFI 插件在 Windows 的纳入问题
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
> 若结论是「无需改动,留待 Task 4 真机构建时按报错处理」,则不提交,仅在计划勾选并把结论记入 Task 4 备注。
---
## Task 2: 桌面窗口默认/最小尺寸
**Files:**
- Modify: `client/lib/main.dart``main()``isDesktop` 分支,`TrayService.instance.init()` 之后)
**Interfaces:**
- Consumes: `windowManager`(来自 `package:window_manager`,已在 `system_tray.dart` 用,`ensureInitialized()` 已在 `TrayService.init()` 调过)
- Produces: 启动后窗口为 920×640、最小 720×560;低于 720 宽不会掉进 `MobileShell``form_factor.dart` 桌面降级阈值 680)。
- [ ] **Step 1: 在 main.dart 顶部引入 window_manager**
`client/lib/main.dart` 增加 import(与现有 import 同区):
```dart
import 'package:window_manager/window_manager.dart';
```
- [ ] **Step 2: 在 TrayService.init() 之后设置窗口尺寸**
`client/lib/main.dart``main()`,把:
```dart
await TrayService.instance.init();
```
改为:
```dart
await TrayService.instance.init();
// 桌面窗口尺寸:默认 920×640;最小宽 720(低于 form_factor 的 680 阈值会
// 掉进 MobileShell,侧栏消失)。init() 内已 windowManager.ensureInitialized()。
await windowManager.setMinimumSize(const Size(720, 560));
await windowManager.setSize(const Size(920, 640));
await windowManager.center();
```
- [ ] **Step 3: 分析 + 测试**
Run(在 `client/`):
```bash
flutter analyze
flutter test
```
Expected: analyze 无 error;测试全绿。
- [ ] **Step 4: macOS 可选实跑确认)**
Run(在 `client/`,仅 macOS 上确认无回归,非 Windows 验证):
```bash
flutter run -d macos
```
Expected: 窗口约 920×640 居中;拖窄到极限不小于 720 宽。确认后关闭。
- [ ] **Step 5: 提交**
```bash
git add client/lib/main.dart
git commit -m "feat(client/desktop): 设定窗口默认 920x640 / 最小宽 720
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: 字体本地打包(全平台离线)
**Files:**
- Create: `client/fonts/`(拉丁 ttf 复用 `client/test/fonts/` 的 8 个文件 + `NotoSansSC-subset.ttf`
- Create: `tools/fonts/make-cjk-subset.sh``tools/fonts/common-hanzi.txt`
- Modify: `client/pubspec.yaml`(恢复 `fonts:` 段)、`client/lib/pangolin_theme.dart`(生产 `useBundled=true` + CJK fallback
**Interfaces:**
- Consumes: `PangolinFonts`family 常量 `display`/`sans`/`cjk`/`mono`)、`PangolinFonts.useBundled`(现默认 false,测试置 true
- Produces: 生产构建用本地 ttf 渲染;中文经 `fontFamilyFallback: [PangolinFonts.cjk]` 用打包的 Noto Sans SC 子集;总字体增量 ≈ 2–3MB。
- [ ] **Step 1: 准备拉丁 ttf 到生产目录**
Run(仓库根):
```bash
mkdir -p client/fonts
cp client/test/fonts/Sora-Regular.ttf client/test/fonts/Sora-SemiBold.ttf client/test/fonts/Sora-Bold.ttf \
client/test/fonts/Manrope-Regular.ttf client/test/fonts/Manrope-Medium.ttf client/test/fonts/Manrope-SemiBold.ttf client/test/fonts/Manrope-Bold.ttf \
client/test/fonts/JetBrainsMono-Regular.ttf \
client/fonts/
ls -la client/fonts/
```
Expected: `client/fonts/` 出现 8 个拉丁 ttf(合计 ≈ 630KB)。
- [ ] **Step 2: 准备 CJK 子集字表**
创建 `tools/fonts/common-hanzi.txt`:内容 = 「通用规范汉字表」常用字 + app 实际用字。先下载常用字表(pinned,若失效改用 `U+4E009FFF` 兜底,见 Step 3 备注),再并入 l10n 中文:
```bash
mkdir -p tools/fonts
# 通用规范汉字(一级+二级,~6500字) — 来源 pinned,若 404 见 Step 3 兜底
curl -fsSL "https://raw.githubusercontent.com/kaienfr/Font/master/learnfiles/%E9%80%9A%E7%94%A8%E8%A7%84%E8%8C%83%E6%B1%89%E5%AD%97%E8%A1%A8.txt" -o tools/fonts/_common_raw.txt || echo "下载失败,改用兜底"
# 抽取仓库内所有中日韩统一表意文字(确保 UI/l10n 实际用字一定在子集里)
grep -rhoE "[\x{4e00}-\x{9fff}]" client/lib | sort -u | tr -d '\n' > tools/fonts/_app_cjk.txt
# 合并去重 → common-hanzi.txt
cat tools/fonts/_common_raw.txt tools/fonts/_app_cjk.txt 2>/dev/null | grep -oE "[\x{4e00}-\x{9fff}]" | sort -u | tr -d '\n' > tools/fonts/common-hanzi.txt
wc -c tools/fonts/common-hanzi.txt
```
Expected: `common-hanzi.txt` 约 1.8万–2.1万字节(≈ 6000–7000 个三字节汉字)。
- [ ] **Step 3: 写子集生成脚本并生成 Noto Sans SC 子集**
创建 `tools/fonts/make-cjk-subset.sh`
```bash
#!/usr/bin/env bash
# 生成 Noto Sans SC 子集(变量字体,保留 wght 轴,单文件覆盖全字重)。
# 依赖: python3 + fonttools(pip install fonttools brotli)。
# 用法: bash tools/fonts/make-cjk-subset.sh
set -euo pipefail
cd "$(dirname "$0")/../.." # 仓库根
SRC_DIR="tools/fonts/_src"
OUT="client/fonts/NotoSansSC-subset.ttf"
CHARS="tools/fonts/common-hanzi.txt"
mkdir -p "$SRC_DIR"
# Noto Sans SC 变量字体(pinned tag);若 404 换 google/fonts 最新 raw 路径。
SRC_TTF="$SRC_DIR/NotoSansSC.ttf"
if [[ ! -f "$SRC_TTF" ]]; then
curl -fsSL "https://github.com/notofonts/noto-cjk/raw/main/Sans/Variable/TTF/NotoSansSC-VF.ttf" -o "$SRC_TTF"
fi
python3 -m fontTools.subset "$SRC_TTF" \
--text-file="$CHARS" \
--output-file="$OUT" \
--layout-features='*' \
--name-IDs='*' \
--retain-gids
ls -la "$OUT"
```
Run
```bash
python3 -m pip install --quiet fonttools brotli
bash tools/fonts/make-cjk-subset.sh
```
Expected: 生成 `client/fonts/NotoSansSC-subset.ttf`,体积 ≈ 1.52.5MB。
> 兜底:若 Step 2 字表下载失败,把 `--text-file=...` 换成 `--unicodes="U+4E00-U+9FFF,U+3000-U+303F,U+FF00-U+FFEF"`(会偏大,约 4–5MB,可接受但非首选)。
- [ ] **Step 4: 校验总增量 ≤ 3MB**
Run
```bash
du -ch client/fonts/*.ttf | tail -1
```
Expected: 合计 ≤ 3MB。若超,回 Step 3 收紧字表或仅保留必要字重。
- [ ] **Step 5: 恢复 pubspec 的 fonts 段**
`client/pubspec.yaml`:把第 49–50 行的注释字体段替换为实际声明(保留 assets 段不动):
```yaml
# ── 字体(本地打包,全平台离线;拉丁全量 + Noto Sans SC 7000字子集)──
fonts:
- family: Sora
fonts:
- asset: fonts/Sora-Regular.ttf
- asset: fonts/Sora-SemiBold.ttf
weight: 600
- asset: fonts/Sora-Bold.ttf
weight: 700
- family: Manrope
fonts:
- asset: fonts/Manrope-Regular.ttf
- asset: fonts/Manrope-Medium.ttf
weight: 500
- asset: fonts/Manrope-SemiBold.ttf
weight: 600
- asset: fonts/Manrope-Bold.ttf
weight: 700
- family: JetBrains Mono
fonts:
- asset: fonts/JetBrainsMono-Regular.ttf
- family: Noto Sans SC
fonts:
- asset: fonts/NotoSansSC-subset.ttf
```
- [ ] **Step 6: 生产开启 useBundled + 接入 CJK fallback**
`client/lib/pangolin_theme.dart`,把:
```dart
static bool useBundled = false;
```
改为:
```dart
// 生产默认 true:用本地打包 ttf,不走 google_fonts 运行时拉取(离线可用)。
static bool useBundled = true;
```
并在每个返回 `TextStyle` 的字体函数里加 CJK fallback。把 `sora`/`manrope`/`jetBrainsMono` 三个 `useBundled ? TextStyle(...)` 分支统一加 `fontFamilyFallback: const [cjk]`。例如 `sora`
```dart
static TextStyle sora({double? fontSize, FontWeight? fontWeight, double? height, double? letterSpacing}) =>
useBundled
? TextStyle(fontFamily: display, fontFamilyFallback: const [cjk], fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing)
: GoogleFonts.sora(fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing);
```
`manrope``TextStyle(...)` 分支同样加 `fontFamilyFallback: const [cjk],``jetBrainsMono``TextStyle(...)` 分支同样加。`manropeTextTheme` 的 bundled 分支改为:
```dart
static TextTheme manropeTextTheme(TextTheme base) =>
useBundled ? base.apply(fontFamily: sans, fontFamilyFallback: const [cjk]) : GoogleFonts.manropeTextTheme(base);
```
- [ ] **Step 7: 测试钩子兼容性(确保测试仍可覆盖 useBundled)**
`client/test/flutter_test_config.dart` 已在测试启动设 `PangolinFonts.useBundled = true` 并注册 `test/fonts/`。生产默认改 true 后测试行为不变;但需确认测试不会因缺少 Noto Sans SC 注册而失败(fallback 字体未注册时 Flutter 用默认字体兜,不抛异常)。
Run(在 `client/`):
```bash
flutter analyze
flutter test
```
Expected: analyze 无 error;测试全绿(含 golden)。若 golden 因字体度量变化失败,确认变化合理后 `flutter test --update-goldens` 并人工 review diff。
- [ ] **Step 8: macOS 实跑确认中文渲染**
Run`client/`macOS):
```bash
flutter run -d macos
```
Expected: 断网或正常网络下,界面中文均正常显示(不再依赖 google_fonts 联网),拉丁字体为 Sora/Manrope。确认后关闭。
- [ ] **Step 9: 提交**
```bash
git add client/fonts/ client/pubspec.yaml client/lib/pangolin_theme.dart tools/fonts/make-cjk-subset.sh tools/fonts/common-hanzi.txt
# 注意:不提交 tools/fonts/_src、_common_raw.txt、_app_cjk.txt 等中间产物
git status # 确认无中间产物被加入;如有,git restore --staged 之
git commit -m "feat(client): 字体改本地打包(拉丁全量 + Noto Sans SC 7000字子集)
生产 useBundled=true,离线可用;中文经 fontFamilyFallback 用打包子集。
增量约 2-3MB。新增 tools/fonts/make-cjk-subset.sh 可复现子集生成。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
> 把 `tools/fonts/_src/`、`tools/fonts/_*.txt` 中间产物加入 `client/.gitignore` 或仓库根 `.gitignore``tools/fonts/_*`)。
---
## Task 4: 真机首次构建(Windows,迭代到 green build
> **执行者:用户在 Windows 10/11 机器。Claude 提供指令、据回贴日志改代码。** 不走 TDD 单测循环;本任务的「测试」= `flutter build windows` 成功。
**Files:**
- 视报错可能 Modify: `client/windows/CMakeLists.txt``client/pubspec.yaml`、插件相关
- 备注:携带 Task 1 关于 `jni` 的结论
**Interfaces:**
- Consumes: Task 13 的全部改动(已在 macOS 验证)
- Produces: `client\build\windows\x64\runner\Release\pangolin_vpn.exe` + 同级 `sing-box.exe` + `wintun.dll`
- [ ] **Step 1: 装 Windows 工具链**
在 Windows 机器:装 Flutter(含 desktop 支持,`flutter config --enable-windows-desktop`)、Visual Studio 2022(勾「使用 C++ 的桌面开发」负载)、Git。验证:
```powershell
flutter doctor -v
```
Expected: "Visual Studio - develop Windows apps" 一项为 ✓,无致命缺失。
- [ ] **Step 2: 拉取仓库的 feature/windows 分支 + 拉内核二进制**
```powershell
git fetch; git checkout feature/windows
# Git Bash 或 WSL 跑(脚本是 bash):
bash app/kernel/fetch-desktop-bin.sh windows amd64
dir app\kernel\dist\desktop\windows-amd64
```
Expected: 出现 `sing-box.exe` + `wintun.dll`SHA256 校验通过)。
> 国内网络若下载失败:开代理后重试,或手动放置二进制到该目录。
- [ ] **Step 3: pub get + 首次 build(管理员终端)**
用**管理员 PowerShell**
```powershell
cd client
flutter pub get
flutter build windows
```
Expected(理想):BUILD SUCCEEDED,产物在 `build\windows\x64\runner\Release\`
若失败:把完整报错(尤其 MSVC / CMake / 插件名 / `jni` 相关行)回贴给 Claude。
- [ ] **Step 4: 迭代修复(Claude ↔ 用户循环)**
按报错类别处理(Claude 据实际日志给具体 patch):
- `jni` 相关链接/编译错误 → 按 Task 1 结论移除依赖或在 Windows 排除。
- `/WX`warning as error)阻断某插件 → 在 `client/windows/CMakeLists.txt` 对该插件 target 放宽(不要全局关 `/WX`)。
- 插件缺 Windows 实现 → 评估替换或条件引入。
- 重复 Step 3 直到 BUILD SUCCEEDED。
- [ ] **Step 5: 确认产物完整**
```powershell
dir build\windows\x64\runner\Release\
```
Expected: 含 `pangolin_vpn.exe``flutter_windows.dll``data\`、以及 `sing-box.exe` + `wintun.dll`CMake install 段自动拷入;若缺,确认 Task 2/§CMake 的 install 触发)。
- [ ] **Step 6: 提交(若有修复改动)**
```powershell
git add -A
git commit -m "fix(client/windows): 真机构建修复(<具体内容>)`n`nCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 5: 端到端连通验证(Windows
> **执行者:用户在 Windows 机器。** 「测试」= 出口 IP 变为节点公网 IP。
**Files:** 无(验证为主,按需回到 Task 4 修复)
**Interfaces:**
- Consumes: Task 4 的 Release 产物 或 `flutter run -d windows`
- Produces: 确认全局代理生效的证据
- [ ] **Step 1: 启动 app(管理员,单次 UAC)**
```powershell
# 开发态:管理员 PowerShell
cd client
flutter run -d windows --dart-define=PANGOLIN_API_URL=http://<后端IP>:8080
```
或直接双击 Release 的 `pangolin_vpn.exe`(会弹一次 UAC)。
Expected: 弹一次 UAC 后进主界面(`DesktopShell`,原生标题栏 + 侧栏 + 内容区),无布局溢出。
- [ ] **Step 2: 记录基线出口 IP**
新开普通终端:
```powershell
curl https://api.ipify.org
```
记下当前(未连)出口 IP。
- [ ] **Step 3: 登录 → 选节点 → connect**
UI 内完成登录、在「节点」选一个节点、回「连接」页点连接。Expected: 状态变为已连接,无报错。
- [ ] **Step 4: 验证全局代理生效**
```powershell
curl https://api.ipify.org
```
Expected: 返回的 IP = 所选节点公网 IP(≠ Step 2 基线)。
若仍为基线 IP 或超时:查 app 日志面板的 `[stderr]/[stdout]`sing-box);常见为 `wintun.dll` 缺失(应与 `sing-box.exe` 同目录)、TUN 创建被拒(确认管理员权)。回贴日志给 Claude。
- [ ] **Step 5: 记录结果**
把 Step 2/4 的 IP、连接耗时、任何告警回贴。无代码改动则本任务仅勾选;有修复则随 Task 4 提交。
---
## Task 6: 功能验证(Windows
> **执行者:用户在 Windows 机器。** 逐项验收,问题回贴 Claude 修复。
**Files:** 无(按需回修)
- [ ] **Step 1: 停止 / 重连**
UI 点断开 → `curl https://api.ipify.org` 应恢复基线 IP;再连接应恢复节点 IP。Expected: 切换干净,无残留。
- [ ] **Step 2: kill-switch**
设置里开 kill-switch → 连接 → 手动结束 `sing-box.exe`(任务管理器)→ Expected: 网络应被切断(strict_route 生效),app 触发自动退避重连。
- [ ] **Step 3: 自动重连**
连接态下杀 `sing-box.exe` → Expected: app 按 1/2/4/8/16/30s 退避自动重连成功(见日志面板)。
- [ ] **Step 4: 托盘 / 关闭即最小化 / 单实例**
点窗口关闭 → Expected: 隐藏到托盘而非退出;托盘左键/「显示主界面」恢复窗口;再启一个实例 → Expected: 唤起已有窗口并自身退出(单实例锁 47654 端口)。
- [ ] **Step 5: 开机自启**
设置里开开机自启 → 重启或登出登入 → Expected: 注册表 Run 项生效,app 随登录启动。
- [ ] **Step 6: 退出无残留**
托盘「退出」 → Expected: 内核先拆隧道再退出;`tasklist | findstr sing-box` 无残留;网络适配器列表无残留 wintun TUN;`curl` 恢复基线 IP。
- [ ] **Step 7: 高分屏 DPI**
在 125%/150% 缩放显示器上 Expected: 界面清晰不糊、不溢出(manifest 已 PerMonitorV2)。
- [ ] **Step 8: 汇总验收**
把 1–7 结果回贴;任何失败项由 Claude 定位修复并回到对应任务提交。
---
## Task 7: Inno Setup 安装包
**Files:**
- Create: `client/windows/installer/pangolin.iss`
- 构建/出包步骤由用户在 Windows 执行
**Interfaces:**
- Consumes: Task 4 的 `build\windows\x64\runner\Release\` 全量
- Produces: `client\windows\installer\Output\pangolin-setup-<ver>.exe`
- [ ] **Step 1: 写 Inno Setup 脚本**
创建 `client/windows/installer/pangolin.iss`
```ini
; 穿山甲 Pangolin Windows 安装包 (Inno Setup 6)
; 编译: 用 Inno Setup Compiler 打开本文件或命令行 ISCC.exe pangolin.iss
; 前置: 先 flutter build windows(Release),产物在 ..\..\build\windows\x64\runner\Release
#define MyAppName "穿山甲 Pangolin"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "Pangolin"
#define MyAppExeName "pangolin_vpn.exe"
#define BuildDir "..\..\build\windows\x64\runner\Release"
[Setup]
AppId={{B2F1B0A0-7A3C-4C2E-9E5A-PANGOLIN0001}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\Pangolin
DefaultGroupName=Pangolin
DisableProgramGroupPage=yes
OutputBaseFilename=pangolin-setup-{#MyAppVersion}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
; 安装需管理员(写 Program Files + app 本身 requireAdministrator)
PrivilegesRequired=admin
ArchitecturesInstallIn64BitMode=x64compatible
[Languages]
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Files]
; 整个 Release 目录(含 pangolin_vpn.exe, flutter_windows.dll, data\, sing-box.exe, wintun.dll, 插件 dll)
Source: "{#BuildDir}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
[UninstallDelete]
; 卸载清理运行期数据(kernel 配置等)
Type: filesandordirs; Name: "{localappdata}\Pangolin"
```
> `MessagesFile` 的 `ChineseSimplified.isl` 需在 Inno Setup 安装目录的 `Languages\` 存在(官方语言包;缺则去掉中文那行或单独下载放入)。
- [ ] **Step 2: 编译安装包(Windows**
装 Inno Setup 6(含 ChineseSimplified 语言包)。先确保已 `flutter build windows`Release),然后:
```powershell
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" client\windows\installer\pangolin.iss
dir client\windows\installer\Output
```
Expected: 生成 `pangolin-setup-1.0.0.exe`
- [ ] **Step 3: 安装 → 运行 → 卸载验证**
- 双击 setup → 中文向导 → 装到 `Program Files\Pangolin`,生成开始菜单 + 桌面快捷方式。
- 从快捷方式启动 → 弹一次 UAC → 可登录/连接/出网(重复 Task 5 Step 4 的 `curl` 检查)。
- 控制面板卸载 → Expected: app 移除,`%LOCALAPPDATA%\Pangolin` 被清理,无残留 TUN/进程。
- [ ] **Step 4: 提交**
```powershell
git add client/windows/installer/pangolin.iss
git commit -m "build(client/windows): Inno Setup 安装包脚本`n`nCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 8: 文档收尾
**Files:**
- Modify: `client/windows/README.md`
**Interfaces:**
- Consumes: Task 17 的实际结论(jni 处置、字体打包、安装包步骤、已知坑)
- Produces: 可复现的 Windows 构建 + 出安装包文档
- [ ] **Step 1: 更新 README**
`client/windows/README.md` 增补/修订:
- 把「未在真 Windows 验证过」状态更新为「已验证:<日期/版本>」。
- 新增「字体本地打包」说明(`tools/fonts/make-cjk-subset.sh` 用法)。
- 新增「出安装包」段(Task 7 的 ISCC 命令 + 前置 flutter build)。
- 记录 Task 4 实际踩的坑与解法(jni / `/WX` 等)。
- [ ] **Step 2: 提交**
```bash
git add client/windows/README.md
git commit -m "docs(client/windows): 更新构建/打包/已知坑文档
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## 完成定义(对应 spec §8 验收标准)
- [ ] `flutter build windows`Release)真机成功(Task 4)。
- [ ] 单次 UAC,主界面 `DesktopShell` 原生标题栏,无溢出(Task 5 Step 1)。
- [ ] 登录→选节点→connect`curl https://api.ipify.org` = 节点公网 IPTask 5 Step 4)。
- [ ] 停止/重连/kill-switch/托盘/开机自启正常,退出无残留 TUN 与孤儿进程(Task 6)。
- [ ] Inno Setup 安装包可装/运行/卸载干净(Task 7)。
- [ ] 字体全平台本地打包,离线中文正常,增量 ≤ 3MB(Task 3)。
@@ -0,0 +1,135 @@
# Windows 客户端 bring-up + 安装包 — 设计方案
- 日期:2026-06-21
- 分支:`feature/windows`
- 范围:把已写好但从未在真 Windows 上构建/运行的 Windows 客户端跑通端到端,并产出 Inno Setup 安装包。
## 1. 背景与现状
Windows 客户端的代码已**基本就绪**,但 `client/windows/README.md` 明确标注「未在真 Windows 机器上构建/验证过」(开发在 macOS)。本方案不是从零开发,而是 **bring-up(真机构建跑通)+ productionization(安装包)**
### 已存在且看起来正确的部分
| 组件 | 文件 | 状态 |
|---|---|---|
| Dart 桥接层(含完整 Windows 分支) | `client/lib/bridge/desktop_vpn_bridge.dart``kernel_process.dart` | 已写:wintun.dll 检查、`%LOCALAPPDATA%\Pangolin\kernel` 配置目录、`.exe` 解析、`Process.kill` 停止 |
| 平台分派 | `client/lib/bridge/vpn_bridge_provider.dart` | Windows → `DesktopVpnBridge` |
| Flutter runner 脚手架 | `client/windows/runner/``main.cpp` | 已写,默认窗口 1280×720 |
| UAC manifest | `client/windows/runner/runner.exe.manifest` | `requireAdministrator`(sing-box 子进程继承管理员权建 TUN) |
| CMake 打包内核 | `client/windows/CMakeLists.txt` | install 步骤把 `sing-box.exe`+`wintun.dll` 拷到 exe 同级 |
| 二进制拉取脚本 | `app/kernel/fetch-desktop-bin.sh` | `windows amd64`sing-box.exe + wintun 0.14.1SHA256 pin |
| UI / 页面 | `client/lib/shell/``screens/``widgets/` | 跨平台,复用 desktop shell(见 §4 |
| 系统托盘 / 单实例 | `client/lib/system_tray.dart` | 跨平台,已声明支持 Windows |
### 从未做的部分(本方案目标)
- ❌ 在真 Windows 机器上 `flutter build windows` / `flutter run`
- ❌ 端到端连通验证(登录→选节点→connect→出网 IP 为节点 IP)。
- ❌ Inno Setup 安装包。
### 明确不做(本轮范围外)
- 代码签名(EV/OV 证书)。
- Windows CI job。
- 免 UAC 的特权 helper 服务(类比 macOS SMJobBless)→ 列入 BACKLOG。
- frameless 自定义标题栏 → 本轮用原生标题栏。
## 2. 决策记录(来自本次 brainstorming
1. **测试环境**:用户有实体 Windows 机器;Claude 在 macOS 改代码并给逐条 PowerShell 指令,用户真机执行并回贴日志,循环迭代。
2. **深度**MVP bring-up + Inno Setup 安装包。
3. **数据面架构**:沿用「整 app UAC 提权 + sing-box.exe 子进程继承管理员权 + wintun TUN 全局代理」的子进程方案(`DesktopVpnBridge`),**不**改 native helper。
4. **UI**:复用现有 `DesktopShell`,无 Windows 专属页面设计。
5. **窗口标题栏**:原生 Windows 标题栏(系统最小/最大/关闭按钮),app 内 `ContentTopBar` 位于其下。
## 3. 数据面架构(沿用既有)
```
Flutter app (pangolin_vpn.exe, requireAdministrator)
└─ DesktopVpnBridge (Dart) ── 写 config_*.json → %LOCALAPPDATA%\Pangolin\kernel\
└─ DesktopKernelProcess ── Process.start("sing-box.exe", run -c config)
├─ sing-box.exe ── 加载同目录 wintun.dll → 建 TUN → 全局代理
└─ Clash API (127.0.0.1:随机端口) ← Dart 轮询状态/流量/切节点/kill-switch
```
与 macOS 的差异:macOS 走原生 System Extension`kUseNativeVpnMacOS`),Windows 本轮继续用子进程 + UAC。两者共用 `DesktopVpnBridge` 之外的所有上层(UI / 状态 / API)。
## 4. UI / 页面方案:复用 desktop shell,零新设计
`core/responsive/form_factor.dart` 按平台 + 窗口宽度判形态:Windows 为桌面平台,窗口宽 ≥680 → `DesktopShell`,与 macOS 桌面**同一份代码、同一套设计 token**(`design/colors_and_type.css` 单源)。
```
┌─ 原生标题栏 ───────────────── – □ × ┐
├─────────┬───────────────────────────┤
│ 侧栏 204 │ ContentTopBar (52, 标题/返回) │
│ 6 项导航 ├───────────────────────────┤
│ ·连接 │ 内容区:Connect / Nodes / │
│ ·节点 │ Stats / Account / Contact / │
│ ·统计 │ Settings(+账户下钻子页) │
│ ·我的 │ │
│ ·联系 │ │
│ ·设置 │ │
└─────────┴───────────────────────────┘
```
页面内容、配色、字体全部来自单源,**Windows 不单独画页面**。系统托盘 + 关闭即最小化 + 单实例锁已是跨平台代码。
本轮属于 Windows 的窗口层调整(小工作量,并入 Phase 1):
- **窗口尺寸**:默认 920×640,最小宽度 720(低于 680 会掉进 `MobileShell` 致侧栏消失)。在 `system_tray.dart``windowManager.ensureInitialized()` 后用 `setMinimumSize` / 默认尺寸设定,或在 `main.cpp` 调整初始尺寸并设最小尺寸。
- **字体(决定:打包,全平台离线)**:放弃 `google_fonts` 运行时拉取,改本地打包 ttf,生产置 `useBundled=true`(影响全平台,顺带消除 macOS 联网拉字体隐患)。
- 拉丁三件套全量变量字体:Sora ~0.3MB + Manrope ~0.15MB + JetBrains Mono ~0.2MB(合计 <1MB)。
- **Noto Sans SC 取 7000 字子集**(通用规范汉字表,用 `fonttools pyftsubset` 生成)≈ 1.5–2.5MB,覆盖几乎全部 UI 文案 + 节点名。
-`fontFamilyFallback` 兜到 Windows 自带微软雅黑(macOS 兜苹方),子集外生僻字不显示豆腐块。
- 总增量 ≈ 2–3MB。落地:ttf 入 `client/fonts/` → 恢复 `pubspec.yaml``fonts:` 段(4 family)→ `useBundled` 改为生产默认 true → 主题设 `fontFamilyFallback`
- **托盘/应用图标**`assets/tray_icon.png` + `app_icon.ico`(已有),真机验证显示即可。
- **标题栏**:保持原生,不做 frameless。
- **DPI**manifest 已 PerMonitorV2,真机验证高分屏不糊/不溢出。
## 5. 已识别的构建风险(真机迭代时盯)
1. **`jni` FFI 插件**`client/windows/flutter/generated_plugins.cmake``FLUTTER_FFI_PLUGIN_LIST``jni`,对 Windows 可疑,可能编译失败 → 先查来源(哪个依赖传递引入),必要时确认其 Windows 支持或移除该依赖。
2. **`/WX`warnings as errors+ `_HAS_EXCEPTIONS=0`**:插件源码有告警即 fail,可能需 per-target 放宽。
3. **插件 Windows 兼容性**`tray_manager` / `window_manager` / `flutter_secure_storage_windows` / `screen_retriever_windows` / `launch_at_startup` 在当前锁定版本下需能编过、运行正常。
4. **内核二进制下载**`fetch-desktop-bin.sh` 依赖 wintun.net 与 sing-box GitHub release 可达,国内网络可能需代理。
5. **`path_provider` 目录一致性**:代码硬编码 `%LOCALAPPDATA%\Pangolin\kernel`,验证与运行时实际目录一致、可写。
## 6. 工作分解
### Phase 1 — MVP bring-up(真机跑通端到端)
1. **macOS 侧预检**Claude 可独立做):`flutter pub get``flutter analyze``dart analyze` 确认无编译期/分析错误;查 `jni` 来源,必要时从 Windows 插件列表处理。
2. **窗口层调整**:默认窗口尺寸 920×640 + 最小宽 720。
3. **真机构建**(用户执行,Claude 给指令):装 Flutter + Visual StudioC++ 桌面负载)→ `app/kernel/fetch-desktop-bin.sh windows amd64` → 管理员终端 `flutter build windows` / `flutter run -d windows --dart-define=PANGOLIN_API_URL=...`
4. **按报错迭代**:用户贴 build/运行日志,Claude 改代码,循环到 build 通过。
5. **端到端连通**:登录 → 选节点 → connect → `curl https://api.ipify.org` 出口为节点公网 IP。
6. **功能验证**:停止、自动重连、kill-switch、托盘(关闭最小化/显示/退出)、开机自启、UAC 单次弹窗、退出时内核拆隧道无残留 TUN/孤儿进程。
7. **字体打包**(见 §4,全平台生效):拉丁三件套全量 + Noto Sans SC 7000 字子集(`pyftsubset`)入 `client/fonts/`,恢复 pubspec `fonts:` 段,生产置 `useBundled=true`,主题设 `fontFamilyFallback`Windows 微软雅黑 / macOS 苹方)。增量 ≈ 23MB。
### Phase 2 — Inno Setup 安装包
8.`client/windows/installer/pangolin.iss`
- 源:`build\windows\x64\runner\Release\`(含 `pangolin_vpn.exe` + Flutter 运行时 + `sing-box.exe` + `wintun.dll` + `data\`)。
- 装到 `Program Files\Pangolin`
- 开始菜单 + 桌面快捷方式(启动项继承 exe 的 `requireAdministrator`)。
- 卸载清理 `%LOCALAPPDATA%\Pangolin`
9. 文档化:更新 `client/windows/README.md` 增加打包/出安装包步骤。
### 打包方案选型
- **A) Inno Setup**(采纳):免费、脚本化、最主流,与「含安装包」一致。
- B) MSIX:与 `requireAdministrator` 沙箱冲突,不合适。
- C) 绿色 zip:无开始菜单/卸载,不满足需求。
## 7. 协作模式
- Claude 无法在 macOS 构建 Windows:所有真机步骤提供逐条可复制的 PowerShell 指令;用户回贴报错/日志 → Claude 改代码 → 循环。
- 主要不确定性是插件 Windows 编译兼容性(尤其 `jni`),可能需数轮迭代。
## 8. 验收标准
- [ ] `flutter build windows`(Release)在真机成功,无报错。
- [ ] 运行后单次 UAC 提权,主界面为 `DesktopShell`(原生标题栏 + 侧栏 + 内容区),无布局溢出。
- [ ] 登录 → 选节点 → connect 成功,`curl https://api.ipify.org` 返回节点公网 IP。
- [ ] 停止 / 重连 / kill-switch / 托盘最小化与退出 / 开机自启均正常;退出无残留 TUN 适配器与孤儿 sing-box 进程。
- [ ] Inno Setup 安装包可安装、生成快捷方式、运行、卸载干净。
+154 -52
View File
@@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.application-identifier</key><string>BYL4KQHMTN.com.pangolin.pangolin</string>
<key>com.apple.developer.team-identifier</key><string>BYL4KQHMTN</string>
<key>com.apple.developer.system-extension.install</key><true/>
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider-systemextension</string></array>
<key>com.apple.security.app-sandbox</key><false/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.network.server</key><true/>
<key>keychain-access-groups</key>
<array><string>BYL4KQHMTN.com.pangolin.pangolin</string></array>
</dict></plist>
PLIST
cat > "$WORK/sysext.entitlements" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.application-identifier</key><string>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</string>
<key>com.apple.developer.team-identifier</key><string>BYL4KQHMTN</string>
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider-systemextension</string></array>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.application-groups</key>
<array><string>group.com.pangolin.pangolin</string></array>
</dict></plist>
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
File diff suppressed because one or more lines are too long
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# 生成 Noto Sans SC 子集,用于 client 离线打包(全平台)。
#
# 策略:GB2312 一级+二级常用字(6763 字,经 Python 编解码确定性生成,无需联网)
# 仓库 client/lib 实际用到的 CJK 字符 → 子集字表;
# 源用 Noto Sans CJK SC 变量字体,先 instance 到 Regular(wght=400)再 subset
# (变量 CFF2 的字重轴体积很大;单 Regular 静态 + 引擎合成粗体,显著缩小)。
# 产物:client/fonts/NotoSansSC-Regular-subset.otf (~3MB,6763 字)。
#
# 依赖:python3 + fonttools(pip install fonttools brotli)。
# 用法:bash tools/fonts/make-cjk-subset.sh
set -euo pipefail
cd "$(dirname "$0")/../.." # 仓库根
SRC_DIR="tools/fonts/_src"
CHARS="tools/fonts/common-hanzi.txt"
OUT="client/fonts/NotoSansSC-Regular-subset.otf"
mkdir -p "$SRC_DIR"
# fonttools:优先用隔离 venv(避免污染系统 python),否则用 python3 -m。
PY="python3"
SUB="python3 -m fontTools.subset"
if [ -x "/tmp/fonttools-venv/bin/python" ]; then
PY="/tmp/fonttools-venv/bin/python"
SUB="/tmp/fonttools-venv/bin/pyftsubset"
fi
# 1) 字表:GB2312 6763 常用字 ∪ 仓库实际用字。
"$PY" - "$CHARS" <<'PYGEN'
import sys, glob
chars=set()
for hi in range(0xB0,0xF8):
for lo in range(0xA1,0xFF):
try:
ch=bytes([hi,lo]).decode('gb2312')
if '一'<=ch<='鿿': chars.add(ch)
except UnicodeDecodeError:
pass
# 仓库实际用到的 CJK(确保 UI/l10n 文案一定在子集内)
for path in glob.glob('client/lib/**/*.dart', recursive=True):
try:
for ch in open(path,encoding='utf-8').read():
if '一'<=ch<='鿿': chars.add(ch)
except (UnicodeDecodeError,OSError):
pass
open(sys.argv[1],'w',encoding='utf-8').write(''.join(sorted(chars)))
print('charset hanzi:',len(chars))
PYGEN
# 2) 源字体(变量 OTF);若已存在则跳过。失效时换 google/fonts 或 notofonts 最新路径。
SRC_TTF="$SRC_DIR/NotoSansSC.ttf"
if [ ! -f "$SRC_TTF" ]; then
curl -fsSL "https://github.com/notofonts/noto-cjk/raw/main/Sans/Variable/OTF/NotoSansCJKsc-VF.otf" -o "$SRC_TTF"
fi
# 3) instance 到 Regular(wght=400)。
STATIC="$SRC_DIR/NotoSC-Regular.otf"
"$PY" -m fontTools.varLib.instancer "$SRC_TTF" wght=400 -o "$STATIC" --quiet
# 4) subset(不用 --retain-gids,否则保留全部原 gid 致体积暴涨)。
"$SUB" "$STATIC" \
--text-file="$CHARS" \
--output-file="$OUT" \
--layout-features='*' \
--name-IDs='*'
ls -la "$OUT"
+2 -2
View File
@@ -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"
}
},
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;