mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 15:49:49 +00:00
266 lines
9.0 KiB
Dart
266 lines
9.0 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:install_plugin/install_plugin.dart';
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:pub_semver/pub_semver.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../api/api.dart' as api;
|
|
import '../api/gopeed_site_api.dart';
|
|
import '../api/model/downloader_config.dart';
|
|
import 'arch/arch.dart';
|
|
import 'github_mirror.dart';
|
|
import 'log_util.dart';
|
|
import 'package_info.dart';
|
|
import 'util.dart';
|
|
|
|
enum UpdateChannel {
|
|
windowsInstaller,
|
|
windowsPortable,
|
|
macosDmg,
|
|
linuxFlathub,
|
|
linuxSnap,
|
|
linuxDeb,
|
|
linuxAppImage,
|
|
linuxRpm,
|
|
androidApk,
|
|
iosIpa,
|
|
docker,
|
|
}
|
|
|
|
const _channelEnv = String.fromEnvironment('UPDATE_CHANNEL');
|
|
|
|
UpdateChannel? get updateChannel {
|
|
for (final channel in UpdateChannel.values) {
|
|
if (channel.name == _channelEnv) return channel;
|
|
}
|
|
if (Util.isWindows()) return UpdateChannel.windowsPortable;
|
|
if (Util.isMacos()) return UpdateChannel.macosDmg;
|
|
if (Util.isAndroid()) return UpdateChannel.androidApk;
|
|
if (Util.isIOS()) return UpdateChannel.iosIpa;
|
|
return null;
|
|
}
|
|
|
|
String get _updaterBinaryName => 'updater${Util.isWindows() ? '.exe' : ''}';
|
|
|
|
const _releasePageSize = 10;
|
|
const _githubReleasesUrl = 'https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=$_releasePageSize';
|
|
|
|
class VersionInfo {
|
|
const VersionInfo({required this.version, required this.changeLog, required this.releaseUrl});
|
|
|
|
final String version;
|
|
final String changeLog;
|
|
final String releaseUrl;
|
|
}
|
|
|
|
/// Installs the small native updater bundled with desktop releases.
|
|
///
|
|
/// Development builds do not necessarily contain this asset. Callers should
|
|
/// treat a missing asset as recoverable; [updateApp] falls back to the release
|
|
/// page when the helper is unavailable.
|
|
Future<void> installUpdater() async {
|
|
if (!Util.isDesktop()) return;
|
|
await Util.installAsset(
|
|
'assets/exec/$_updaterBinaryName',
|
|
await Util.homePathJoin(_updaterBinaryName),
|
|
executable: true,
|
|
);
|
|
}
|
|
|
|
Future<VersionInfo?> checkUpdate() async {
|
|
List<dynamic> releases;
|
|
try {
|
|
final releaseDataStr = (await api.proxyRequest(_githubReleasesUrl)).data;
|
|
if (releaseDataStr == null || releaseDataStr.isEmpty) {
|
|
throw const FormatException('Empty GitHub releases response');
|
|
}
|
|
final releaseData = jsonDecode(releaseDataStr);
|
|
if (releaseData is! List<dynamic>) {
|
|
throw const FormatException('Invalid GitHub releases response');
|
|
}
|
|
releases = releaseData;
|
|
} catch (_) {
|
|
releases = await GopeedSiteApi.instance.getReleases(perPage: _releasePageSize);
|
|
}
|
|
return selectUpdateRelease(releases, appVersion);
|
|
}
|
|
|
|
bool isNewerVersion(String latest, String current) {
|
|
try {
|
|
return _parseVersion(latest).compareTo(_parseVersion(current)) > 0;
|
|
} on FormatException {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
VersionInfo? selectUpdateRelease(List<dynamic> releases, String currentVersionText) {
|
|
late final Version currentVersion;
|
|
try {
|
|
currentVersion = _parseVersion(currentVersionText);
|
|
} on FormatException {
|
|
return null;
|
|
}
|
|
|
|
Map<String, dynamic>? selectedRelease;
|
|
Version? selectedVersion;
|
|
|
|
for (final item in releases) {
|
|
if (item is! Map) continue;
|
|
final release = Map<String, dynamic>.from(item);
|
|
if (release['draft'] == true) continue;
|
|
|
|
final tagName = release['tag_name'];
|
|
if (tagName is! String || tagName.isEmpty) continue;
|
|
|
|
late final Version candidateVersion;
|
|
try {
|
|
candidateVersion = _parseVersion(tagName);
|
|
} on FormatException {
|
|
continue;
|
|
}
|
|
|
|
if (candidateVersion.compareTo(currentVersion) <= 0) continue;
|
|
|
|
final candidateIsPrerelease = release['prerelease'] == true || candidateVersion.isPreRelease;
|
|
if (!currentVersion.isPreRelease) {
|
|
if (candidateIsPrerelease) continue;
|
|
} else if (candidateIsPrerelease && !_hasSameReleaseCore(candidateVersion, currentVersion)) {
|
|
// Preview builds graduate through their own beta/rc line before returning
|
|
// to stable; they do not jump into the next version's preview line.
|
|
continue;
|
|
}
|
|
|
|
if (selectedVersion == null || candidateVersion.compareTo(selectedVersion) > 0) {
|
|
selectedRelease = release;
|
|
selectedVersion = candidateVersion;
|
|
}
|
|
}
|
|
|
|
if (selectedRelease == null || selectedVersion == null) return null;
|
|
final tagName = selectedRelease['tag_name'] as String;
|
|
return VersionInfo(
|
|
version: _versionText(tagName),
|
|
changeLog: (selectedRelease['body'] ?? '').toString(),
|
|
releaseUrl: (selectedRelease['html_url'] ?? 'https://github.com/GopeedLab/gopeed/releases/tag/$tagName').toString(),
|
|
);
|
|
}
|
|
|
|
Version _parseVersion(String version) => Version.parse(_versionText(version));
|
|
|
|
String _versionText(String version) => version.startsWith('v') ? version.substring(1) : version;
|
|
|
|
bool _hasSameReleaseCore(Version first, Version second) =>
|
|
first.major == second.major && first.minor == second.minor && first.patch == second.patch;
|
|
|
|
/// Extracts the matching section from Gopeed's bilingual GitHub release notes.
|
|
String localizedReleaseNotes(String fullChangeLog, String languageCode) {
|
|
final isChinese = languageCode.toLowerCase().startsWith('zh');
|
|
final chineseStart = RegExp(r'^#\s+更新日志', multiLine: true).firstMatch(fullChangeLog)?.start;
|
|
if (isChinese) {
|
|
return (chineseStart == null ? fullChangeLog : fullChangeLog.substring(chineseStart)).trim();
|
|
}
|
|
final englishStart = RegExp(r'^#\s+Release notes', multiLine: true).firstMatch(fullChangeLog)?.start;
|
|
if (englishStart == null) return fullChangeLog.trim();
|
|
final englishEnd = chineseStart != null && chineseStart > englishStart ? chineseStart : fullChangeLog.length;
|
|
return fullChangeLog.substring(englishStart, englishEnd).trim();
|
|
}
|
|
|
|
typedef UpdateProgressCallback = void Function(int received, int total);
|
|
|
|
/// Downloads and applies [versionInfo] using the build-time release channel.
|
|
/// Store-distributed and unsupported channels safely open the release page.
|
|
Future<void> updateApp(
|
|
VersionInfo versionInfo, {
|
|
required ExtraConfigGithubMirror githubMirror,
|
|
required UpdateProgressCallback onProgress,
|
|
}) async {
|
|
final channel = updateChannel;
|
|
final assetName = updateAssetName(versionInfo.version, channel: channel);
|
|
var assetPath = '';
|
|
|
|
if (assetName.isNotEmpty) {
|
|
final rawUrl = 'https://github.com/GopeedLab/gopeed/releases/download/v${versionInfo.version}/$assetName';
|
|
assetPath = path.join((await getTemporaryDirectory()).path, assetName);
|
|
final downloadUrl = await githubAutoMirror(rawUrl, MirrorType.githubRelease, config: githubMirror);
|
|
final client = Dio();
|
|
try {
|
|
await client.download(downloadUrl, assetPath, onReceiveProgress: onProgress);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
switch (channel) {
|
|
case UpdateChannel.windowsInstaller:
|
|
case UpdateChannel.windowsPortable:
|
|
case UpdateChannel.macosDmg:
|
|
case UpdateChannel.linuxFlathub:
|
|
case UpdateChannel.linuxSnap:
|
|
case UpdateChannel.linuxDeb:
|
|
final updaterPath = await Util.homePathJoin(_updaterBinaryName);
|
|
if (!await File(updaterPath).exists()) {
|
|
await _openRelease(versionInfo.releaseUrl);
|
|
return;
|
|
}
|
|
await Process.run(updaterPath, [
|
|
'-pid',
|
|
pid.toString(),
|
|
'-channel',
|
|
channel!.name,
|
|
'-asset',
|
|
assetPath,
|
|
'-exeDir',
|
|
path.dirname(Platform.resolvedExecutable),
|
|
'-log',
|
|
path.join(logsDir(), 'updater.log'),
|
|
]);
|
|
case UpdateChannel.androidApk:
|
|
await InstallPlugin.installApk(assetPath);
|
|
case UpdateChannel.linuxAppImage:
|
|
case UpdateChannel.linuxRpm:
|
|
case UpdateChannel.iosIpa:
|
|
case UpdateChannel.docker:
|
|
case null:
|
|
await _openRelease(versionInfo.releaseUrl);
|
|
}
|
|
}
|
|
|
|
Future<void> _openRelease(String releaseUrl) async {
|
|
final opened = await launchUrl(Uri.parse(releaseUrl), mode: LaunchMode.externalApplication);
|
|
if (!opened) throw StateError('Unable to open the release page.');
|
|
}
|
|
|
|
String updateAssetName(String version, {UpdateChannel? channel, Architecture? architecture}) {
|
|
final targetChannel = channel ?? updateChannel;
|
|
final arch = architecture ?? getArch();
|
|
|
|
String commonArchName() => switch (arch) {
|
|
Architecture.ia32 => '386',
|
|
Architecture.x64 => 'amd64',
|
|
_ => arch.name,
|
|
};
|
|
|
|
return switch (targetChannel) {
|
|
UpdateChannel.windowsInstaller => 'Gopeed-v$version-windows-${commonArchName()}.zip',
|
|
UpdateChannel.windowsPortable => 'Gopeed-v$version-windows-${commonArchName()}-portable.zip',
|
|
UpdateChannel.macosDmg => 'Gopeed-v$version-macos-${commonArchName()}.dmg',
|
|
UpdateChannel.linuxDeb => 'Gopeed-v$version-linux-${commonArchName()}.deb',
|
|
UpdateChannel.androidApk => _androidAssetName(version, arch),
|
|
_ => '',
|
|
};
|
|
}
|
|
|
|
String _androidAssetName(String version, Architecture arch) {
|
|
final archName = switch (arch) {
|
|
Architecture.arm => 'armeabi-v7a',
|
|
Architecture.arm64 => 'arm64-v8a',
|
|
Architecture.x64 => 'x86_64',
|
|
_ => null,
|
|
};
|
|
return 'Gopeed-v$version-android${archName == null ? '' : '-$archName'}.apk';
|
|
}
|