Files
Public/apps/native/lib/ota/updater.dart
T
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

252 lines
7.9 KiB
Dart

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<String, dynamic> 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<AppRelease?> fetch() async {
try {
final data = await api.get('/system/app-release');
if (data is Map) return AppRelease.fromJson(Map<String, dynamic>.from(data));
} catch (_) {}
return null;
}
Future<void> installApk(String path) async {
await _ch.invokeMethod('installApk', {'path': path});
}
Future<File> 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<void> 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<bool>(
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<void> 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<double>(0);
if (!context.mounted) return;
showDialog<void>(
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<double>(0);
if (!context.mounted) return;
showDialog<void>(
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<double> progress;
@override
State<OtaProgress> createState() => _OtaProgressState();
}
class _OtaProgressState extends State<OtaProgress> {
@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';
}
}