76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
252 lines
7.9 KiB
Dart
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';
|
|
}
|
|
}
|