import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import '../api/oa_client.dart'; import '../app_runtime.dart'; import '../theme.dart'; class AppRelease { AppRelease({ required this.version, required this.build, required this.url, this.changelog = '', this.force = false, this.windowsUrl = '', this.linuxUrl = '', this.linuxDebUrl = '', }); final String version; final int build; final String url; final String changelog; final bool force; final String windowsUrl; final String linuxUrl; final String linuxDebUrl; bool get newer => build > AppRuntime.build; String get desktopUrl { if (Platform.isWindows) return windowsUrl; if (Platform.isLinux) return linuxDebUrl.isNotEmpty ? linuxDebUrl : linuxUrl; return ''; } factory AppRelease.fromJson(Map j) { return AppRelease( version: '${j['version'] ?? ''}', build: (j['build'] as num?)?.toInt() ?? 0, url: '${j['url'] ?? j['apkUrl'] ?? ''}', changelog: '${j['changelog'] ?? ''}', force: j['force'] == true, windowsUrl: '${j['windowsUrl'] ?? ''}', linuxUrl: '${j['linuxUrl'] ?? ''}', linuxDebUrl: '${j['linuxDebUrl'] ?? ''}', ); } } class OtaUpdater { OtaUpdater(this.api); final OaClient api; static const _ch = MethodChannel('com.fysxkj.oa/ota'); Future fetch() async { try { final data = await api.get('/system/app-release'); if (data is Map) return AppRelease.fromJson(Map.from(data)); } catch (_) {} return null; } Future installApk(String path) async { await _ch.invokeMethod('installApk', {'path': path}); } Future download(String url, void Function(double p) onProgress, {String filename = 'fengying-oa-update'}) async { final dir = await getTemporaryDirectory(); final ext = url.contains('.exe') ? '.exe' : url.contains('.deb') ? '.deb' : url.contains('.tar.gz') ? '.tar.gz' : url.contains('.apk') ? '.apk' : ''; final file = File('${dir.path}/$filename$ext'); final client = HttpClient(); try { final req = await client.getUrl(Uri.parse(url)); final res = await req.close(); if (res.statusCode >= 400) { throw ApiException('下载失败 ${res.statusCode}'); } final total = res.contentLength; var rec = 0; final sink = file.openWrite(); await for (final chunk in res) { rec += chunk.length; sink.add(chunk); if (total > 0) onProgress(rec / total); } await sink.close(); return file; } finally { client.close(force: true); } } Future prompt(BuildContext context, AppRelease rel, {bool manual = false}) async { if (!rel.newer && !manual) return; if (!rel.newer && manual) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已是最新版本'))); } return; } if (!context.mounted) return; final go = await showDialog( context: context, barrierDismissible: !rel.force, builder: (ctx) => AlertDialog( title: Text('发现新版本 ${rel.version}'), content: Text(rel.changelog.isEmpty ? '请升级后继续使用。' : rel.changelog), actions: [ if (!rel.force) TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('稍后')), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('立即升级')), ], ), ); if (go == true && context.mounted) await start(context, rel); } Future start(BuildContext context, AppRelease rel) async { final desktop = rel.desktopUrl; if (Platform.isWindows || Platform.isLinux) { if (desktop.isEmpty) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无桌面端升级包地址'))); } return; } final progress = ValueNotifier(0); if (!context.mounted) return; showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: const Text('正在下载更新'), content: OtaProgress(progress: progress), ), ); try { final file = await download(desktop, (p) => progress.value = p, filename: 'fengying-oa-setup'); if (context.mounted) Navigator.of(context, rootNavigator: true).pop(); if (Platform.isWindows) { await Process.start(file.path, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'], mode: ProcessStartMode.detached); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('安装程序已启动,请按提示完成升级后重启应用'))); } } else if (file.path.endsWith('.deb')) { final r = await Process.run('pkexec', ['env', 'DEBIAN_FRONTEND=noninteractive', 'dpkg', '-i', file.path]); if (context.mounted) { if (r.exitCode == 0) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('升级完成,请重启应用'))); } else { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('安装失败:${r.stderr}'))); } } } else { await Process.run('xdg-open', [file.parent.path]); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已下载到 ${file.path},请解压覆盖后重启应用'))); } } } catch (e) { if (context.mounted) { Navigator.of(context, rootNavigator: true).pop(); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e'))); } } finally { progress.dispose(); } return; } if (!Platform.isAndroid) return; if (rel.url.isEmpty) return; final progress = ValueNotifier(0); if (!context.mounted) return; showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: const Text('正在下载更新'), content: OtaProgress(progress: progress), ), ); try { final file = await download(rel.url, (p) => progress.value = p); if (context.mounted) Navigator.of(context, rootNavigator: true).pop(); await installApk(file.path); } catch (e) { if (context.mounted) { Navigator.of(context, rootNavigator: true).pop(); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e'))); } } finally { progress.dispose(); } } } class OtaProgress extends StatefulWidget { const OtaProgress({super.key, required this.progress}); final ValueListenable progress; @override State createState() => _OtaProgressState(); } class _OtaProgressState extends State { @override void initState() { super.initState(); widget.progress.addListener(_on); } void _on() => setState(() {}); @override void dispose() { widget.progress.removeListener(_on); super.dispose(); } @override Widget build(BuildContext context) { final p = widget.progress.value; return Column( mainAxisSize: MainAxisSize.min, children: [ LinearProgressIndicator(value: p <= 0 ? null : p, color: kBind), const SizedBox(height: 12), Text('${(p * 100).clamp(0, 100).toStringAsFixed(0)}%'), ], ); } } String prettyJson(Object v) { try { return const JsonEncoder.withIndent(' ').convert(v); } catch (_) { return '$v'; } }