Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 252779c607 | |||
| b702957788 |
@@ -4,8 +4,11 @@
|
||||
// - Android:http 流式下 APK 到临时目录 → open_filex 拉起系统安装器
|
||||
// (open_filex 内部封装 FileProvider;manifest 需 REQUEST_INSTALL_PACKAGES)
|
||||
// - Windows:下 exe → Process.start(detached) 起安装 → exit(0) 让其覆盖
|
||||
// - macOS :下 zip → open 解压 + 访达显示,提示手动拖入「应用程序」
|
||||
// (本 app 带系统扩展,不自动替换 bundle,避免破坏 sysext)
|
||||
// - macOS :下 zip → ditto 解压 → 分离 helper 等主进程退出后原子换 /Applications
|
||||
// 里的 .app + open 重启(未开沙箱,admin 用户可写 /Applications → 全程无弹窗;
|
||||
// .app 属主非本人则 osascript 弹一次系统密码框提权;不可写/异常则回退访达定位手动拖)。
|
||||
// 运行中的 sysext 从 /Library/SystemExtensions 跑,换 .app 不影响它——
|
||||
// 与手动拖入同路径,新版启动照常走 OSSystemExtensionRequest。
|
||||
// - iOS :不能 App 内装 → 外链(TestFlight/App Store)
|
||||
// 进度用本地 ValueNotifier 驱动一个不可关闭的进度对话框;失败降级浏览器下载。
|
||||
import 'dart:async';
|
||||
@@ -124,12 +127,134 @@ Future<void> _install(String path) async {
|
||||
exit(0); // 退出让安装器覆盖
|
||||
}
|
||||
if (Platform.isMacOS) {
|
||||
// 不自动替换 bundle(带系统扩展,风险高):解压 + 访达高亮,提示手动拖入。
|
||||
await Process.run('open', <String>[path]); // Archive Utility 解压
|
||||
await Process.run('open', <String>['-R', path]); // 访达定位
|
||||
await _macInstall(path); // 成功则 exit(0) 不返回;回退则内部走访达定位后返回
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS 自动安装:解压 → 分离 helper 换 /Applications 的 .app + 重启。
|
||||
/// 成功路径 exit(0)(不返回);不满足条件/失败则回退访达定位(现状半自动流程)后正常返回,
|
||||
/// 由调用方提示手动拖入。
|
||||
Future<void> _macInstall(String zipPath) async {
|
||||
final exe = Platform.resolvedExecutable; // …/pangolin_vpn.app/Contents/MacOS/pangolin_vpn
|
||||
const marker = '/Contents/MacOS/';
|
||||
final mi = exe.indexOf(marker);
|
||||
final appPath = mi > 0 ? exe.substring(0, mi) : ''; // …/pangolin_vpn.app
|
||||
// 仅当能定位到 .app bundle 才尝试自动装;异常布局(如无 bundle 运行)回退。
|
||||
if (appPath.isEmpty || !appPath.toLowerCase().endsWith('.app')) {
|
||||
await _macReveal(zipPath);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final tmp = await getTemporaryDirectory();
|
||||
final staging = '${tmp.path}/pangolin-update-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final extractDir = '$staging/new';
|
||||
await Directory(extractDir).create(recursive: true);
|
||||
|
||||
// ditto 解 PKZip(zip 由 --keepParent 打,顶层含 pangolin_vpn.app);失败即回退。
|
||||
final ex = await Process.run('/usr/bin/ditto', <String>['-x', '-k', zipPath, extractDir]);
|
||||
if (ex.exitCode != 0) {
|
||||
await _macReveal(zipPath);
|
||||
return;
|
||||
}
|
||||
final newApp = await _findDotApp(extractDir);
|
||||
if (newApp == null) {
|
||||
await _macReveal(zipPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 写 helper、分离启动、退出让其换装重启。参数:PID/现.app/新.app/暂存/zip。
|
||||
final helper = '$staging/pangolin-update.sh';
|
||||
await File(helper).writeAsString(_macUpdateHelper);
|
||||
await Process.run('/bin/chmod', <String>['+x', helper]);
|
||||
await Process.start(
|
||||
'/bin/sh',
|
||||
<String>[helper, '$pid', appPath, newApp, staging, zipPath],
|
||||
mode: ProcessStartMode.detached,
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
exit(0); // helper 等本进程退出后原子换 bundle + open 重启
|
||||
} catch (_) {
|
||||
await _macReveal(zipPath); // 任何意外 → 保底手动流程
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退:Archive Utility 解压 zip + 访达高亮(与旧版一致的半自动流程)。
|
||||
Future<void> _macReveal(String zipPath) async {
|
||||
await Process.run('open', <String>[zipPath]); // 解压
|
||||
await Process.run('open', <String>['-R', zipPath]); // 访达定位
|
||||
}
|
||||
|
||||
/// 在解压目录里找顶层 .app(ditto --keepParent 打的 zip 解出 pangolin_vpn.app)。
|
||||
Future<String?> _findDotApp(String dir) async {
|
||||
final d = Directory(dir);
|
||||
if (!await d.exists()) return null;
|
||||
await for (final e in d.list(followLinks: false)) {
|
||||
if (e is Directory && e.path.toLowerCase().endsWith('.app')) return e.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// macOS 自更新 helper 脚本(分离进程跑):等主 app 退出 → 原子换 bundle(失败回滚,
|
||||
/// 属主非本人则 osascript 提权)→ 清 quarantine → open 重启。
|
||||
const String _macUpdateHelper = r'''#!/bin/sh
|
||||
# Pangolin macOS 自更新 helper —— 由 app_updater.dart 生成、分离进程启动。
|
||||
# 参数:PID(主app进程) APP(现.app) NEW_APP(解压出的新.app) STAGING(暂存目录) ZIP(下载的zip,清理用)
|
||||
PID="$1"; APP="$2"; NEW_APP="$3"; STAGING="$4"; ZIP="$5"
|
||||
exec >>"$STAGING/update.log" 2>&1
|
||||
echo "[helper] start pid=$PID app=$APP new=$NEW_APP"
|
||||
|
||||
# 1. 等主 app 完全退出(最多 ~30s 兜底)
|
||||
i=0
|
||||
while kill -0 "$PID" 2>/dev/null; do
|
||||
sleep 0.3; i=$((i+1))
|
||||
[ "$i" -gt 100 ] && { echo "[helper] wait timeout"; break; }
|
||||
done
|
||||
sleep 0.5
|
||||
|
||||
BACKUP="${APP}.pangolin-old"
|
||||
|
||||
# 2. 静默换:mv 旧→备份 → mv 新→原位;任何一步失败自动回滚(admin 用户对 /Applications 可写 → 无弹窗)
|
||||
swap_plain() {
|
||||
/bin/rm -rf "$BACKUP" 2>/dev/null
|
||||
/bin/mv "$APP" "$BACKUP" 2>/dev/null || return 1
|
||||
/bin/mv "$NEW_APP" "$APP" 2>/dev/null || { /bin/mv "$BACKUP" "$APP" 2>/dev/null; return 1; }
|
||||
/bin/rm -rf "$BACKUP" 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
if swap_plain; then
|
||||
echo "[helper] swap_plain ok"
|
||||
else
|
||||
echo "[helper] swap_plain failed -> osascript 提权"
|
||||
# 3. 提权兜底:把带路径的换装命令写进 root 脚本,osascript 弹一次系统密码框以 root 跑
|
||||
ROOT_SH="$STAGING/swap-root.sh"
|
||||
{
|
||||
echo '#!/bin/sh'
|
||||
echo "/bin/rm -rf \"$BACKUP\""
|
||||
echo "/bin/mv \"$APP\" \"$BACKUP\" || exit 1"
|
||||
echo "/bin/mv \"$NEW_APP\" \"$APP\" || { /bin/mv \"$BACKUP\" \"$APP\"; exit 1; }"
|
||||
echo "/bin/rm -rf \"$BACKUP\""
|
||||
} > "$ROOT_SH"
|
||||
/bin/chmod +x "$ROOT_SH"
|
||||
if ! /usr/bin/osascript -e "do shell script \"/bin/sh '$ROOT_SH'\" with administrator privileges"; then
|
||||
echo "[helper] osascript failed/canceled -> 回退访达定位"
|
||||
/usr/bin/open -R "$NEW_APP"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. 清 quarantine(已公证+staple,防御性)+ open 重启新版
|
||||
/usr/bin/xattr -dr com.apple.quarantine "$APP" 2>/dev/null
|
||||
/usr/bin/open "$APP"
|
||||
echo "[helper] relaunched"
|
||||
|
||||
# 5. 清理
|
||||
/bin/rm -f "$ZIP" 2>/dev/null
|
||||
/bin/rm -rf "$STAGING/new" 2>/dev/null
|
||||
exit 0
|
||||
''';
|
||||
|
||||
Future<void> _openInBrowser(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
|
||||
@@ -7,7 +7,8 @@ enum PayChannel { usdt, cny }
|
||||
|
||||
extension PayChannelX on PayChannel {
|
||||
String get currency => this == PayChannel.usdt ? 'USDT' : 'CNY';
|
||||
String get method => this == PayChannel.usdt ? 'crypto' : 'alipay';
|
||||
// CNY 渠道生产走哪吒(nezha)聚合(底层支付宝/微信),非直连 alipay 商户。
|
||||
String get method => this == PayChannel.usdt ? 'crypto' : 'nezha';
|
||||
}
|
||||
|
||||
class PayCatalogItem {
|
||||
|
||||
@@ -244,7 +244,7 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
}
|
||||
|
||||
Future<void> _pickAndSwitch(BuildContext context, WidgetRef ref, PaymentFlowState s) async {
|
||||
final other = s.method == 'crypto' ? 'alipay' : 'crypto';
|
||||
final other = s.method == 'crypto' ? 'nezha' : 'crypto';
|
||||
await ref.read(paymentFlowProvider.notifier).switchMethod(other);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../models/payment.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/payment_provider.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import '../widgets/pangolin_toast.dart';
|
||||
import '../widgets/plan_card.dart';
|
||||
import '../widgets/seg_switch.dart';
|
||||
|
||||
@@ -50,8 +51,17 @@ class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
|
||||
Future<void> _buy(PayCatalogItem item) async {
|
||||
await ref.read(paymentFlowProvider.notifier).start(item, _channel.method);
|
||||
if (!mounted) return;
|
||||
if (ref.read(paymentFlowProvider).phase == PaymentPhase.awaitingPayment) {
|
||||
final st = ref.read(paymentFlowProvider);
|
||||
if (st.phase == PaymentPhase.awaitingPayment) {
|
||||
widget.onOrderCreated?.call();
|
||||
} else if (st.phase == PaymentPhase.failed) {
|
||||
// 下单失败给可见 toast,别让用户以为「点了没反应」。
|
||||
showPangolinToast(
|
||||
context,
|
||||
t.lang == AppLang.zh
|
||||
? (st.errorZh ?? '下单失败,请稍后重试')
|
||||
: (st.errorEn ?? 'Failed to create order, please retry'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class PaymentFlowState {
|
||||
|
||||
final PaymentPhase phase;
|
||||
final PayCatalogItem? item;
|
||||
final String method; // alipay | crypto
|
||||
final String method; // nezha | crypto
|
||||
final PayOrder? order;
|
||||
final PayOrderStatus? status;
|
||||
final String? errorZh;
|
||||
@@ -82,8 +82,10 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
|
||||
return Platform.isAndroid || Platform.isIOS;
|
||||
}
|
||||
|
||||
// 法币渠道(nezha)带 is_mobile 提示;crypto 不需要。经 pangolin 代理白名单
|
||||
// (is_mobile/render)透传到 pay。
|
||||
Map<String, String> _metadata(String method) =>
|
||||
method == 'alipay' ? {'is_mobile': _isMobile ? '1' : '0'} : const {};
|
||||
method == 'nezha' ? {'is_mobile': _isMobile ? '1' : '0'} : const {};
|
||||
|
||||
Future<void> start(PayCatalogItem item, String method) async {
|
||||
_stopPolling();
|
||||
@@ -98,6 +100,15 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
|
||||
} on AuthApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn);
|
||||
} catch (_) {
|
||||
// 网络/解析等非 Auth 异常也要落 failed —— 否则状态机卡在 creating,
|
||||
// 购买页看着「点了没反应」。
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(
|
||||
phase: PaymentPhase.failed,
|
||||
errorZh: '下单失败,请检查网络后重试',
|
||||
errorEn: 'Failed to create order, please check your network and retry',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,14 @@ void main() {
|
||||
expect(items.first.priceLabel(PayChannel.cny), '¥29.99');
|
||||
});
|
||||
|
||||
test('PayChannel 渠道→method 映射:usdt→crypto / cny→nezha(生产渠道码)', () {
|
||||
// 回归钉:CNY 生产走哪吒(nezha)聚合,不是直连 alipay——发错 method 会被 pay 回 400。
|
||||
expect(PayChannel.usdt.method, 'crypto');
|
||||
expect(PayChannel.cny.method, 'nezha');
|
||||
expect(PayChannel.usdt.currency, 'USDT');
|
||||
expect(PayChannel.cny.currency, 'CNY');
|
||||
});
|
||||
|
||||
test('createOrder 只传 sku+method+metadata,解析 session', () async {
|
||||
final api = PaymentApi(_client(MockClient((req) async {
|
||||
expect(req.url.path, '/v1/pay/orders');
|
||||
|
||||
Reference in New Issue
Block a user