Commit Graph

454 Commits

Author SHA1 Message Date
wangjia eb7c3c1062 feat(agent): WARP 域名分流 —— 命中域名走 Cloudflare 干净出口(#29)
节点 sing-box 渲染新增可选 WARP 分流:节点本地 warp.json(默认
<StateDir>/warp.json)配 WARP 凭证 + 域名清单 → 渲染时注入一个 userspace
WireGuard(WARP)endpoint + route(sniff 取 SNI/Host → domain_suffix 命中走
warp,其余 final=direct)。sing-box 1.11+ endpoints 语法,system=false 用户态
不依赖内核 wg 模块。

- 运营改域名只需编辑 warp.json + 重启 agent(sing-box 无热重载),即「配置的方式」。
- warp.json 不存在/enabled=false/域名空/凭证缺/坏 JSON → 一律按未启用,配置与旧
  节点逐字节一致,坏配置绝不产出无法启动的 sing-box config(渲染读失败仅记日志)。
- WARP 凭证节点私有(wgcf 注册免费匿名账号),不入 git、不经控制面。
- 测试:注入 endpoint+route/无配置无 route/禁用或残缺不注入/坏 JSON 优雅退化。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:40:00 +08:00
wangjia f035552ff5 fix: disconnect 吊销每设备凭证 + 客户端断开时真正调用(F4,#28)
审查 F4 两层问题:①DisconnectNode 只撤账户级 ent.DpUUID,而 connect 下发的是
每设备 devDp——吊销从未对准目标;②客户端从未调用过 disconnect 端点(只有
fetchConfig),端点是死代码,凭证一律活到 TTL(付费 24h)。

- server:disconnect 收 optional body {device_id},吊销该设备 dp_uuid(优先)+
  账户级遗留兜底;旧客户端无 body 走兜底,行为不回归。nil hub 守卫(测试友好,
  与 ListNodes 一致)。
- client:ConnectApi.disconnect(best-effort,5s 超时吞错);_disconnect 加
  revokeCredential 参数,仅在「不会紧接重连同节点」的路径置 true(用户主动断开/
  额度耗尽/登出)——看门狗断开→重连若也吊销,revoke 可能晚于新 connect 推送、
  误杀新会话。
- test:httpapi disconnect 三态(带 device_id 双吊销/无 body 仅兜底/设备已移除
  不炸);client 功能套件 182 过(golden 为已知 macOS 本地漂移,不相关)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:29:01 +08:00
wangjia 63d1baeb01 fix(server): devices 唯一键改 (user_id,uuid) —— 同机换账号不再 403 死结(F3,#27)
device_id 按安装持久、跨账号复用;旧全局 UNIQUE(uuid) 使同机第二账号注册永远
403 → ConnectNode 判 DEVICE_NOT_REGISTERED,提示的「重新登录」无法自救。

- migration 21(sqlite/mysql):UNIQUE(uuid)→UNIQUE(user_id,uuid);platform 放行
  linux(normalizePlatform 早已接受,旧 CHECK/ENUM 会拒)。SQLite 表重建用
  rename→重建→复制→drop 次序,单事务内不触发 sessions 的级联清空(FK ON)。
- 查找全部收口为按 (user,uuid) 作用域(重复 uuid 跨用户后全局查询歧义):
  findDeviceByUserUUIDTx / FindByUserUUID;Register 删跨用户 Forbidden 分支;
  Delete/ForceLogout/Rename 对他人设备返回 404(不可见);SessionActive 删
  「非本人 fail-safe」分支,dev==nil→false 语义不变。
- 测试:SQLite 真迁移库 F3 回归(两账号同 uuid 各自成行/同用户重复拒/linux 入库/
  sessions 重建后级联仍成立)+ MySQL 集成测试 schema 同步与双账号用例。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:16:40 +08:00
wangjia bbe149fd41 docs: 全栈设计审查 2026-07(前端/后端/数据库,13 项发现分级)
P0:控制面明文 HTTP、SQLite 零备份。P1:同机换账号设备注册 403 死结(migration16
注释里推迟的 UNIQUE(user_id,uuid) 未落地)、disconnect 撤账户级凭证而 connect 发
每设备凭证、agent 用量 Reset 后上报失败即丢(at-most-once)、Redis 白名单单点全员
掉线、argon2id 64MiB 并发 OOM。P2:sessions/audit 无留存、免费额度 UTC 日界、
三时钟口径、hub 内存态、单实例端口无握手、ReportUsage 无事务。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:52:08 +08:00
wangjia 14bcdc2e79 docs(contact): 落定渠道二级页设计 —— 统一单表 + LINE 灰置
拍板:不展示成员数;群组=「加入」其余=「打开」;独立 GET /v1/contact;
全渠道(Telegram/LINE/邮箱/发卡)统一一张 contact_link 表,靠 url 协议区分
(mailto:/https:/tg://),邮箱=kind=link+mailto。L1 平台图标/名/强调色由前端
platform 注册表内置(唯一不入库的皮)。LINE 也做二级、同构,但先灰置「即将开放」
(registry comingSoon=[line],配好去灰即用)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:30:24 +08:00
wangjia 91a890428f docs: 联系我们渠道二级页交互设计(Telegram/LINE 多频道群组,DB 配置)
点渠道进二级页,按 kind 分组(频道/群组/Bot)列多条链接,内容 DB 配置。
含可点原型(App 真实暗色 token)+ 单表 contact_link 数据模型 + GET /v1/contact
接口 + 交互规则(平台 >1 条链接才进二级,否则点击直达)。#24,待统一后开发。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:23:02 +08:00
wangjia 2c804d4ebf fix(scripts): local_test.sh 对齐当前 macOS 架构(sysext 标识符名 + 静态 libbox)
发版脚本停留在旧「嵌入 Libbox.framework」架构,与现工程漂移两处、导致 sign 失败:
- sysext bundle 名 = 标识符 com.pangolin.pangolin.PacketTunnel.systemextension
  (PRODUCT_NAME,见 CLAUDE.md),非短名 PacketTunnel.systemextension。
- libbox 现「只 Link 不 Embed」静态进 sysext 二进制,sysext 内已无独立
  Libbox.framework;删掉对不存在 framework 的签名,签 sysext bundle 即覆盖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:11:05 +08:00
wangjia 8937044f55 fix(client/macos): 最小化托盘后点 Dock 图标唤回窗口(1.0.48+49)
windowManager.hide() 走 orderOut: 后主窗口不可见,点 Dock 图标系统只发
applicationShouldHandleReopen(hasVisibleWindows=false),AppDelegate 未接管 →
点了没反应。AppDelegate 实现该回调,无可见窗口时 makeKeyAndOrderFront +
activate 唤回 mainFlutterWindow。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:50:40 +08:00
wangjia 76126f7a0c chore: bump 1.0.47+48 (弱网自动重连#18 + 用量去计)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:58:27 +08:00
wangjia be27529054 feat(client): 弱网抖动先自动重连当前节点,不再动不动报「节点异常」(#18)
弱网下(实测 RTT 2.2s/丢包)urltest 瞬断或内核 off,看门狗此前一判死就断开+要用户手动
重连(nodeUnhealthyError),体验差。改为:无备用节点时先自动重连**当前节点**(不换节点——
弱网是本地网络问题,换节点无益还会来回横跳),连续失败超过 _kMaxAutoReconnect(3) 次才真报
「节点异常」。urltest 成功或用户主动连接即清零计数,只有持续失败才耗尽。

- _tryAutoReconnectCurrent(bounded 重连当前节点)+ _handleUnexpectedOff(kernel off 走同一逻辑)。
- _onNodeUnhealthy 无备用分支改调 _tryAutoReconnectCurrent;智能有备用仍优先切备用(不变)。
- 重连期给「网络波动,正在重连…」瞬态提示(nodeReconnecting,zh/en)。
- 测试:注入时钟 + 内存 SecureKV(避免 deviceId 挂起);kernel-off 连续判死→3 次自动重连后报
  节点异常;路径B 无备用→自动重连当前节点不换节点。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:57:19 +08:00
wangjia daaed39bac fix(server): 用量分钟数按墙上时钟去重 —— 多设备并发不再 N× 超计(bug)
agent 按每 dp_uuid(每设备)每窗口计 SessionMinutes=1,控制面 ReportUsage 逐条累加进
usage_daily.minutes_used → N 台并发 = N×墙上时钟(线上 chenxin 2903 分钟/天,不可能)。
免费额度是「所有设备共同的时间」,超计会把免费账号错误判「时长耗尽」而断连。

改 ReportUsage:账户维度按 user 分组去重 —— 字节仍求和(可加),分钟按窗口取 max
(=窗口墙上分钟,恒为 1)去重,循环后每 user 各 flush 一次 AccumulateUsage/Hourly。
每设备维度(usage_device_*)保持逐台累加不变。usage_daily.minutes_used ≤ Σ 每设备分钟,
/me 的 quota_today_min 与 ConnectNode 免费门随之正确。历史数据次日归零、不回填。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:57:19 +08:00
wangjia 837bf55f6a fix(client): 「今日剩余」不再冻结 —— 倒计时兜底启动 + 会话后静默刷新 /me(1.0.46+47)
免费版「今日剩余 xx 分钟」一直不变的两处原因:
1. 倒计时仅在经 _connect 连接时锁定 _freeRemainingSec;Android 常驻隧道/重启后自动
   同步到已在跑的隧道时不走 _connect → 倒计时不启,连接页只显示静态「今日剩余」。
   现于 _onKernelStatus.on 兜底:免费且未锁定则按当前额度补锁,保证显示会动的倒计时。
2. /me 仅登录时拉一次,会话消耗后不刷新 → 「今日剩余」停在登录值。现于断开/内核掉线
   后静默刷新 /me(新增 MeNotifier.silentRefresh,不进 AsyncLoading → 不闪整页 spinner)。
   markExhausted / watchAd 也改用 silentRefresh,避免额度变动时连接页闪 spinner。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 01:54:53 +08:00
wangjia 038e5b6ab5 fix(server): 被移除的设备会话轮询判活 —— dev 行不存在应判「失效」而非「有效」(bug)
SessionActive 在设备被 DeleteDevice 删行后 FindByUUID 返回 nil,此前一律 return true
(「未知/非本人设备不据此登出」)。结果:被移除的设备轮询 /me/session 永远拿到
active=true → 不登出、不弹框,只表现为数据面凭证被召回→隧道断→客户端误报「节点异常」。
改为:dev==nil(自身 device_id 查无此行,已登录客户端必然注册过 → 只能是被移除)→
return false,让其登出;dev.UserID!=userID(他人 uuid)仍 return true 兜底。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 01:54:53 +08:00
wangjia a274cc40c8 fix(client): 额度卡「今日剩余」标签不再被省略 + 短「免费版」标签(1.0.45+46)
连接页额度卡头部一行放不下「今日剩余 X 分钟 + 免费版·每日10分钟」时,此前 Flexible
落在标签上,导致「今日剩余」被挤成「今日…」。改为:标签固定绝不省略,数值作最后收缩项;
右上角标签缩短为「免费版」(每日额度由卡片本身「今日剩余 X 分钟」表达,不再挤进胶囊)。
新增 planFreeTag l10n(zh 免费版 / en Free),quotaFree 仍保留供账户页 plan_badge_card。
golden 基线(quota_low/exhausted)在 Linux 权威环境重生成。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 01:40:50 +08:00
wangjia fc8c03f2c3 chore: bump 1.0.44+45 (免费版倒计时+看广告加时 #21)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 01:01:42 +08:00
wangjia b461a476a2 feat(client): 免费版连接倒计时 + 到点切断 + 灰按钮 + 看广告加时(#21 前端)
四端共享 Dart 实现,配合 #21 后端账户级卡控:

- me.dart: 加 quota_cap_min(当日总额度 = daily + 看广告 bonus,进度条分母)。
- quota_provider: total 取 quotaCapMin;isExhausted;markExhausted(倒计时归零本地置耗尽
  + 登录态拉 me 校准);watchAd() async 调 /ads/unlock(占位 ad_token=uuid)→ 刷新 me,返回 granted。
- connection_provider: 连接时锁定 _freeRemainingSec(会员 null 不倒计时);复用 elapsed 计时器
  每 tick 算 freeCountdown,归零 → _onFreeQuotaExhausted(主动切断不报节点异常 + markExhausted);
  倒计时用墙上时钟(后台漏跳回前台补上、准时切);连接遇后端 QUOTA_EXHAUSTED 兜底置耗尽。
- connect_button: enabled/onDisabledTap —— 额度耗尽 off 态灰化(锁图标),点击弹加时。
- quota_card: 三态(连接倒计时 mm:ss / 未连接剩余分钟 / 耗尽今日已用完);移动「看广告加时」、桌面「升级会员」。
- ad_reward_dialog(新): 移动端占位广告(播放中→3s→加时→奖励);桌面版硬 10 分钟提示去移动端/升级。
- l10n(zh/en): 倒计时/已用完/看广告加时/占位广告/桌面升级 双语。
- 测试: quota isExhausted/markExhausted;连接倒计时归零自动切断+置耗尽(注入时钟);
  额度卡三态 widget;/me 契约含 quota_cap_min;golden 更新(Linux 权威基线 quota_low/exhausted)。
- 文档: docs/free-quota-ad.html + 登记 docs/index.html。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:33:21 +08:00
wangjia e023fb6579 feat(server): 免费版账户级分钟卡控 + 累加式看广告加时(#21 后端)
免费版此前形同虚设:ConnectNode 每次连接发固定 daily_minutes×1min TTL、
从不扣减已用,重连即崭新 10 分钟,日额度从未强制。改为账户级(全设备共享)
真卡控 + 累加式看广告加时:

- migration 000020: usage_daily 加 ad_bonus_minutes(sqlite+mysql)。
  当日额度 = plans.daily_minutes + ad_bonus_minutes;剩余 = 额度 − minutes_used。
- usage.store.AddAdBonusMinutes: 事务内累加封顶(替代布尔 MarkAdUnlocked),
  返回新总额+本次实际加时;GetDay/GetUsageRange 补读 ad_bonus_minutes。
- usage.service.UnlockAd: verify+nonce 后 +adBonusPerAd(10) 封顶 adDailyBonusCeiling(120),
  返回 granted+remaining;TodaySummary 加 MinutesCap/AdBonusMinutes。
- usage.ads DevVerifier: 放行式占位校验(nonce 防重放仍生效),接真 AdMob 前
  让看广告加时流程可端到端跑通;main.go 按 ADS_DEV_MODE/默认装配。
- ads/unlock: 204 → 200 返回 {granted_minutes, minutes_remaining}。
- ConnectNode 免费门: 读 AccountDayMinutes(used,bonus) → remaining≤0 拒 QUOTA_EXHAUSTED,
  否则 TTL=remaining(凭证到点硬切断兜底)。nodes.store 加 AccountDayMinutes。
- /me: 补 ad_bonus_minutes,quota_today_min 分母改 daily+bonus,加 quota_cap_min。
- quota.CheckFreeConnect 同步累加语义(免费基础 10 分钟不再需先看广告)。
- openapi + 契约/迁移版本/集成测试同步;新增 SQLite AddAdBonusMinutes 单测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:44:10 +08:00
wangjia 0bedd1c4d1 fix(server): 连接删掉账户级 dp_uuid 回退,未注册设备直接拒(#16)
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Has been cancelled
漏洞:设备被移除后行已删,但靠残留 access token + 连接时回退「账户级 dp_uuid」
还能连上(EnsureDeviceDpUUID 找不到设备→回退),且不计入活跃设备数,绕过设备上限。

修:ConnectNode 去掉账户级回退。设备未注册/已被移除(EnsureDeviceDpUUID 返回
ErrDeviceNotFound)→ 403 {code:DEVICE_NOT_REGISTERED} + 提示「请重新登录以重新
注册设备」。客户端现有 ConnectApiException 处理会把该 message_zh 显示在连接页,
用户重新登录即重新注册(并命中登录挡板/超限流程)。

- nodes: 新增哨兵 ErrDeviceNotFound;EnsureDeviceDpUUID 包装它
- httpapi ConnectNode: errors.Is(ErrDeviceNotFound) → 拒连;其余错误 500

go build/vet 干净;nodes/httpapi 测试全过。客户端不用改。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:51:12 +08:00
wangjia 6ea2ec4a32 chore: bump 1.0.43+44 (含连接设备上限 backstop 客户端 UX)
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:25:38 +08:00
wangjia 0a70a24cdb feat: 连接设备上限 backstop — 兜住"已登录的超限设备"(#16)
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Has been cancelled
登录挡板只拦新登录,已登录的超限会话(如 pro 已连 5 台)永远不被提示 → 限制形同虚设。
补服务端连接卡点:超限账户连接直接拒,兜住已登录设备(下次一连即被拦,无需重登)。

服务端:
- Entitlement 加 MaxDevices(EntitlementForUser 查 p.max_devices;free 默认 1)
- NodeStore.CountActiveDevices(近 30d 活跃)+ SQLNodeStore/mock 实现
- ConnectNode:activeCount > MaxDevices → 403 {code:DEVICE_LIMIT_EXCEEDED, max_devices}

客户端:
- ConnectApiException 解析 code/max_devices(结构化错误体)
- _connect 遇 DEVICE_LIMIT_EXCEEDED → 置 deviceLimitProvider → 顶层路由弹「移除设备」页
  (设备列表走 devicesProvider),移除到上限内自动放行

后端 go test(store/nodes/httpapi/devices)全过;前端 analyze 干净 + 66 测试过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:11:13 +08:00
wangjia 8afe1f050c chore(windows): installer 版本 1.0.41→1.0.42 对齐 pubspec
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:30:46 +08:00
wangjia fdca3d84a7 chore: bump 1.0.42+43
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Has been cancelled
含设备数量限制超限挡板(#16)+ 看门狗后台修复 + 连接时长墙上时钟等本轮客户端改动。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:24:39 +08:00
wangjia e6f622d605 docs: 设备数量限制 + 超限 UX 设计文档(#16) + 登记 index
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Has been cancelled
沿用家族样式;涵盖决策(登录闸/踢旧/自动清理)、DB(无 schema 变更)、后端
(devices CheckDeviceLimit + auth 登录闸)、前端(DeviceLimitScreen)、验证与不在本轮。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:26:51 +08:00
wangjia 34d980d875 feat(client): 设备数超限挡板 DeviceLimitScreen(#16 前端)
登录响应带 device_limit 时,顶层路由把用户挡在「移除设备」页,降到上限内才进主界面。

- auth_api.dart:AuthTokens 解析 device_limit;新增 DeviceLimit/DeviceBrief 模型
- auth_provider.dart:deviceLimitProvider(挡板状态);auth_screen 登录后置入
- main.dart:isLoggedIn 后若 deviceLimit!=null → DeviceLimitScreen(挡 HomeShell)
- screens/device_limit_screen.dart(新):设备列表(本机标注不可删)+ 每台移除
  + 一键「移除最久未用」+ 退出登录;降到上限内自动清挡板放行(复用 devicesProvider.remove)
- l10n:deviceLimitTitle/Desc/RemoveOldest(zh/en);顺带修 devicesSub 文案 5→3 台

analyze 干净;client 全量测试 161 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:25:14 +08:00
wangjia 6bac7fd2f0 feat(server): 设备上限登录闸(超限非硬拒登,返回 device_limit 信号)#16
启用设备数量限制的服务端部分。登录照常成功签发 token(非硬拒登),但若账户活跃
设备数超套餐上限,登录响应带 device_limit 信号,客户端据此弹「移除设备」页。

- devices/store.go:CountActiveDevices(last_seen 近 staleWindow)+ PruneStaleDevices
  (删超期僵尸行,免费版重装 churn 自愈)
- devices/service.go:staleWindow=30d;DeviceLimitStatus + CheckDeviceLimit
  (best-effort prune → ResolvePlan → 活跃 count > cap 即 Over,附活跃设备列表)
- auth:DeviceRegistrar 加 CheckDeviceLimit;recordLogin 回传 *DeviceLimit;
  LoginOutcome.DeviceLimit;Login 透传;handler tokenPairResponse.device_limit(omitempty)
- main.go:authDeviceRegistrar 适配 devices.CheckDeviceLimit → auth.DeviceLimit
- 测试:auth 登录透传超限信号(仍签发 token);devices within/over/prune-stale

连接侧 backstop(服务端硬拦)本轮从简未做,作为后续硬化(登录闸为客户端可信信号)。
DB 无 schema 变更。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:16:10 +08:00
wangjia 27dc59ed63 feat(server): pro 套餐设备上限 5 → 3
- seed(sqlite/mysql 000007)+ 001_init:pro max_devices 5→3(全新库直接对)
- 新增迁移 000019(sqlite/mysql,up/down):UPDATE plans SET max_devices=3
  WHERE code='pro' —— 已迁移的线上库靠它更新;down 回退 5
- 000002/001 注释同步(free 1 / pro 3 / team 10)
- 测试:sqlite_migrate 期望版本 18→19;devices 集成测试 pro 断言 5→3
- free(1)/team(10)不变

注:设备上限当前仍未强制执行(登录注册 MaxDevices=0),仅数据/展示口径;
真正启用+超限 UX 见 todo #16。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:07:03 +08:00
wangjia 58211d2fb8 fix(client): 连接时长按墙上时钟算,切后台/锁屏不漏计
iOS 锁屏/切后台后连接页时长少算:elapsed 原来靠每秒 +1 的 Timer 累加,后台
isolate 被挂起、Timer 停跳 → 后台那段时长漏掉。改为记 _connectedAt 起点,
elapsed = now - _connectedAt(墙上时钟),与 timer 是否跳无关;回前台(resumed)
立即 _refreshElapsed 补上。_connectedAt 用 ??= 保留同一会话起点,断开才清。

测试:新增「切后台 300s 时长不漏计」回归(前台 10s + 后台 300s = 310s);全 8 例过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:53:49 +08:00
wangjia 002eea7562 build(scripts): build-libbox apple 按平台分发产物,避免覆盖另一端 Frameworks
只编某一平台时(apple macos / apple ios),产出的 xcframework 只含该平台 slice,
不能覆盖到另一平台的 client/*/Frameworks(否则那端 build 缺 slice)。改为:
空(全 Apple)→ macos+ios 都放;macos→只 macos;ios→只 ios。
本次 iOS 打包已验证只部署到 client/ios/Frameworks。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:37:09 +08:00
wangjia 37c1724b67 fix(client): 切后台停看门狗 — 后台 urltest 被限流不再误判节点死断连
真机(Honor/MagicOS)复现:连上后切后台 ~1min 必断,报「当前节点异常」。
logcat 铁证:进程存活、Dart 看门狗后台照跑,
  [Watchdog] urltest stale 59s → upstream dead → unhealthy → doStop
根因:后台(Doze)把 urltest 主动探测限流 → urltest 停更,但 speed 帧仍在流
→ statsLive=true + urltest 陈旧 → 路径A 误判上游死,把正常隧道断掉。上次的
statsLive 闸只挡「stats 整体停」(macOS 睡眠),挡不住「speed 在流、只 urltest 停」。

修:看门狗是前台 UX 功能(发现节点死好让用户换),后台无 UI 可响应、urltest 必被限流
→ AppLifecycleListener 在 paused/hidden 停看门狗,resumed 且仍连接则重启(基准由
_onStats 首帧/gap 重置)。真·内核掉线(路径A',事件驱动)不受影响,仍上报。

测试:connection_watchdog 新增「切后台停看门狗」回归(后台 speed 在流+urltest 停 >45s
不切节点);全 7 例过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:54:16 +08:00
wangjia b23377acde build(scripts): 各端 libbox 构建脚本修正 + 自动部署产物
排查 Android 打包"libbox.aar not found / Unresolved reference nekohasekai"踩坑:
- scripts/build-libbox.sh:
  · 构建后自动部署产物到工程引用位置(android→app/kernel/dist/android/libbox.aar;
    apple→client/{macos,ios}/Frameworks/Libbox.xcframework),不再留 /tmp 让人手动拷
  · android 自动挑 NDK ≥28(未设 ANDROID_NDK_HOME 时):官方 build_libbox 拉 cronet-go,
    其 libcronet.a 在 NDK 27 lld 链接报 unknown relocation(315)失败
  · 头注释补全前置(NDK≥28 / JDK17 / gomobile 入 PATH)与产物路径
- app/kernel/build-android.sh:补 -javapkg io.nekohasekai —— 缺它产出默认包名 `libbox`,
  与 Kotlin 的 io.nekohasekai.libbox 对不上、APK Kotlin 编译失败
- build.gradle:注释指明两条构建途径 + 包名必须 io.nekohasekai.libbox

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:53:35 +08:00
wangjia 07861ea04b fix(client): 看门狗后台唤醒不再误判节点死→假重连 (#18)
现象:app/Mac 后台或睡眠一段时间再打开,出现"重连过程"但 VPN 没掉。
根因:连通看门狗路径A(urltest stale)用墙上时钟算陈旧度,挂起期 stats 停更、
_lastUrltestOk 冻住;唤醒时一算 >45s 即判上游死 → _onNodeUnhealthy 触发
disconnect+connect(智能模式还自动切节点),但隧道一直好好的。

修(connection_provider.dart 一处):
- _onStats 记 _lastStatsAt(任意 stats 帧时刻);stats 出现 >10s 长间断(挂起/唤醒)
  时把 _lastUrltestOk 前移到现在,给节点新的 45s 窗口自证
- 看门狗路径A 增加 statsLive 闸:仅在 stats 确实在流(距今 <10s)且 urltest 停 ≥45s
  时才判节点死;stats 整体停(挂起未恢复)则跳过本轮
- 加可注入时钟 _now(默认 DateTime.now),使时间驱动的 path A 可单测

测试:connection_watchdog_test 新增两例 —— ① stats 在流但 urltest 停≥45s 仍正常判死
切节点(path A 未被削弱);② 挂起 60s 唤醒不假重连(无 statsLive 闸则会误切,真回归)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:10:34 +08:00
wangjia 6bcdbbe752 revert(macos): killswitch 回退 L0(低优先级暂缓)
用户判定 killswitch 优先级低、暂不做:
- includeAllNetworks 实测堵死整机网络;退化方案 enforceRoutes+on-demand
  几乎等于没做(核心 fail-closed 没拿到),on-demand 还对免费版配额造成
  重连 churn —— 性价比低
- VpnChannel.swift 恢复到 L0 stub(setKillSwitch→result(nil));
  PacketTunnelProvider.swift 已是原样
- docs §6.5 改为「尝试与暂缓」记录,状态表 macOS 回 L0
- local_test.sh 的 ks-* 漏测子命令保留(将来重拾 killswitch 时可用)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:38:47 +08:00
wangjia a32d87c9c0 fix(macos): killswitch 撤掉 includeAllNetworks(堵死整机网络) — 改 enforceRoutes+on-demand
实测 includeAllNetworks=true 把所有流量(含 sing-box 连服务器握手、app 调控制面
请求)在隧道建起前就塞进隧道 → 握手出不去 → 连接失败 + on-demand 死循环 + 整机
断网(连「我的」页账户信息都拉不到、显示 —)。

改为不会误伤握手/控制面的组合:
- configureKillSwitch:includeAllNetworks 恒 false;保留 enforceRoutes(连接期防漏)
  + excludeLocalNetworks + NEOnDemandRule 常开(兜重连)
- 撤回 start() 的 killSwitch option 及扩展侧 includeAllNetworks 透传(回 false)
- 诚实标注:当前 L1→L2 之间,未到 includeAllNetworks-L3;要拿「扛进程被杀」须先
  在扩展内放行服务器/控制面连接,留待真机验证

docs/killswitch-design.html: §6.5 加踩坑修正、状态表 macOS 改「L1→L2 之间」

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:09:04 +08:00
wangjia 97c0fdbbad test(macos): local_test.sh 加 killswitch fail-closed 漏测子命令
ks-baseline:VPN 断开下记录真实公网 IP 基线
ks-test:VPN 连接(killswitch 开)下 sudo 杀扩展进程,在 on-demand 重连
  窗口内反复探 IP —— 任何一次拿到真实 IP 即判泄漏(FAIL),全程被阻断/
  仅隧道 IP 即 fail-closed 生效(PASS);随后验 on-demand 自愈
ks-status:看 VPN 状态 + 统一日志里 killSwitch/includeAllNetworks 打点

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:01:51 +08:00
wangjia 680f1af205 feat(macos): KillSwitch L0→L3 — NE includeAllNetworks+enforceRoutes+on-demand
macOS 走原生 NE,strict_route 不生效。补齐断网保护到天花板 L3:

VpnChannel.swift
- 缓存 killSwitchEnabled(默认 true,与 Dart AppSettings.killSwitch 对齐)
- setKillSwitch 落地:存标志,manager 已装配则即时重写 NE 配置+保存
- configureKillSwitch:includeAllNetworks+enforceRoutes(L2 OS 强制、扛崩溃)
  + NEOnDemandRule 常开(L3)+ excludeLocalNetworks 放行 LAN
  (enforceRoutes/excludeLocalNetworks 为 macOS 11+ API,#available 守卫)
- stop() gotcha:on-demand 常开时先关 isOnDemandEnabled+save 再 stop,
  否则手动断开被 OS 立刻拉回
- start() options 带 killSwitch 传扩展

PacketTunnelProvider.swift
- startTunnel 读 killSwitch 选项(OS on-demand 自启时 nil→默认 true)
- libbox 回调 includeAllNetworks() 返回该值,与 NE 层对齐

docs/killswitch-design.html: 新增 §6.5 实现方案,状态表 macOS L0→L3

验证:xcodebuild CODE_SIGNING_ALLOWED=NO 两 target SwiftCompile 通过;
端到端 fail-closed/on-demand 需真机手测(NE 难单测)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:54:32 +08:00
wangjia e2440e44cc docs: 补 CLAUDE.md 移动端/跨端统计章节 + 新增 urltest 排障文档
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Has been cancelled
- CLAUDE.md:加「移动端(iOS/iPad+Android)原生隧道」「跨端实时统计+urltest 延迟」两节——
  libbox 构建前置(JDK 17/包名/gitignore)、clash_api 注入正解、libbox 单 client Group 坑、
  Dart 共享广播流、连接态跳直连实测;附「最后在线取 last_seen」一句。
- 新增 docs/connect-latency-urltest.html(排障/Runbook):四端 urltest 实现 + 根因链 + 打点法,
  登记进 docs/index.html。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:56:10 +08:00
wangjia 53fb6492b1 chore: 整理——gitignore android/.gradle 构建缓存 + 同步 todo 看板
- client/.gitignore 加 /android/.gradle/(gradle 构建缓存,不该入 git)。
- 同步 todo.json/html 到当前状态。

注:server/internal/pb/agent/v1 是 stray 生成产物(未被任何代码 import,代码用手写的
agentv1),未跟踪、不影响合并;清理需 rm,留给用户(! rm -rf server/internal/pb/agent)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:52:32 +08:00
wangjia 00bec95255 chore(client): 清理 tablet golden 旧命名孤儿文件(#11)
删除 5 个旧命名(无 theme/lang 后缀)且无任何测试引用的 tablet golden:
tablet_account/connect/connect_dark/servers/stats.png。当前 tablet_pages_golden_test 的
命名矩阵是 tablet_<view>_{light_zh,light_en,dark_zh}(12 个),孤儿是早期命名残留。
顺手清掉 test/golden/failures/(gitignored 的失败产物)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:47:39 +08:00
wangjia d0d39c93f6 fix(client): 设备列表离线时间改用 last_seen「最后在线」(原 last_login 不准)
设备列表对离线设备显示「最后登录 X 前」,取的是 last_login(会话创建那一刻)。一直在线
的设备断开后会错显成登录时间(如 13 小时前),与实际「最后活跃」不符。改用 last_seen
(连接/用量上报/~15s 会话轮询都会刷新),标签相应改为「最后在线」/「Last online」。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:30:43 +08:00
wangjia 1666b1d462 fix(client/android): libbox 包名 io.nekohasekai.libbox(原 import libbox.* 编不过)
gomobile(sing-box build_libbox)产出的 AAR 包是 io.nekohasekai.libbox,而 Kotlin 一直
import libbox.*(含内联 libbox.NetworkInterface)→ 全模块 Unresolved。此前 Android 从未编
到这步(libbox.aar 缺失),故未暴露。改为 io.nekohasekai.libbox.* 后 APK 构建通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:20:52 +08:00
wangjia e994c44116 feat(client/android): urltest 延迟与 iOS/macOS 对齐 + 开明文(联调 http API)
Android 同 iOS 根因:单 CommandClient 的 Group 命令拿不到 urltest + 配置无 clash_api。
- PangolinVpnService:startKernel 注入 clash_api(127.0.0.1)+ cache_file;新增 pollClash
  经本地 HTTP 查 /proxies(history)+ /group/<name>/delay 取真实延迟,替换原依赖 Group
  回调的 urlTestTimer(Group 不回调,延迟恒空)。逻辑与 iOS StatsCollector 完全一致。
- Manifest:usesCleartextTraffic=true —— 控制面 API 当前 http,Android 9+ 默认禁明文,
  否则登录就连不上(生产转 https 后去掉)。

注:需 libbox.aar(gomobile,要 JDK 17)才能编译验证;本机仅 JDK 21,待装 17。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:06:11 +08:00
wangjia 328d34a320 fix(client): 「我的」页加「设置」入口(我的设备下面)——iPad 竖屏/手机可达设置
iPad 竖屏回落 mobile 底 Tab(无侧栏),设置入口够不着。在账户页「我的设备」下面加一行
「设置」→ 下钻 SettingsScreen(复用桌面 SettingsPage + 移动返回栏)。与「联系我们」一致:
desktop/tablet 侧栏 + 账户页都可进。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:48:54 +08:00
wangjia 849e30dd05 fix(client): iPad(tablet)对齐桌面——侧栏补设置/联系我们 + 顶部避让状态栏
iPad 测试反馈:① 平板侧栏只有 连接/节点/统计,缺「设置」「联系我们」(桌面侧栏有,只能从
「我的」页进);② 顶部 brand/标题与 iOS 状态栏重叠(TabletShell 没包 SafeArea)。

- TabletShell:侧栏补 设置/联系我们 两项 + content 路由到 SettingsPage/ContactPage(对齐
  DesktopShell);整体包 SafeArea(bottom:false)避开状态栏/notch。
- SettingsPage:开机自启是桌面登录项概念,iOS/Android 无此能力 → 仅 desktop 平台显示该行。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:36:33 +08:00
wangjia ae5d497f0b feat(client/ios): 实时统计+urltest 延迟与 macOS 对齐 + 修 pbxproj UUID 冲突
把 macOS 已验证的统计架构移植到 iOS,补齐连接页延迟(此前 iOS 同 macOS 根因:扩展没注入
clash_api + app 单 client Status+Group → urltest 恒空):
- 扩展 PacketTunnelProvider:加 injectClashApi(clash_api+cache_file)+ 进程内 StatsCollector
  (libbox 取速率 + clash /proxies、/group/delay 取 urltest);handleAppMessage 增 action="stats"。
- app StatsClient:改为每秒 sendProviderMessage("stats") 拉取 → VpnStatsStreamHandler 推送,
  去掉直连 socket 的单 client Status+Group 坏写法。

修两处编译阻断(框架补齐后才暴露,此前 iOS 从未编译到这步):
- pbxproj 致命 bug:StatsClient.swift 与 PacketTunnelProvider.swift 复用同一 UUID
  (...0002...AA/AB),Xcode 无法区分 → 文件错配 target、全模块 cannot-find 级联。
  PacketTunnelProvider.swift 改用独立 ...0007... ID。
- tunFd 的 CTLIOCGINFO 单行 | 链拆成子表达式,避免 Swift 类型检查器超时。

前置:scripts/build-libbox.sh apple ios 生成 client/ios/Frameworks/Libbox.xcframework(gitignore)。
Dart 侧无需改(iOS 同走 VpnNativeBridge,本轮 macOS 的广播流/_onStats 回退/连接态跳直连实测已共享)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:12:28 +08:00
wangjia 7add0259b4 chore(client/macos): 清理延迟排查的诊断打点 + debug 注入(最终干净版)
延迟显示已修通。移除排查期的临时诊断:扩展侧 Log 客户端/klog 捕获、log.level=debug 注入、
diag 字段/setDiag;主 app StatsClient 每5帧的 dl/ul/urltest 打印;Dart 侧 [Stats]/[Ping] 打点。
保留全部修复:clash_api 注入取 urltest、sendProviderMessage 统计通道、/proxies history+active
缓存、共享广播流、_onStats 回退 effectiveNode、连接态跳过直连实测。

CURRENT_PROJECT_VERSION 52→53。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:03:30 +08:00
wangjia e00163e2d9 fix(client): statsStream/statusStream 缓存为共享广播流,修延迟不回写
根因:VpnNativeBridge.statsStream getter 每次访问都 receiveBroadcastStream(),多订阅者
(连接页速度 + ConnectionController._onStats 回写 urltest 延迟)各起一条原生订阅;但
EventChannel 原生侧只留一个 sink(StatsStreamHandler.onListen 覆盖),后订阅者赢、先订阅者
变哑。速度订阅赢了 sink → _onStats 收不到 → urltest 实测(已确认到达 native:reality-out=595)
永不回写 node.ping → 连接页延迟一直 —。

修复:receiveBroadcastStream() 只调一次、缓存成 asBroadcastStream() 共享流,所有 Dart
订阅者复用同一条原生订阅。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:57:37 +08:00
wangjia e6e7d5db10 diag(client): _onStats 顶部无条件打点 + 订阅打点,确认是否被调用
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:47:16 +08:00
wangjia d14f7a0ec5 diag(client): _onStats/setLivePing 打点,定位延迟为何不回写 node.ping
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:26:56 +08:00
wangjia dbe657f997 fix(client): 自动连接到已运行隧道时延迟显示 — —— _onStats 回退 effectiveNode
连接页延迟一直 —:自动连接/重连到「已在跑的隧道」时不走 _connect → _connectedNode 为
null → _onStats 提前 return,内核 urltest 实测(已确认到达 app:reality-out=579)无处回写
node.ping。与看门狗 _checkHealth/_probe 一致,回退到 effectiveNode(当前/智能所选节点)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:17:57 +08:00
wangjia e3fb5239a6 diag(client/macos): 抓 sing-box urltest 内核日志,定位探测为何失败
确认 urltest 为空是内核探测 100% 失败(传输/读取链路均正常)。本次注入 log.level=debug,
StatsCollector 新增 Log 命令客户端,抓取内核日志里 urltest/gstatic 相关行(失败原因:
DNS/连接/超时),塞进 stats 的 klog 字段经主 app 可读日志透出 —— 直接看到内核报错。

CURRENT_PROJECT_VERSION 51→52。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:58:07 +08:00