feat: add app update checks and standalone release publishing panel
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart'
|
||||
show MethodChannel, PlatformException, MissingPluginException;
|
||||
|
||||
import '../app/app_version.dart';
|
||||
|
||||
class UpdateCheckException implements Exception {
|
||||
const UpdateCheckException(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
Uri? updateDownloadUri(String value) {
|
||||
if (value.length > 2048 || RegExp(r'[\s\x00-\x1f\x7f]').hasMatch(value)) {
|
||||
return null;
|
||||
}
|
||||
final uri = Uri.tryParse(value);
|
||||
return uri != null &&
|
||||
uri.scheme == 'https' &&
|
||||
uri.host.isNotEmpty &&
|
||||
uri.userInfo.isEmpty
|
||||
? uri
|
||||
: null;
|
||||
}
|
||||
|
||||
class AppRelease {
|
||||
const AppRelease({
|
||||
required this.version,
|
||||
required this.buildNumber,
|
||||
required this.notes,
|
||||
required this.downloadUri,
|
||||
});
|
||||
final String version, notes;
|
||||
final int buildNumber;
|
||||
final Uri downloadUri;
|
||||
}
|
||||
|
||||
class UpdateHttpResponse {
|
||||
const UpdateHttpResponse(this.status, this.body);
|
||||
final int status;
|
||||
final List<int> body;
|
||||
}
|
||||
|
||||
abstract interface class UpdateTransport {
|
||||
Future<UpdateHttpResponse> get(Uri endpoint);
|
||||
}
|
||||
|
||||
class IoUpdateTransport implements UpdateTransport {
|
||||
const IoUpdateTransport();
|
||||
@override
|
||||
Future<UpdateHttpResponse> get(Uri endpoint) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
|
||||
Future<UpdateHttpResponse> fetch() async {
|
||||
final request = await client.getUrl(endpoint);
|
||||
request.followRedirects = false;
|
||||
request.headers.set('Accept', 'application/json');
|
||||
request.headers.set('Cache-Control', 'no-cache');
|
||||
final response = await request.close();
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in response) {
|
||||
if (bytes.length + chunk.length > AppUpdateService.maxBytes) {
|
||||
throw const UpdateCheckException('更新信息过大,请稍后重试');
|
||||
}
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
return UpdateHttpResponse(response.statusCode, bytes);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetch().timeout(const Duration(seconds: 12));
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppUpdateService {
|
||||
AppUpdateService({
|
||||
this.transport = const IoUpdateTransport(),
|
||||
this.currentBuild = appBuildNumber,
|
||||
this.endpoint = const String.fromEnvironment(
|
||||
'UPDATE_ENDPOINT',
|
||||
defaultValue: 'https://app.glowtps.cn/v1/updates/android',
|
||||
),
|
||||
});
|
||||
static const maxBytes = 64 * 1024;
|
||||
final UpdateTransport transport;
|
||||
final int currentBuild;
|
||||
final String endpoint;
|
||||
Future<AppRelease?>? _pending;
|
||||
|
||||
Future<AppRelease?> check() {
|
||||
if (_pending != null) return _pending!;
|
||||
final future = _check();
|
||||
_pending = future;
|
||||
// Attach error handling here too, so cleanup never creates an unhandled
|
||||
// future when startup intentionally suppresses an unavailable server.
|
||||
unawaited(
|
||||
future.then<void>(
|
||||
(_) {
|
||||
_pending = null;
|
||||
},
|
||||
onError: (Object _, StackTrace _) {
|
||||
_pending = null;
|
||||
},
|
||||
),
|
||||
);
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<AppRelease?> _check() async {
|
||||
try {
|
||||
final uri = updateDownloadUri(endpoint);
|
||||
if (uri == null || uri.fragment.isNotEmpty) {
|
||||
throw const UpdateCheckException('更新服务地址无效');
|
||||
}
|
||||
final response = await transport
|
||||
.get(uri)
|
||||
.timeout(const Duration(seconds: 13));
|
||||
if (response.status != 200 || response.body.length > maxBytes) {
|
||||
throw const UpdateCheckException('暂时无法获取更新,请稍后重试');
|
||||
}
|
||||
final data = jsonDecode(utf8.decode(response.body));
|
||||
if (data is! Map ||
|
||||
data['schema_version'] != 1 ||
|
||||
data['application_id'] != 'com.tpschedule.app' ||
|
||||
!data.containsKey('latest')) {
|
||||
throw const FormatException();
|
||||
}
|
||||
final latest = data['latest'];
|
||||
if (latest == null) return null;
|
||||
if (latest is! Map) throw const FormatException();
|
||||
final version = latest['version'],
|
||||
build = latest['build_number'],
|
||||
notes = latest['notes'],
|
||||
url = latest['download_url'];
|
||||
if (version is! String ||
|
||||
!RegExp(r'^\d{1,4}\.\d{1,4}\.\d{1,4}$').hasMatch(version) ||
|
||||
build is! int ||
|
||||
build < 1 ||
|
||||
build > 2100000000 ||
|
||||
notes is! String ||
|
||||
notes.trim().isEmpty ||
|
||||
notes.runes.length > 8000 ||
|
||||
url is! String) {
|
||||
throw const FormatException();
|
||||
}
|
||||
final download = updateDownloadUri(url);
|
||||
if (download == null) throw const FormatException();
|
||||
if (build <= currentBuild) return null;
|
||||
return AppRelease(
|
||||
version: version,
|
||||
buildNumber: build,
|
||||
notes: notes.trim(),
|
||||
downloadUri: download,
|
||||
);
|
||||
} on UpdateCheckException {
|
||||
rethrow;
|
||||
} on FormatException {
|
||||
throw const UpdateCheckException('更新信息格式异常,请稍后重试');
|
||||
} catch (_) {
|
||||
throw const UpdateCheckException('无法连接更新服务,请检查网络后重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typedef UpdateDownloadOpener = Future<bool> Function(Uri uri);
|
||||
|
||||
Future<bool> openUpdateInBrowser(Uri uri) async {
|
||||
if (updateDownloadUri(uri.toString()) == null) return false;
|
||||
try {
|
||||
return await const MethodChannel('com.tpschedule.app/updates')
|
||||
.invokeMethod<bool>('openDownload', {'url': uri.toString()}) ??
|
||||
false;
|
||||
} on PlatformException {
|
||||
return false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user