Add update checks and tray notifications

This commit is contained in:
MaxFan
2026-05-11 21:07:58 +03:30
parent b2974cdc95
commit 588daa1332
6 changed files with 302 additions and 1 deletions
+63
View File
@@ -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
+1
View File
@@ -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&currency_code=USD";
@@ -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<GitHubReleaseInfo?> 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!);
}
}
+113
View File
@@ -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
+35
View File
@@ -102,6 +102,41 @@
</Grid>
</Border>
<!-- Updates -->
<Border Style="{StaticResource CardPanel}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Style="{StaticResource SectionHeader}" Text="بروزرسانی"/>
<TextBlock Text="{Binding UpdateStatusText}"
FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="18"/>
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Vertical"
VerticalAlignment="Center"
MinWidth="150">
<Button Style="{StaticResource SecondaryButton}"
Content="{Binding UpdateButtonText}"
Command="{Binding CheckForUpdatesCommand}"
Padding="14,8"/>
<Button Style="{StaticResource PrimaryButton}"
Content="باز کردن صفحه انتشار"
Command="{Binding OpenLatestReleaseCommand}"
Padding="14,8"
Margin="0,8,0,0"/>
</StackPanel>
</Grid>
</Border>
<!-- Quick Path -->
<Border Style="{StaticResource CardPanel}">
<StackPanel>
+35 -1
View File
@@ -16,7 +16,7 @@ Candidate checks:
- WinDivert and Wintun native component availability
- extraction folder write permissions
- route and packet interception readiness
- GitHub release/update status
- deeper release asset verification for future automatic update flows
Potential actions:
@@ -28,3 +28,37 @@ Potential actions:
- avoid silent driver or system-level changes
For public releases, TunnelX should continue to prefer self-contained standalone EXE builds so end users do not need to install the .NET Runtime separately.
### Profile management
- import and export connection profiles
- clone profiles with clearer naming
- per-profile health and last-test status
- presets for common workflows such as browser-only, messaging, development, and full VPN
### Split tunnel rule clarity
- explicit rule priority between apps, include destinations, and exclude destinations
- wildcard and domain-suffix previews
- conflict detection when the same destination is both included and excluded
- show which rule caused a destination to be routed or bypassed
### Health dashboard
- user-facing states: safe, protected, needs attention, and broken
- short explanation for the latest important network event
- suggested action next to DNS, IPv6, route, and leak status
### Kill switch
- optional per-app kill switch when the tunnel core or TUN bridge drops
- keep selected apps blocked until the tunnel is restored or the user disconnects
### Traffic accounting clarity
- keep tunnel, direct, per-app, DNS, and history counters aligned to one accounting model
- expose whether counters are interface-based, rewritten-flow-based, or app-attributed
### Installer and prerequisite checks
- optional installer/bootstrapper for shortcuts, uninstall support, prerequisite checks, and future update flow