check app version update

This commit is contained in:
wanghongenpin
2025-04-13 23:10:50 +08:00
parent 3ea6dce5cb
commit ab905bb1fc
14 changed files with 517 additions and 47 deletions

View File

@@ -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`

View File

@@ -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"
}

View File

@@ -314,5 +314,15 @@
"time": "时间",
"nowTimestamp": "当前时间戳(秒)",
"hosts": "Hosts 映射",
"toAddress": "映射地址"
"toAddress": "映射地址",
"appUpdateCheckVersion": "检测更新",
"appUpdateNotAvailableMsg": "已是最新版本",
"appUpdateDialogTitle": "有可用更新",
"appUpdateUpdateMsg": "ProxyPin 的新版本现已推出。您想现在更新吗?",
"appUpdateCurrentVersionLbl": "当前版本",
"appUpdateNewVersionLbl": "新版本",
"appUpdateUpdateNowBtnTxt": "现在更新",
"appUpdateLaterBtnTxt": "以后再说",
"appUpdateIgnoreBtnTxt": "忽略"
}

View File

@@ -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<void> 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<RemoteVersionEntity?> 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<String, dynamic>));
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<int> parseVersion(String version) {
return normalizeVersion(version).split('.').map(int.parse).toList();
}
List<int> current = parseVersion(currentVersion);
List<int> 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; // 最新版本有更多的子版本号
}
}

View File

@@ -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);

View File

@@ -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<void> 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),
),
],
);
}
}

View File

@@ -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<String, dynamic> 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);
}
}

View File

@@ -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<void> 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,
);
}
}

View File

@@ -63,6 +63,8 @@ class ThemeModel {
}
class AppConfiguration {
static const String version = "1.1.8";
ValueNotifier<bool> globalChange = ValueNotifier(false);
ThemeModel _theme = ThemeModel();

View File

@@ -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<DesktopHomePage> implements EventL
WidgetsBinding.instance.addPostFrameCallback((_) {
showUpgradeNotice();
});
} else {
AppUpdateRepository.checkUpdate(context);
}
}

View File

@@ -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<StatefulWidget> createState() {
return _AppUpdateStateChecking();
}
}
class _AppUpdateStateChecking extends State<DesktopAbout> {
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")))
],
)),
);
}
}

View File

@@ -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<Setting> {
@override
Widget build(BuildContext context) {
return MenuAnchor(
builder: (context, controller, child) {
return IconButton(
@@ -94,49 +93,7 @@ class _SettingState extends State<Setting> {
}
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());
}
///设置外部代理地址

View File

@@ -13,6 +13,15 @@ extension ListFirstWhere<T> on Iterable<T> {
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}');
}

View File

@@ -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