diff --git a/analysis_options.yaml b/analysis_options.yaml index 235b70f..e28af18 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,6 +2,9 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +formatter: + page_width: 120 + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 10cc246..b2f685f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -315,5 +315,15 @@ "time": "DateTime", "nowTimestamp": "Now timestamp", "hosts": "Hosts", - "toAddress": "To Address" + "toAddress": "To Address", + + "appUpdateCheckVersion": "Check for Updates", + "appUpdateNotAvailableMsg": "Already Using The Latest Version", + "appUpdateDialogTitle": "Update Available", + "appUpdateUpdateMsg": "A new version of ProxyPin is available. Would you like to update now?", + "appUpdateCurrentVersionLbl": "Current Version", + "appUpdateNewVersionLbl": "New Version", + "appUpdateUpdateNowBtnTxt": "Update Now", + "appUpdateLaterBtnTxt": "Later", + "appUpdateIgnoreBtnTxt": "Ignore" } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fa218bf..4646793 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -314,5 +314,15 @@ "time": "时间", "nowTimestamp": "当前时间戳(秒)", "hosts": "Hosts 映射", - "toAddress": "映射地址" + "toAddress": "映射地址", + + "appUpdateCheckVersion": "检测更新", + "appUpdateNotAvailableMsg": "已是最新版本", + "appUpdateDialogTitle": "有可用更新", + "appUpdateUpdateMsg": "ProxyPin 的新版本现已推出。您想现在更新吗?", + "appUpdateCurrentVersionLbl": "当前版本", + "appUpdateNewVersionLbl": "新版本", + "appUpdateUpdateNowBtnTxt": "现在更新", + "appUpdateLaterBtnTxt": "以后再说", + "appUpdateIgnoreBtnTxt": "忽略" } \ No newline at end of file diff --git a/lib/ui/app_update/app_update_repository.dart b/lib/ui/app_update/app_update_repository.dart new file mode 100644 index 0000000..3ae68c0 --- /dev/null +++ b/lib/ui/app_update/app_update_repository.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:http/http.dart' as http; +import 'package:proxypin/network/util/logger.dart'; +import 'package:proxypin/ui/app_update/remote_version_entity.dart'; +import 'package:proxypin/ui/component/app_dialog.dart'; +import 'package:proxypin/ui/configuration.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'constants.dart'; +import 'new_version_dialog.dart'; + +class AppUpdateRepository { + static final HttpClient httpClient = HttpClient(); + + static Future checkUpdate(BuildContext context, {bool canIgnore = true, bool showToast = false}) async { + try { + var lastVersion = await getLatestVersion(); + if (lastVersion == null) { + logger.w("[AppUpdate] failed to fetch latest version info"); + return; + } + + if (!context.mounted) return; + + var availableUpdates = compareVersions(AppConfiguration.version, lastVersion.version); + if (availableUpdates) { + if (canIgnore) { + var ignoreVersion = await SharedPreferencesAsync().getString(Constants.ignoreReleaseVersionKey); + if (ignoreVersion == lastVersion.version) { + logger.d("ignored release [${lastVersion.version}]"); + return; + } + } + + logger.d("new version available: $lastVersion"); + + if (!context.mounted) return; + NewVersionDialog( + AppConfiguration.version, + lastVersion, + canIgnore: true, + ).show(context); + return; + } + + logger.i("already using latest version[${AppConfiguration.version}], last: [${lastVersion.version}]"); + + if (showToast) { + AppLocalizations localizations = AppLocalizations.of(context)!; + CustomToast.success(localizations.appUpdateNotAvailableMsg).show(context); + } + } catch (e) { + logger.e("Error checking for updates: $e"); + if (showToast) { + AppAlertDialog(message: e.toString()).show(context); + } + } + } + + /// Fetches the latest version information from the GitHub releases API. + static Future getLatestVersion({bool includePreReleases = false}) async { + final response = await http.get(Uri.parse(Constants.githubReleasesApiUrl)); + if (response.statusCode != 200 || response.body.isEmpty) { + logger.w("[AppUpdate] failed to fetch latest version info"); + return null; + } + + var body = jsonDecode(response.body) as List; + final releases = body.map((e) => GithubReleaseParser.parse(e as Map)); + late RemoteVersionEntity latest; + if (includePreReleases) { + latest = releases.first; + } else { + latest = releases.firstWhere((e) => e.preRelease == false); + } + + logger.d("[AppUpdate] latest version: $latest"); + return latest; + } + + static bool compareVersions(String currentVersion, String latestVersion) { + String normalizeVersion(String version) { + return version.startsWith('v') ? version.substring(1) : version; + } + + List parseVersion(String version) { + return normalizeVersion(version).split('.').map(int.parse).toList(); + } + + List current = parseVersion(currentVersion); + List latest = parseVersion(latestVersion); + + for (int i = 0; i < current.length; i++) { + if (i >= latest.length || current[i] > latest[i]) { + return false; // 当前版本高于最新版本 + } else if (current[i] < latest[i]) { + return true; // 需要更新 + } + } + + return latest.length > current.length; // 最新版本有更多的子版本号 + } +} diff --git a/lib/ui/app_update/constants.dart b/lib/ui/app_update/constants.dart new file mode 100644 index 0000000..a372e9c --- /dev/null +++ b/lib/ui/app_update/constants.dart @@ -0,0 +1,11 @@ +abstract class Constants { + static const githubUrl = "https://github.com/wanghongenpin/proxypin"; + static const githubReleasesApiUrl = + "https://api.github.com/repos/wanghongenpin/proxypin/releases"; + static const githubLatestReleaseUrl = + "https://github.com/wanghongenpin/proxypin/releases/latest"; + + static const String ignoreReleaseVersionKey = "ignored_release_version"; +} + +const kAnimationDuration = Duration(milliseconds: 250); diff --git a/lib/ui/app_update/new_version_dialog.dart b/lib/ui/app_update/new_version_dialog.dart new file mode 100644 index 0000000..157c0c2 --- /dev/null +++ b/lib/ui/app_update/new_version_dialog.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:proxypin/network/util/logger.dart'; +import 'package:proxypin/ui/app_update/remote_version_entity.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'constants.dart'; + +class NewVersionDialog extends StatelessWidget { + NewVersionDialog( + this.currentVersion, + this.newVersion, { + this.canIgnore = true, + }) : super(key: _dialogKey); + + final String currentVersion; + final RemoteVersionEntity newVersion; + final bool canIgnore; + + static final _dialogKey = GlobalKey(debugLabel: 'new version dialog'); + + Future show(BuildContext context) async { + if (_dialogKey.currentContext == null) { + return showDialog( + context: context, + useRootNavigator: true, + builder: (context) => this, + ); + } else { + logger.d("new version dialog is already open"); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + AppLocalizations localizations = AppLocalizations.of(context)!; + + return AlertDialog( + title: Text(localizations.appUpdateDialogTitle), + // scrollable: true, + content: Container( + constraints: BoxConstraints(maxHeight: 230, maxWidth: 500), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(localizations.appUpdateUpdateMsg), + const SizedBox(height: 5), + Text.rich( + TextSpan( + children: [ + TextSpan(text: "${localizations.appUpdateCurrentVersionLbl}: ", style: theme.textTheme.bodySmall), + TextSpan(text: currentVersion, style: theme.textTheme.labelMedium), + ], + ), + ), + Text.rich( + TextSpan( + children: [ + TextSpan(text: "${localizations.appUpdateNewVersionLbl}: ", style: theme.textTheme.bodySmall), + TextSpan(text: newVersion.version, style: theme.textTheme.labelMedium), + ], + ), + ), + Text(newVersion.content ?? '', style: theme.textTheme.labelMedium), + ], + ))), + actions: [ + if (canIgnore) + TextButton( + onPressed: () async { + SharedPreferencesAsync().setString(Constants.ignoreReleaseVersionKey, newVersion.version); + logger.i("ignored release [${newVersion.version}]"); + if (context.mounted) Navigator.pop(context); + }, + child: Text(localizations.appUpdateIgnoreBtnTxt), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(localizations.appUpdateLaterBtnTxt), + ), + TextButton( + onPressed: () async { + await launchUrl(Uri.parse(newVersion.url), mode: LaunchMode.externalApplication); + }, + child: Text(localizations.appUpdateUpdateNowBtnTxt), + ), + ], + ); + } +} diff --git a/lib/ui/app_update/remote_version_entity.dart b/lib/ui/app_update/remote_version_entity.dart new file mode 100644 index 0000000..73613f4 --- /dev/null +++ b/lib/ui/app_update/remote_version_entity.dart @@ -0,0 +1,49 @@ +import 'package:proxypin/utils/lang.dart'; + +class RemoteVersionEntity { + final String version; + final String buildNumber; + final String releaseTag; + final bool preRelease; + final String url; + final String? content; + final DateTime publishedAt; + + RemoteVersionEntity({ + required this.version, + required this.buildNumber, + required this.releaseTag, + required this.preRelease, + required this.url, + this.content, + required this.publishedAt, + }); + + @override + String toString() { + return 'RemoteVersionEntity(version: $version, buildNumber: $buildNumber, releaseTag: $releaseTag, preRelease: $preRelease, url: $url, publishedAt: $publishedAt)'; + } +} + +abstract class GithubReleaseParser { + static RemoteVersionEntity parse(Map json) { + final fullTag = json['tag_name'] as String; + final fullVersion = fullTag.removePrefix("v").split("-").first.split("+"); + var version = fullVersion.first; + var buildNumber = fullVersion.elementAtOrElse(1, (index) => ""); + + final preRelease = json["prerelease"] as bool; + final publishedAt = DateTime.parse(json["published_at"] as String); + + var body = json['body']?.toString().split("English: "); + return RemoteVersionEntity( + version: version, + buildNumber: buildNumber, + releaseTag: fullTag, + preRelease: preRelease, + url: json["html_url"] as String, + content: body?.last, + publishedAt: publishedAt); + } +} + diff --git a/lib/ui/component/app_dialog.dart b/lib/ui/component/app_dialog.dart new file mode 100644 index 0000000..3401057 --- /dev/null +++ b/lib/ui/component/app_dialog.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:toastification/toastification.dart'; + +class AppAlertDialog extends StatelessWidget { + const AppAlertDialog({ + super.key, + this.title, + required this.message, + }); + + final String? title; + final String message; + + factory AppAlertDialog.fromErr(({String type, String? message}) err) => AppAlertDialog( + title: err.message == null ? null : err.type, + message: err.message ?? err.type, + ); + + Future show(BuildContext context) async { + await showDialog( + context: context, + useRootNavigator: true, + builder: (context) => this, + ); + } + + @override + Widget build(BuildContext context) { + final localizations = MaterialLocalizations.of(context); + + return AlertDialog( + title: title != null ? Text(title!) : null, + content: SingleChildScrollView( + child: SizedBox( + width: 468, + child: Text(message), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(localizations.okButtonLabel), + ), + ], + ); + } +} + +enum AlertType { + info, + error, + success; + + ToastificationType get _toastificationType => switch (this) { + success => ToastificationType.success, + error => ToastificationType.error, + info => ToastificationType.info, + }; +} + +class CustomToast extends StatelessWidget { + const CustomToast( + this.message, { + super.key, + this.type = AlertType.info, + this.icon, + this.duration = const Duration(seconds: 3), + }); + + const CustomToast.error( + this.message, { + super.key, + this.duration = const Duration(seconds: 5), + }) : type = AlertType.error, + icon = Icons.error; + + const CustomToast.success( + this.message, { + super.key, + this.duration = const Duration(seconds: 3), + }) : type = AlertType.success, + icon = Icons.check_circle; + + final String message; + final AlertType type; + final IconData? icon; + final Duration duration; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(message)), + ], + ), + ); + } + + void show(BuildContext context) { + toastification.show( + context: context, + title: Text(message), + icon: icon == null ? null : Icon(icon), + type: type._toastificationType, + alignment: Alignment.bottomLeft, + autoCloseDuration: duration, + style: ToastificationStyle.fillColored, + pauseOnHover: true, + showProgressBar: false, + dragToClose: true, + closeOnClick: true, + closeButtonShowType: CloseButtonShowType.onHover, + ); + } +} diff --git a/lib/ui/configuration.dart b/lib/ui/configuration.dart index 04c11ce..c43b8e0 100644 --- a/lib/ui/configuration.dart +++ b/lib/ui/configuration.dart @@ -63,6 +63,8 @@ class ThemeModel { } class AppConfiguration { + static const String version = "1.1.8"; + ValueNotifier globalChange = ValueNotifier(false); ThemeModel _theme = ThemeModel(); diff --git a/lib/ui/desktop/desktop.dart b/lib/ui/desktop/desktop.dart index 72a4912..d8fc7e8 100644 --- a/lib/ui/desktop/desktop.dart +++ b/lib/ui/desktop/desktop.dart @@ -35,6 +35,7 @@ import 'package:proxypin/ui/desktop/request/list.dart'; import 'package:proxypin/ui/desktop/toolbar/toolbar.dart'; import 'package:proxypin/utils/listenable_list.dart'; +import '../app_update/app_update_repository.dart'; import '../component/split_view.dart'; /// @author wanghongen @@ -93,6 +94,8 @@ class _DesktopHomePagePageState extends State implements EventL WidgetsBinding.instance.addPostFrameCallback((_) { showUpgradeNotice(); }); + } else { + AppUpdateRepository.checkUpdate(context); } } diff --git a/lib/ui/desktop/toolbar/setting/about.dart b/lib/ui/desktop/toolbar/setting/about.dart new file mode 100644 index 0000000..79a558d --- /dev/null +++ b/lib/ui/desktop/toolbar/setting/about.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:proxypin/ui/app_update/app_update_repository.dart'; +import 'package:proxypin/ui/configuration.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class DesktopAbout extends StatefulWidget { + const DesktopAbout({super.key}); + + @override + State createState() { + return _AppUpdateStateChecking(); + } +} + +class _AppUpdateStateChecking extends State { + bool checkUpdating = false; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + Widget build(BuildContext context) { + bool isCN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'zh'); + String gitHub = "https://github.com/wanghongenpin/proxypin"; + + return AlertDialog( + titlePadding: const EdgeInsets.only(left: 20, top: 10, right: 15), + title: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + const Expanded(child: SizedBox()), + Text(localizations.about, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), + const Expanded(child: SizedBox()), + Align(alignment: Alignment.topRight, child: CloseButton()) + ]), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text("ProxyPin", style: TextStyle(fontSize: 20)), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.only(left: 10, right: 10), + child: + Text(isCN ? "全平台开源免费抓包软件" : "Full platform open source free capture HTTP(S) traffic software")), + const SizedBox(height: 10), + Text("v${AppConfiguration.version}"), + const SizedBox(height: 10), + ListTile( + title: Text('GitHub'), + trailing: const Icon(Icons.open_in_new, size: 22), + onTap: () => launchUrl(Uri.parse(gitHub))), + ListTile( + title: Text(localizations.feedback), + trailing: const Icon(Icons.open_in_new, size: 22), + onTap: () => launchUrl(Uri.parse("$gitHub/issues"))), + ListTile( + title: Text(localizations.appUpdateCheckVersion), + trailing: checkUpdating + ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator()) + : const Icon(Icons.sync, size: 22), + onTap: () async { + if (checkUpdating) { + return; + } + setState(() { + checkUpdating = true; + }); + await AppUpdateRepository.checkUpdate(context, canIgnore: false, showToast: true); + setState(() { + checkUpdating = false; + }); + }), + ListTile( + title: Text(isCN ? "下载地址" : "Download"), + trailing: const Icon(Icons.open_in_new, size: 22), + onTap: () => launchUrl( + Uri.parse(isCN ? "https://gitee.com/wanghongenpin/proxypin/releases" : "$gitHub/releases"))) + ], + )), + ); + } +} diff --git a/lib/ui/desktop/toolbar/setting/setting.dart b/lib/ui/desktop/toolbar/setting/setting.dart index 9ea8ec6..8fbc7da 100644 --- a/lib/ui/desktop/toolbar/setting/setting.dart +++ b/lib/ui/desktop/toolbar/setting/setting.dart @@ -24,10 +24,10 @@ import 'package:proxypin/network/components/manager/request_block_manager.dart'; import 'package:proxypin/network/util/system_proxy.dart'; import 'package:proxypin/ui/component/multi_window.dart'; import 'package:proxypin/ui/component/widgets.dart'; +import 'package:proxypin/ui/desktop/toolbar/setting/about.dart'; import 'package:proxypin/ui/desktop/toolbar/setting/external_proxy.dart'; import 'package:proxypin/ui/desktop/toolbar/setting/hosts.dart'; import 'package:proxypin/ui/desktop/toolbar/setting/request_block.dart'; -import 'package:url_launcher/url_launcher.dart'; import 'filter.dart'; @@ -56,7 +56,6 @@ class _SettingState extends State { @override Widget build(BuildContext context) { - return MenuAnchor( builder: (context, controller, child) { return IconButton( @@ -94,49 +93,7 @@ class _SettingState extends State { } showAbout() { - bool isCN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'zh'); - - String gitHub = "https://github.com/wanghongenpin/proxypin"; - - showDialog( - context: context, - builder: (context) { - return AlertDialog( - titlePadding: const EdgeInsets.only(left: 20, top: 10, right: 15), - title: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - const Expanded(child: SizedBox()), - Text(localizations.about, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), - const Expanded(child: SizedBox()), - Align(alignment: Alignment.topRight, child: CloseButton()) - ]), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text("ProxyPin", style: TextStyle(fontSize: 20)), - const SizedBox(height: 10), - Padding( - padding: const EdgeInsets.only(left: 10, right: 10), - child: - Text(isCN ? "全平台开源免费抓包软件" : "Full platform open source free capture HTTP(S) traffic software")), - const SizedBox(height: 10), - const Text("V1.1.7"), - const SizedBox(height: 10), - ListTile( - title: Text('GitHub', textAlign: TextAlign.center, style: TextStyle(color: Colors.blue)), - onTap: () => launchUrl(Uri.parse(gitHub))), - ListTile( - title: - Text(localizations.feedback, textAlign: TextAlign.center, style: TextStyle(color: Colors.blue)), - onTap: () => launchUrl(Uri.parse("$gitHub/issues"))), - ListTile( - title: Text(isCN ? "下载地址" : "Download", - textAlign: TextAlign.center, style: TextStyle(color: Colors.blue)), - onTap: () => launchUrl( - Uri.parse(isCN ? "https://gitee.com/wanghongenpin/proxypin/releases" : "$gitHub/releases"))) - ], - ), - ); - }); + showDialog(context: context, builder: (context) => DesktopAbout()); } ///设置外部代理地址 diff --git a/lib/utils/lang.dart b/lib/utils/lang.dart index d92a916..a672280 100644 --- a/lib/utils/lang.dart +++ b/lib/utils/lang.dart @@ -13,6 +13,15 @@ extension ListFirstWhere on Iterable { return null; } } + + T elementAtOrElse(int index, T Function(int index) defaultValue) { + if (index < 0) return defaultValue(index); + var count = 0; + for (final element in this) { + if (index == count++) return element; + } + return defaultValue(index); + } } extension DateTimeFormat on DateTime { @@ -102,6 +111,14 @@ class Strings { /// 这样会导致,换行时上一行可能会留很大的空白区域 /// 把每个字符插入一个0宽的字符, \u{200B} extension StringEnhance on String { + + String removePrefix(String prefix) { + if (startsWith(prefix)) { + return substring(prefix.length, length); + } else { + return this; + } + } String fixAutoLines() { return Characters(this).join('\u{200B}'); } diff --git a/pubspec.yaml b/pubspec.yaml index 923308b..5ac1bec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: shared_preferences: ^2.5.3 image_pickers: ^2.0.6 url_launcher: ^6.3.1 + toastification: ^3.0.2 qr_flutter: ^4.1.0 flutter_qr_reader_copy: ^1.0.9