diff --git a/AppTunnel/MainWindow.xaml.cs b/AppTunnel/MainWindow.xaml.cs index b6f9e20..c9d263a 100644 --- a/AppTunnel/MainWindow.xaml.cs +++ b/AppTunnel/MainWindow.xaml.cs @@ -3,6 +3,7 @@ using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media.Animation; using System.Windows.Media; +using AppTunnel.Models; using AppTunnel.ViewModels; using AppTunnel.Helpers; using AppTunnel.Services; @@ -16,6 +17,8 @@ public partial class MainWindow : Window private CancellationTokenSource _loadCts = new(); private System.Windows.Forms.NotifyIcon? _trayIcon; private bool _isRealExit; + private ConnectionState _lastNotifiedConnectionState = ConnectionState.Disconnected; + private bool _updateNotificationShown; public MainWindow() { @@ -69,8 +72,25 @@ public partial class MainWindow : Window statusItem.Text = $"وضعیت: {_viewModel.StatusText}"; }); } + else if (args.PropertyName == nameof(MainViewModel.ConnectionState)) + { + Dispatcher.BeginInvoke(NotifyConnectionStateChanged); + } + else if (args.PropertyName == nameof(MainViewModel.IsUpdateAvailable)) + { + Dispatcher.BeginInvoke(NotifyUpdateAvailable); + } }; + var updateItem = new System.Windows.Forms.ToolStripMenuItem("بررسی بروزرسانی"); + updateItem.Click += (_, _) => + { + BringToForeground(); + if (_viewModel.CheckForUpdatesCommand.CanExecute(null)) + _viewModel.CheckForUpdatesCommand.Execute(null); + }; + menu.Items.Add(updateItem); + menu.Items.Add(new System.Windows.Forms.ToolStripSeparator()); var exitItem = new System.Windows.Forms.ToolStripMenuItem("خروج از برنامه"); @@ -136,6 +156,49 @@ public partial class MainWindow : Window } } + private void NotifyConnectionStateChanged() + { + if (_trayIcon == null) return; + + var state = _viewModel.ConnectionState; + if (state == _lastNotifiedConnectionState) return; + _lastNotifiedConnectionState = state; + + switch (state) + { + case ConnectionState.Connected: + ShowTrayNotification("TunnelX متصل شد", _viewModel.StatusText, + System.Windows.Forms.ToolTipIcon.Info); + break; + case ConnectionState.Disconnected: + ShowTrayNotification("TunnelX قطع شد", _viewModel.StatusText, + System.Windows.Forms.ToolTipIcon.Info); + break; + case ConnectionState.Error: + ShowTrayNotification("خطا در اتصال TunnelX", _viewModel.StatusText, + System.Windows.Forms.ToolTipIcon.Warning); + break; + } + } + + private void NotifyUpdateAvailable() + { + if (_trayIcon == null || _updateNotificationShown || !_viewModel.IsUpdateAvailable) + return; + + _updateNotificationShown = true; + ShowTrayNotification("بروزرسانی TunnelX آماده است", + _viewModel.UpdateStatusText, + System.Windows.Forms.ToolTipIcon.Info); + } + + private void ShowTrayNotification(string title, string message, System.Windows.Forms.ToolTipIcon icon) + { + if (_trayIcon == null) return; + _trayIcon.Visible = true; + _trayIcon.ShowBalloonTip(3500, title, message, icon); + } + private void OnLoaded(object sender, RoutedEventArgs e) { // Force window to foreground — borderless/transparent windows sometimes diff --git a/AppTunnel/Services/AppInfo.cs b/AppTunnel/Services/AppInfo.cs index 670add8..7638697 100644 --- a/AppTunnel/Services/AppInfo.cs +++ b/AppTunnel/Services/AppInfo.cs @@ -5,6 +5,7 @@ public static class AppInfo public const string AppName = "TunnelX"; public const string CreatorName = "MaxFan"; public const string GitHubUrl = "https://github.com/MaxiFan/TunnelX"; + public const string LatestReleaseUrl = GitHubUrl + "/releases/latest"; public const string LicenseName = "GPL-3.0-or-later"; public const string PayPalEmail = "gallafan@gmail.com"; public const string PayPalDonateUrl = "https://www.paypal.com/donate/?business=gallafan%40gmail.com¤cy_code=USD"; diff --git a/AppTunnel/Services/GitHubReleaseChecker.cs b/AppTunnel/Services/GitHubReleaseChecker.cs new file mode 100644 index 0000000..db5780f --- /dev/null +++ b/AppTunnel/Services/GitHubReleaseChecker.cs @@ -0,0 +1,55 @@ +using System.Net.Http; +using System.Text.Json; + +namespace AppTunnel.Services; + +public sealed record GitHubReleaseInfo( + Version Version, + string TagName, + string Name, + string Url, + bool IsPrerelease); + +public static class GitHubReleaseChecker +{ + private const string LatestReleaseApi = + "https://api.github.com/repos/MaxiFan/TunnelX/releases/latest"; + + public static async Task GetLatestReleaseAsync(CancellationToken ct) + { + using var http = new HttpClient(); + http.DefaultRequestHeaders.UserAgent.ParseAdd("TunnelX"); + http.Timeout = TimeSpan.FromSeconds(8); + + using var response = await http.GetAsync(LatestReleaseApi, ct); + if (!response.IsSuccessStatusCode) + return null; + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + using var json = await JsonDocument.ParseAsync(stream, cancellationToken: ct); + var root = json.RootElement; + + var tag = root.TryGetProperty("tag_name", out var tagElement) + ? tagElement.GetString() ?? "" + : ""; + if (!TryParseVersion(tag, out var version)) + return null; + + var name = root.TryGetProperty("name", out var nameElement) + ? nameElement.GetString() ?? tag + : tag; + var url = root.TryGetProperty("html_url", out var urlElement) + ? urlElement.GetString() ?? AppInfo.LatestReleaseUrl + : AppInfo.LatestReleaseUrl; + var prerelease = root.TryGetProperty("prerelease", out var preElement) && + preElement.ValueKind == JsonValueKind.True; + + return new GitHubReleaseInfo(version, tag, name, url, prerelease); + } + + public static bool TryParseVersion(string value, out Version version) + { + value = (value ?? "").Trim().TrimStart('v', 'V'); + return Version.TryParse(value, out version!); + } +} diff --git a/AppTunnel/ViewModels/MainViewModel.Core.cs b/AppTunnel/ViewModels/MainViewModel.Core.cs index 294145e..8c6e6fe 100644 --- a/AppTunnel/ViewModels/MainViewModel.Core.cs +++ b/AppTunnel/ViewModels/MainViewModel.Core.cs @@ -65,6 +65,8 @@ public partial class MainViewModel : INotifyPropertyChanged OpenGitHubCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.GitHubUrl)); OpenDonateCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.PayPalDonateUrl)); CopyDonationInfoCommand = new RelayCommand(_ => CopyDonationInfoToClipboard()); + CheckForUpdatesCommand = new RelayCommand(_ => _ = CheckForUpdatesAsync(false), _ => !IsCheckingForUpdates); + OpenLatestReleaseCommand = new RelayCommand(_ => OpenExternalLink(LatestReleaseUrl), _ => !string.IsNullOrWhiteSpace(LatestReleaseUrl)); _trafficRouter.TrafficUpdated += OnTrafficUpdated; @@ -88,6 +90,7 @@ public partial class MainViewModel : INotifyPropertyChanged LoadExcludes(); LoadIncludes(); LoadHistory(); + _ = CheckForUpdatesAsync(true); } #region Properties @@ -251,6 +254,59 @@ public partial class MainViewModel : INotifyPropertyChanged public string DonatePayPalText => $"پی‌پل: {AppInfo.PayPalEmail}"; public string CryptoDonationText => AppInfo.CryptoDonationText; + private bool _isCheckingForUpdates; + public bool IsCheckingForUpdates + { + get => _isCheckingForUpdates; + set + { + if (_isCheckingForUpdates == value) return; + _isCheckingForUpdates = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(UpdateButtonText)); + CommandManager.InvalidateRequerySuggested(); + } + } + + private bool _isUpdateAvailable; + public bool IsUpdateAvailable + { + get => _isUpdateAvailable; + set + { + if (_isUpdateAvailable == value) return; + _isUpdateAvailable = value; + OnPropertyChanged(); + } + } + + private string _updateStatusText = "برای بررسی نسخه جدید، دکمه بررسی بروزرسانی را بزنید."; + public string UpdateStatusText + { + get => _updateStatusText; + set + { + if (_updateStatusText == value) return; + _updateStatusText = value; + OnPropertyChanged(); + } + } + + private string _latestReleaseUrl = AppInfo.LatestReleaseUrl; + public string LatestReleaseUrl + { + get => _latestReleaseUrl; + set + { + if (_latestReleaseUrl == value) return; + _latestReleaseUrl = value; + OnPropertyChanged(); + CommandManager.InvalidateRequerySuggested(); + } + } + + public string UpdateButtonText => IsCheckingForUpdates ? "در حال بررسی..." : "بررسی بروزرسانی"; + public string ConnectButtonText => _connectionState switch { ConnectionState.Disconnected => "🔌 اتصال", @@ -619,6 +675,8 @@ public partial class MainViewModel : INotifyPropertyChanged public ICommand OpenGitHubCommand { get; } public ICommand OpenDonateCommand { get; } public ICommand CopyDonationInfoCommand { get; } + public ICommand CheckForUpdatesCommand { get; } + public ICommand OpenLatestReleaseCommand { get; } #endregion @@ -658,6 +716,61 @@ public partial class MainViewModel : INotifyPropertyChanged } } + private async Task CheckForUpdatesAsync(bool silent) + { + if (IsCheckingForUpdates) return; + + try + { + IsCheckingForUpdates = true; + if (!silent) + UpdateStatusText = "در حال بررسی آخرین نسخه در GitHub..."; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var latest = await GitHubReleaseChecker.GetLatestReleaseAsync(cts.Token); + if (latest == null) + { + if (!silent) + UpdateStatusText = "بررسی نسخه جدید ناموفق بود. اتصال اینترنت یا GitHub را بررسی کنید."; + Logger.Warning("[UPDATE] Latest release check failed"); + return; + } + + LatestReleaseUrl = latest.Url; + var currentVersion = System.Reflection.Assembly.GetExecutingAssembly() + .GetName().Version ?? new Version(0, 0, 0); + var current = new Version(currentVersion.Major, currentVersion.Minor, currentVersion.Build); + + if (latest.Version > current) + { + IsUpdateAvailable = true; + UpdateStatusText = $"نسخه جدید آماده است: {latest.TagName} - برای دانلود از GitHub باز کنید."; + Logger.Info($"[UPDATE] New version available: current={current} latest={latest.TagName}"); + return; + } + + IsUpdateAvailable = false; + UpdateStatusText = $"TunnelX به‌روز است. نسخه فعلی: {AppInfo.VersionText}"; + Logger.Info($"[UPDATE] App is up to date: current={current} latest={latest.TagName}"); + } + catch (OperationCanceledException) + { + if (!silent) + UpdateStatusText = "بررسی بروزرسانی به زمان مجاز نرسید."; + Logger.Warning("[UPDATE] Latest release check timed out"); + } + catch (Exception ex) + { + if (!silent) + UpdateStatusText = $"بررسی بروزرسانی ناموفق بود: {ex.Message}"; + Logger.Warning($"[UPDATE] Latest release check failed: {ex.Message}"); + } + finally + { + IsCheckingForUpdates = false; + } + } + private void PasteConfigFromClipboard() { try diff --git a/AppTunnel/Views/HelpTabView.xaml b/AppTunnel/Views/HelpTabView.xaml index b549208..e80b07d 100644 --- a/AppTunnel/Views/HelpTabView.xaml +++ b/AppTunnel/Views/HelpTabView.xaml @@ -102,6 +102,41 @@ + + + + + + + + + + + + + + + +