Merge remote-tracking branch 'origin/main' into feat/pay-v2-integration
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Lint — shellcheck (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 40s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 19s
ci-pangolin / Flutter — analyze + test (push) Failing after 4m59s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m51s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Failing after 1m33s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m59s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 4s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Lint — shellcheck (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 40s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 19s
ci-pangolin / Flutter — analyze + test (push) Failing after 4m59s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m51s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Failing after 1m33s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m59s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 4s
# Conflicts: # docs/index.html # server/cmd/server/main.go
This commit is contained in:
@@ -40,11 +40,19 @@ class MeNotifier extends AsyncNotifier<Me> {
|
||||
return ref.read(accountApiProvider).me();
|
||||
}
|
||||
|
||||
/// 手动刷新(如连接/兑换后)。
|
||||
/// 手动刷新(如兑换后 / 下拉刷新)。会短暂进 loading 态(触发整页转圈)。
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => ref.read(accountApiProvider).me());
|
||||
}
|
||||
|
||||
/// 静默刷新:不进 loading 态、成功才替换。供连接期/断开后刷新「今日剩余」用,
|
||||
/// 避免 refresh() 的 AsyncLoading 让连接页整页闪 spinner(meLoadingProvider)。
|
||||
Future<void> silentRefresh() async {
|
||||
if (!ref.read(authProvider).isLoggedIn) return;
|
||||
final next = await AsyncValue.guard(() => ref.read(accountApiProvider).me());
|
||||
if (next is AsyncData<Me>) state = next;
|
||||
}
|
||||
}
|
||||
|
||||
final meProvider = AsyncNotifierProvider<MeNotifier, Me>(MeNotifier.new);
|
||||
|
||||
@@ -1,21 +1,77 @@
|
||||
// app_providers.dart — 语言 / 主题 / 套餐视角等基础状态(Riverpod)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../l10n/strings_en.dart';
|
||||
import '../l10n/strings_es.dart';
|
||||
import '../l10n/strings_ja.dart';
|
||||
import '../l10n/strings_ko.dart';
|
||||
import '../l10n/strings_ru.dart';
|
||||
import '../l10n/strings_zh.dart';
|
||||
import 'account_providers.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
/// 当前语言(单显)。设置/账户页段控切换。
|
||||
final localeProvider = StateProvider<AppLang>((ref) => AppLang.zh);
|
||||
/// AppLang → 文案资源实例。供 [appTextProvider] 与「拿不到 Consumer 的场景」
|
||||
/// (model / 非 Consumer 的 tile,只有 AppLang)复用,避免各处重写 switch。
|
||||
AppText appTextFor(AppLang lang) {
|
||||
switch (lang) {
|
||||
case AppLang.zh:
|
||||
return const StringsZh();
|
||||
case AppLang.en:
|
||||
return const StringsEn();
|
||||
case AppLang.ja:
|
||||
return const StringsJa();
|
||||
case AppLang.ko:
|
||||
return const StringsKo();
|
||||
case AppLang.ru:
|
||||
return const StringsRu();
|
||||
case AppLang.es:
|
||||
return const StringsEs();
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前语言(单显)。默认英文(国际化默认语种);用户选择持久化到
|
||||
/// shared_preferences(key `pg_lang`,存枚举 name),重启保留 —— 原来无持久化,
|
||||
/// 切了语言重启会丢。设置/账户页经 `.notifier).set(lang)` 切换。
|
||||
class LocaleNotifier extends StateNotifier<AppLang> {
|
||||
LocaleNotifier() : super(AppLang.en) {
|
||||
_load();
|
||||
}
|
||||
static const _key = 'pg_lang';
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final saved = (await SharedPreferences.getInstance()).getString(_key);
|
||||
if (saved != null) {
|
||||
for (final l in AppLang.values) {
|
||||
if (l.name == saved) {
|
||||
state = l;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* 读失败保持默认 en */
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> set(AppLang lang) async {
|
||||
state = lang;
|
||||
try {
|
||||
await (await SharedPreferences.getInstance()).setString(_key, lang.name);
|
||||
} catch (_) {
|
||||
/* 持久化失败忽略,本次会话仍生效 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final localeProvider =
|
||||
StateNotifierProvider<LocaleNotifier, AppLang>((ref) => LocaleNotifier());
|
||||
|
||||
/// 由语言派生的文案资源——UI 一律通过它取文案,不写死字面量。
|
||||
final appTextProvider = Provider<AppText>((ref) {
|
||||
final lang = ref.watch(localeProvider);
|
||||
return lang == AppLang.zh ? const StringsZh() : const StringsEn();
|
||||
});
|
||||
final appTextProvider = Provider<AppText>((ref) => appTextFor(ref.watch(localeProvider)));
|
||||
|
||||
/// 主题模式。默认跟随系统;设置页可显式切深色。
|
||||
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
|
||||
|
||||
@@ -19,9 +19,11 @@ import '../services/api_config.dart';
|
||||
import '../services/auth_api.dart';
|
||||
import '../services/connect_api.dart';
|
||||
import '../services/device_identity.dart';
|
||||
import 'account_providers.dart';
|
||||
import 'app_providers.dart';
|
||||
import 'auth_provider.dart';
|
||||
import 'nodes_provider.dart';
|
||||
import 'quota_provider.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
// 设备 ID 由 deviceIdentityProvider 提供(secure storage 持久化的稳定 UUID)。
|
||||
@@ -35,7 +37,12 @@ enum VpnPhase { off, connecting, on }
|
||||
// ── 连接状态快照 ──────────────────────────────────────────────────
|
||||
|
||||
class ConnectionState {
|
||||
const ConnectionState({required this.phase, this.elapsed = Duration.zero, this.error});
|
||||
const ConnectionState({
|
||||
required this.phase,
|
||||
this.elapsed = Duration.zero,
|
||||
this.error,
|
||||
this.freeCountdown,
|
||||
});
|
||||
|
||||
final VpnPhase phase;
|
||||
final Duration elapsed;
|
||||
@@ -43,18 +50,27 @@ class ConnectionState {
|
||||
/// 连接失败/中断原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。
|
||||
final String? error;
|
||||
|
||||
ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) =>
|
||||
ConnectionState(phase: phase ?? this.phase, elapsed: elapsed ?? this.elapsed);
|
||||
/// 免费版连接期剩余额度(倒计时);null = 不适用(会员/未连接/额度不限)。
|
||||
/// 由状态机按「连接时锁定的剩余额度 − 已用时长」本地推算,归零即自动切断。
|
||||
final Duration? freeCountdown;
|
||||
|
||||
ConnectionState copyWith({VpnPhase? phase, Duration? elapsed, Duration? freeCountdown}) =>
|
||||
ConnectionState(
|
||||
phase: phase ?? this.phase,
|
||||
elapsed: elapsed ?? this.elapsed,
|
||||
freeCountdown: freeCountdown ?? this.freeCountdown,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ConnectionState &&
|
||||
other.phase == phase &&
|
||||
other.elapsed == elapsed &&
|
||||
other.error == error;
|
||||
other.error == error &&
|
||||
other.freeCountdown == freeCountdown;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(phase, elapsed, error);
|
||||
int get hashCode => Object.hash(phase, elapsed, error, freeCountdown);
|
||||
}
|
||||
|
||||
// ── 连通看门狗 ─────────────────────────────────────────────────────
|
||||
@@ -76,6 +92,8 @@ const _kUrltestStale = Duration(seconds: 45);
|
||||
// stats 帧在此时长内到过即视为「在流」。超过(app 挂起/唤醒未恢复)时看门狗路径 A 不判活,
|
||||
// 避免把 app 被挂起的空档错算成节点死。须 > 原生 stats 轮询间隔(~1s),留足余量。
|
||||
const _kStatsLive = Duration(seconds: 10);
|
||||
// 无备用节点时,弱网抖动判「节点异常」先自动重连当前节点的最大次数;超过才真报错。#18
|
||||
const _kMaxAutoReconnect = 3;
|
||||
|
||||
// ── 状态机 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -92,7 +110,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
_authSub = _ref.listen<AuthState>(authProvider, (prev, next) {
|
||||
if ((prev?.isLoggedIn ?? false) && !next.isLoggedIn) {
|
||||
_userDisconnect = true; // 视为「非节点异常」的主动断开,不弹「节点异常」
|
||||
unawaited(_disconnect());
|
||||
// 登出也尝试吊销凭证(F4);token 可能已失效,best-effort 吞错。
|
||||
unawaited(_disconnect(revokeCredential: true));
|
||||
}
|
||||
});
|
||||
// 生命周期闸:切后台停看门狗,回前台再开。原因:后台(尤其 Android Doze)会把 urltest
|
||||
@@ -140,6 +159,12 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
ConnectApi? _api;
|
||||
// 实际所连节点(连接时锁定):看门狗探测/判活针对它,而非会随 ping 漂移的 effectiveNode。
|
||||
Node? _connectedNode;
|
||||
// 无备用节点时「先自动重连当前节点」的连续尝试计数;连接恢复健康(urltest 成功)或
|
||||
// 用户主动连接时清零,只有持续失败才耗尽额度后真报「节点异常」。#18
|
||||
int _autoReconnectAttempts = 0;
|
||||
// 免费版:连接时锁定的剩余额度(秒);连接期按「_freeRemainingSec − 已用秒」倒计时,
|
||||
// 归零即自动切断(_onFreeQuotaExhausted)。null = 会员/额度不限,不倒计时。
|
||||
int? _freeRemainingSec;
|
||||
|
||||
// ── 公有 API ───────────────────────────────────────────────────
|
||||
|
||||
@@ -147,10 +172,11 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
void toggle() {
|
||||
switch (state.phase) {
|
||||
case VpnPhase.off:
|
||||
_autoReconnectAttempts = 0; // 用户主动连接:重置弱网自动重连额度(#18)
|
||||
_connect();
|
||||
case VpnPhase.on:
|
||||
_userDisconnect = true; // 用户主动断开:其 kernel off 不当作节点异常
|
||||
_disconnect();
|
||||
_disconnect(revokeCredential: true); // 服务端同步吊销本设备凭证(F4)
|
||||
case VpnPhase.connecting:
|
||||
break; // 握手进行中,不响应
|
||||
}
|
||||
@@ -170,10 +196,15 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
_userDisconnect = false;
|
||||
_lastUrltestOk = null; // 重置 urltest 判活基准(连上后由 _onStats 首次成功置位)
|
||||
_lastStatsAt = null; // 重置 stats 在流基准(连上后由 _onStats 首帧置位)
|
||||
// 免费版:锁定本次连接可用的剩余额度(账户共享,权威取自 me)。会员为 null 不倒计时。
|
||||
_freeRemainingSec = _ref.read(isFreePlanProvider)
|
||||
? _ref.read(quotaProvider).remainingMinutes * 60
|
||||
: null;
|
||||
state = const ConnectionState(phase: VpnPhase.connecting);
|
||||
|
||||
final node = _ref.read(effectiveNodeProvider);
|
||||
final zh = _ref.read(localeProvider) == AppLang.zh;
|
||||
final lang = _ref.read(localeProvider);
|
||||
final zh = lang == AppLang.zh; // 仅用于服务端 e.messageZh/En 的二选一(下方)
|
||||
logLine('Connect', '_connect node=${node.code} uuid=${node.uuid.isEmpty ? "EMPTY" : "ok"} '
|
||||
'selected=${_ref.read(selectedNodeCodeProvider)} nodes=${(_ref.read(nodesProvider).valueOrNull ?? const []).length}');
|
||||
|
||||
@@ -182,7 +213,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
if (mounted) {
|
||||
state = ConnectionState(
|
||||
phase: VpnPhase.off,
|
||||
error: zh ? '节点尚未就绪,请稍候重试' : 'Nodes not ready, please retry',
|
||||
error: lang.nodesNotReady,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -202,13 +233,19 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
if (mounted) state = const ConnectionState(phase: VpnPhase.off);
|
||||
return;
|
||||
}
|
||||
// 免费额度已用完(服务端兜底):本地置耗尽 → 按钮灰化、点击弹广告/升级。回 off。
|
||||
if (e.code == 'QUOTA_EXHAUSTED') {
|
||||
_ref.read(quotaProvider.notifier).markExhausted();
|
||||
if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn);
|
||||
return;
|
||||
}
|
||||
// 把后端/网络错误冒泡到 UI(原静默回 off,用户不知所以)。
|
||||
if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
state = ConnectionState(
|
||||
phase: VpnPhase.off,
|
||||
error: zh ? '连接失败,请重试' : 'Connection failed, please retry',
|
||||
error: lang.connectFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -242,7 +279,23 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
/// [revokeCredential]:同时通知控制面吊销本设备在该节点的数据面凭证(F4)。
|
||||
/// 仅在「不会紧接着重连同一节点」的路径置 true(用户主动断开/额度耗尽/登出)——
|
||||
/// 看门狗「断开→立刻重连」若也吊销,revoke 可能在新 connect 推完凭证后才到达、
|
||||
/// 把新会话杀掉。fire-and-forget:不阻塞本地拆隧道与 UI 回 off。
|
||||
Future<void> _disconnect({bool revokeCredential = false}) async {
|
||||
if (revokeCredential) {
|
||||
final api = _api;
|
||||
final node = _connectedNode;
|
||||
if (api != null && node != null && node.uuid.isNotEmpty) {
|
||||
unawaited(() async {
|
||||
try {
|
||||
final deviceId = await _ref.read(deviceIdentityProvider).deviceId();
|
||||
await api.disconnect(nodeId: node.uuid, deviceId: deviceId);
|
||||
} catch (_) {/* best-effort */}
|
||||
}());
|
||||
}
|
||||
}
|
||||
_stopElapsed();
|
||||
_stopWatchdog();
|
||||
try {
|
||||
@@ -250,12 +303,26 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
} catch (_) {}
|
||||
// 携带 _offNotice(节点异常/null);与 kernel off 读同一字段,不互相覆盖。
|
||||
if (mounted) state = ConnectionState(phase: VpnPhase.off, error: _offNotice);
|
||||
_refreshQuota(); // 会话结束 → 刷新「今日剩余」反映本次消耗
|
||||
}
|
||||
|
||||
// 静默刷新 /me(不闪整页 spinner),让免费额度「今日剩余」跨会话/跨设备更新。
|
||||
void _refreshQuota() {
|
||||
if (_ref.read(authProvider).isLoggedIn && _ref.read(isFreePlanProvider)) {
|
||||
unawaited(_ref.read(meProvider.notifier).silentRefresh());
|
||||
}
|
||||
}
|
||||
|
||||
void _onKernelStatus(VpnStatus s) {
|
||||
if (!mounted) return;
|
||||
switch (s) {
|
||||
case VpnStatus.on:
|
||||
// 免费版:若本次「on」不是经 _connect 而来(如 Android 常驻隧道、app 重启后
|
||||
// 自动同步到已在跑的隧道),_freeRemainingSec 还是 null → 倒计时不启。这里兜底
|
||||
// 按当前额度补锁定,保证连接页显示的是倒计时(会变)而非静态「今日剩余」。
|
||||
if (_freeRemainingSec == null && _ref.read(isFreePlanProvider)) {
|
||||
_freeRemainingSec = _ref.read(quotaProvider).remainingMinutes * 60;
|
||||
}
|
||||
state = state.copyWith(phase: VpnPhase.on);
|
||||
_startElapsed();
|
||||
_startWatchdog();
|
||||
@@ -269,15 +336,20 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
// 必须排除 connecting 握手期的瞬态 off——macOS NE 连接序列是 off→connecting→off→
|
||||
// connecting→on,connecting 期的 off 是握手抖动、不是异常(否则一连接就误报「节点异常」)。
|
||||
final wasConnected = state.phase == VpnPhase.on;
|
||||
_stopElapsed();
|
||||
_stopWatchdog();
|
||||
if (!_userDisconnect && wasConnected && _offNotice == null) {
|
||||
_offNotice = _ref.read(appTextProvider).nodeUnhealthyError;
|
||||
// 意外掉线(非用户主动、曾连上;弱网抖动最常见的就是这条 kernel off)。先自动重连
|
||||
// **当前节点**(不换节点——弱网是本地网络问题,换节点无益还会来回横跳),重试用尽才
|
||||
// 报「节点异常」。(wasConnected 闸:重连握手期再掉线时 phase 已非 on,不自我循环触发。)#18
|
||||
logLine('Watchdog', 'unexpected kernel ${s.name} after connected → node interrupted');
|
||||
unawaited(_ref.read(nodesProvider.notifier).refresh());
|
||||
_userDisconnect = false;
|
||||
unawaited(_handleUnexpectedOff());
|
||||
return;
|
||||
}
|
||||
state = ConnectionState(phase: VpnPhase.off, error: _offNotice);
|
||||
_userDisconnect = false;
|
||||
_stopElapsed();
|
||||
_stopWatchdog();
|
||||
_refreshQuota(); // 内核掉线也算会话结束 → 刷新「今日剩余」
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +372,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
if (ds.isEmpty) return;
|
||||
// urltest 成功:经 proxy 出站(REALITY 到节点)真实可达 → 记录时刻(供路径 A 判活)+ 回写延迟。
|
||||
_lastUrltestOk = now;
|
||||
_autoReconnectAttempts = 0; // 节点已恢复健康 → 清零自动重连计数,下次抖动重获满额重试。#18
|
||||
final best = ds.reduce((a, b) => a < b ? a : b);
|
||||
_ref.read(nodesProvider.notifier).setLivePing(node.uuid, best);
|
||||
}
|
||||
@@ -370,12 +443,38 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 手动选定节点(或智能模式无其他可用节点):断开并提示,尊重用户选择、不自动换。
|
||||
// 提示走 _offNotice,由 _disconnect/_onKernelStatus 应用,避免被 kernel off 覆盖。
|
||||
// 无备用节点(手动选定节点 / 智能但无其他可用):弱网抖动别一判死就断开+要用户手动重连。
|
||||
// 先自动重连**当前节点**,连续失败超过上限再真报「节点异常」。#18
|
||||
if (await _tryAutoReconnectCurrent()) return;
|
||||
// 重试用尽:断开并提示「节点异常」。提示走 _offNotice,由 _disconnect/_onKernelStatus 应用。
|
||||
_offNotice = t.nodeUnhealthyError;
|
||||
await _disconnect();
|
||||
}
|
||||
|
||||
/// 弱网抖动:自动重连**当前节点**(不换节点、不改 selectedNode),bounded。
|
||||
/// 返回 true = 已发起重连(额度内);false = 已用尽额度,调用方应改报「节点异常」。#18
|
||||
/// urltest 成功(_onStats)或用户主动连接(toggle)会把计数清零 → 只有持续失败才耗尽。
|
||||
Future<bool> _tryAutoReconnectCurrent() async {
|
||||
if (_autoReconnectAttempts >= _kMaxAutoReconnect) return false;
|
||||
_autoReconnectAttempts++;
|
||||
final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider);
|
||||
logLine('Watchdog', 'auto-reconnect current node ${node.code} (attempt $_autoReconnectAttempts/$_kMaxAutoReconnect)');
|
||||
await _disconnect();
|
||||
await _connect();
|
||||
// 重连握手中给「网络波动,正在重连…」瞬态提示(连上后随 copyWith 清除)。
|
||||
if (mounted && state.phase != VpnPhase.off) {
|
||||
state = ConnectionState(phase: state.phase, error: _ref.read(appTextProvider).nodeReconnecting);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 意外内核掉线(弱网)的处理:先自动重连当前节点,额度用尽才断开并报「节点异常」。#18
|
||||
Future<void> _handleUnexpectedOff() async {
|
||||
if (await _tryAutoReconnectCurrent()) return;
|
||||
_offNotice = _ref.read(appTextProvider).nodeUnhealthyError;
|
||||
await _disconnect();
|
||||
}
|
||||
|
||||
/// 选延迟最优、可用(status up)、非当前节点的 code;无则 null。
|
||||
String? _pickAlternativeCode(String excludeCode) {
|
||||
final nodes = (_ref.read(nodesProvider).valueOrNull ?? const [])
|
||||
@@ -397,18 +496,41 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
_elapsed = Timer.periodic(const Duration(seconds: 1), (_) => _refreshElapsed());
|
||||
}
|
||||
|
||||
/// 按墙上时钟把 elapsed 刷成 now - _connectedAt(切后台回来也准)。
|
||||
/// 按墙上时钟把 elapsed 刷成 now - _connectedAt(切后台回来也准);免费版顺带推算
|
||||
/// 倒计时,归零即自动切断。倒计时用墙上时钟,后台/锁屏漏跳也会在回前台补上、准时切。
|
||||
void _refreshElapsed() {
|
||||
final at = _connectedAt;
|
||||
if (mounted && state.phase == VpnPhase.on && at != null) {
|
||||
state = state.copyWith(elapsed: _now().difference(at));
|
||||
if (!mounted || state.phase != VpnPhase.on || at == null) return;
|
||||
final elapsed = _now().difference(at);
|
||||
|
||||
Duration? countdown;
|
||||
final capSec = _freeRemainingSec;
|
||||
if (capSec != null) {
|
||||
final leftSec = capSec - elapsed.inSeconds;
|
||||
if (leftSec <= 0) {
|
||||
unawaited(_onFreeQuotaExhausted());
|
||||
return;
|
||||
}
|
||||
countdown = Duration(seconds: leftSec);
|
||||
}
|
||||
state = ConnectionState(phase: VpnPhase.on, elapsed: elapsed, freeCountdown: countdown);
|
||||
}
|
||||
|
||||
/// 免费额度耗尽:主动切断隧道(不报节点异常),本地置耗尽让按钮灰化,并拉 me 校准。
|
||||
Future<void> _onFreeQuotaExhausted() async {
|
||||
_freeRemainingSec = null;
|
||||
_userDisconnect = true; // 视为主动断开,不触发「节点异常」
|
||||
_offNotice = _ref.read(appTextProvider).quotaExhaustedNotice;
|
||||
_ref.read(quotaProvider.notifier).markExhausted();
|
||||
logLine('Quota', 'free daily minutes used up → auto disconnect');
|
||||
await _disconnect(revokeCredential: true); // 额度耗尽:服务端即刻吊销(F4)
|
||||
}
|
||||
|
||||
void _stopElapsed() {
|
||||
_elapsed?.cancel();
|
||||
_elapsed = null;
|
||||
_connectedAt = null;
|
||||
_freeRemainingSec = null;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// quota_provider.dart — 免费版每日额度状态(Riverpod)
|
||||
//
|
||||
// 设计约定(design/CLAUDE.md §7 / §2):免费额度权威在服务端。
|
||||
// 总额度取自 plans 的 free.daily_minutes,今日剩余取自 me.quota_today_min
|
||||
// (后端已算好 = 上限 − 今日已用)。adUnlocked 为本地会话态(看广告需 ad SDK,
|
||||
// 尚未接入,保留本地乐观置位)。
|
||||
// 设计约定(design/CLAUDE.md §7 / §2):免费额度权威在服务端,且**全账户共享**
|
||||
// (非每设备)。总额度取自 me.quota_cap_min(= 套餐每日上限 + 看广告累加分钟),
|
||||
// 今日剩余取自 me.quota_today_min(后端已算好 = 额度 − 今日已用)。
|
||||
//
|
||||
// 看广告加时(累加式):watchAd() 调 /v1/ads/unlock,服务端校验后 +N 分钟并回传最新
|
||||
// 剩余,客户端随即刷新 me 让额度权威同步。占位广告 SDK 阶段用客户端生成的 ad_token,
|
||||
// 服务端 DevVerifier 放行(nonce 仍防重放)。
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../services/device_identity.dart';
|
||||
import 'account_providers.dart';
|
||||
|
||||
/// 免费额度快照。
|
||||
@@ -13,18 +18,14 @@ class FreeQuotaState {
|
||||
const FreeQuotaState({
|
||||
this.totalMinutes = 10,
|
||||
this.remainingMinutes = 10,
|
||||
this.adUnlocked = false,
|
||||
});
|
||||
|
||||
/// 每日总额度(分钟)。§7:免费版每日 10 分钟。
|
||||
/// 今日总额度(分钟)= 套餐每日上限 + 看广告累加。§7:免费版基础每日 10 分钟。
|
||||
final int totalMinutes;
|
||||
|
||||
/// 今日剩余分钟(展示值,权威以服务端为准)。
|
||||
/// 今日剩余分钟(权威以服务端为准;连接期倒计时由连接状态机本地推算)。
|
||||
final int remainingMinutes;
|
||||
|
||||
/// 今日是否已观看激励视频解锁。
|
||||
final bool adUnlocked;
|
||||
|
||||
/// 进度(0–1),用于进度条宽度。
|
||||
double get progress =>
|
||||
totalMinutes == 0 ? 0 : (remainingMinutes / totalMinutes).clamp(0.0, 1.0);
|
||||
@@ -32,11 +33,12 @@ class FreeQuotaState {
|
||||
/// 是否进入低额度警示(≤3 分钟切 warning 色)。
|
||||
bool get isLow => remainingMinutes <= 3;
|
||||
|
||||
FreeQuotaState copyWith({int? totalMinutes, int? remainingMinutes, bool? adUnlocked}) =>
|
||||
FreeQuotaState(
|
||||
/// 今日额度是否已耗尽(剩余 0):连接按钮据此灰化,点击弹广告/升级。
|
||||
bool get isExhausted => remainingMinutes <= 0;
|
||||
|
||||
FreeQuotaState copyWith({int? totalMinutes, int? remainingMinutes}) => FreeQuotaState(
|
||||
totalMinutes: totalMinutes ?? this.totalMinutes,
|
||||
remainingMinutes: remainingMinutes ?? this.remainingMinutes,
|
||||
adUnlocked: adUnlocked ?? this.adUnlocked,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,18 +55,41 @@ class QuotaController extends StateNotifier<FreeQuotaState> {
|
||||
void _sync() {
|
||||
final me = _ref.read(meProvider).valueOrNull;
|
||||
final plans = _ref.read(plansProvider).valueOrNull;
|
||||
var total = 10; // §7 默认免费 10 分钟,plans 就绪后以其为准
|
||||
var base = 10; // §7 默认免费基础 10 分钟,plans 就绪后以其为准
|
||||
if (plans != null) {
|
||||
for (final p in plans) {
|
||||
if (p.code == 'free' && p.dailyMinutes != null) total = p.dailyMinutes!;
|
||||
if (p.code == 'free' && p.dailyMinutes != null) base = p.dailyMinutes!;
|
||||
}
|
||||
}
|
||||
// 总额度优先取服务端 quota_cap_min(含看广告加时);缺省回退基础额度。
|
||||
final total = me?.quotaCapMin ?? base;
|
||||
final remaining = (me?.quotaTodayMin ?? total).clamp(0, total);
|
||||
state = state.copyWith(totalMinutes: total, remainingMinutes: remaining);
|
||||
state = FreeQuotaState(totalMinutes: total, remainingMinutes: remaining);
|
||||
}
|
||||
|
||||
/// 观看激励视频后解锁今日使用(本地乐观;真实 ad 校验待 ad SDK 接入)。
|
||||
void watchAd() => state = state.copyWith(adUnlocked: true);
|
||||
/// 连接期倒计时归零 → 本地立即置耗尽(按钮随即灰化);登录态下再静默拉 me 让服务端权威同步。
|
||||
void markExhausted() {
|
||||
state = state.copyWith(remainingMinutes: 0);
|
||||
_ref.read(meProvider.notifier).silentRefresh();
|
||||
}
|
||||
|
||||
/// 看广告加时:调 /v1/ads/unlock 累加分钟,成功后刷新 me 拿最新额度。
|
||||
/// 返回本次加时分钟(null = 失败)。占位阶段用客户端生成的 ad_token。
|
||||
Future<int?> watchAd() async {
|
||||
try {
|
||||
final deviceId = await _ref.read(deviceIdentityProvider).deviceId();
|
||||
final res = await _ref.read(accountApiProvider).adUnlock(
|
||||
deviceId: deviceId,
|
||||
adToken: const Uuid().v4(), // 占位 ad_token(DevVerifier 放行)
|
||||
);
|
||||
// 乐观置位剩余,再静默拉 me 校准(账户共享,以服务端为准;不闪整页 spinner)。
|
||||
state = state.copyWith(remainingMinutes: res.minutesRemaining);
|
||||
await _ref.read(meProvider.notifier).silentRefresh();
|
||||
return res.grantedMinutes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final quotaProvider = StateNotifierProvider<QuotaController, FreeQuotaState>(
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// update_provider.dart — 应用更新检查(启动自动检查 + 定时轮询)。
|
||||
//
|
||||
// 对照 jiu client/lib/providers/update_provider.dart:用 AsyncNotifier 的惰性
|
||||
// build() 做「启动后延迟首查 + 每小时轮询」;shell 里 watch 一次即拉起整条流程。
|
||||
// `_dismissed` 是本次进程内存标志(用户点「稍后」置真,防反复弹),每轮轮询重置。
|
||||
// 强制更新(force_update)不受 dismiss 影响,由 UI 走不可关闭弹窗。
|
||||
//
|
||||
// 拿到更新后,下载安装走 core/update/app_updater.dart(App 内下载,不再开浏览器)。
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../services/api_config.dart';
|
||||
|
||||
/// 启动后首次检查的延迟(避开登录/首屏竞争)。
|
||||
const _kInitialDelay = Duration(seconds: 3);
|
||||
|
||||
/// 轮询间隔。
|
||||
const _kPollInterval = Duration(hours: 1);
|
||||
|
||||
/// 单次检查网络超时。
|
||||
const _kCheckTimeout = Duration(seconds: 8);
|
||||
|
||||
/// 一次更新检查的结果。
|
||||
class AppUpdateInfo {
|
||||
const AppUpdateInfo({
|
||||
required this.latestVersion,
|
||||
required this.buildNumber,
|
||||
required this.forceUpdate,
|
||||
required this.releaseNotes,
|
||||
required this.downloadUrls,
|
||||
required this.hasUpdate,
|
||||
});
|
||||
|
||||
final String latestVersion;
|
||||
final int buildNumber;
|
||||
final bool forceUpdate;
|
||||
final String releaseNotes;
|
||||
final Map<String, String> downloadUrls;
|
||||
final bool hasUpdate;
|
||||
}
|
||||
|
||||
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
|
||||
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
||||
Timer? _timer;
|
||||
Timer? _initialTimer;
|
||||
|
||||
@override
|
||||
Future<AppUpdateInfo?> build() async {
|
||||
// 两个 timer 都在 onDispose 取消。初始延迟用可取消的 Timer(而非
|
||||
// Future.delayed:其内部 timer 无法取消,provider 在延迟期间被 dispose
|
||||
// 时会悬挂 → widget 测试报 pending timer、生产留资源)。
|
||||
ref.onDispose(() {
|
||||
_initialTimer?.cancel();
|
||||
_timer?.cancel();
|
||||
});
|
||||
final ready = Completer<void>();
|
||||
_initialTimer = Timer(_kInitialDelay, ready.complete);
|
||||
await ready.future; // 若延迟期间被 dispose,_initialTimer 取消 → 永不 complete,build 中止
|
||||
final first = await _check();
|
||||
_timer = Timer.periodic(_kPollInterval, (_) async {
|
||||
state = AsyncValue.data(await _check());
|
||||
});
|
||||
return first;
|
||||
}
|
||||
|
||||
/// 设置页「检查更新」手动触发:立即查一次并回结果。
|
||||
Future<AppUpdateInfo?> forceCheck() async {
|
||||
final info = await _check();
|
||||
state = AsyncValue.data(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
/// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。
|
||||
Future<AppUpdateInfo?> _check() async {
|
||||
try {
|
||||
final resp = await http
|
||||
.get(Uri.parse('$kApiBaseUrl/version'))
|
||||
.timeout(_kCheckTimeout);
|
||||
if (resp.statusCode != 200) return null;
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
|
||||
final latestVersion = data['version'] as String? ?? '0.0.0';
|
||||
final buildNumber = (data['build_number'] as num?)?.toInt() ?? 0;
|
||||
final forceUpdate = data['force_update'] as bool? ?? false;
|
||||
final releaseNotes = data['release_notes'] as String? ?? '';
|
||||
final rawUrls = data['download_urls'] as Map<String, dynamic>? ?? const {};
|
||||
final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
|
||||
|
||||
final pkg = await PackageInfo.fromPlatform();
|
||||
final hasUpdate = _isNewer(latestVersion, pkg.version);
|
||||
|
||||
return AppUpdateInfo(
|
||||
latestVersion: latestVersion,
|
||||
buildNumber: buildNumber,
|
||||
forceUpdate: forceUpdate,
|
||||
releaseNotes: releaseNotes,
|
||||
downloadUrls: downloadUrls,
|
||||
hasUpdate: hasUpdate,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 语义化版本比较:latest > current → true。容忍 1.1.4-dev / 1.1.4+7 等后缀。
|
||||
bool _isNewer(String latest, String current) {
|
||||
final l = _parse(latest);
|
||||
final c = _parse(current);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (l[i] > c[i]) return true;
|
||||
if (l[i] < c[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<int> _parse(String v) {
|
||||
final parts = v.split('.').map((s) {
|
||||
final m = RegExp(r'^\d+').firstMatch(s);
|
||||
return m == null ? 0 : int.parse(m.group(0)!);
|
||||
}).toList();
|
||||
while (parts.length < 3) {
|
||||
parts.add(0);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
|
||||
final updateProvider =
|
||||
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
|
||||
|
||||
/// 用户在更新 banner 点「稍后再说」时忽略的版本号。忽略后 banner 隐藏;出现号
|
||||
/// 不同的更新版本会重新显示;设置页手动「检查更新」会清空以重新提示。响应式,
|
||||
/// 供 shell 顶部 banner 的显隐判断。
|
||||
final dismissedUpdateVersionProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// 按当前平台从服务端 download_urls 里取对应下载直链。
|
||||
String? platformDownloadUrl(Map<String, String> downloadUrls) {
|
||||
if (Platform.isMacOS) return downloadUrls['macos'];
|
||||
if (Platform.isWindows) return downloadUrls['windows'];
|
||||
if (Platform.isIOS) return downloadUrls['ios'];
|
||||
if (Platform.isAndroid) return downloadUrls['android'];
|
||||
return downloadUrls['web'];
|
||||
}
|
||||
Reference in New Issue
Block a user