3 Commits

Author SHA1 Message Date
MaxFan 52d970f49e Release v1.2.23 2026-05-11 21:14:16 +03:30
MaxFan 588daa1332 Add update checks and tray notifications 2026-05-11 21:07:58 +03:30
MaxFan b2974cdc95 Clarify protected leak attempts in health status 2026-05-11 20:30:02 +03:30
11 changed files with 325 additions and 14 deletions
+3 -3
View File
@@ -23,9 +23,9 @@
<NeutralLanguage>fa-IR</NeutralLanguage>
<!-- Version Management -->
<Version>1.2.22</Version>
<AssemblyVersion>1.2.22.0</AssemblyVersion>
<FileVersion>1.2.22.0</FileVersion>
<Version>1.2.23</Version>
<AssemblyVersion>1.2.23.0</AssemblyVersion>
<FileVersion>1.2.23.0</FileVersion>
</PropertyGroup>
<ItemGroup>
+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!);
}
}
@@ -293,9 +293,11 @@ public partial class TrafficRouterService : IDisposable
public long LeakCount => Interlocked.Read(ref _statLeakConfirmed);
/// <summary>
/// Number of attempted leaks blocked locally by leak-guard.
/// Diagnostic-only signal for policy-transition races.
/// Diagnostic-only signal; these packets did not escape the machine.
/// </summary>
public long LeakBlockedCount => Interlocked.Read(ref _statLeakBlocked);
public long LeakBlockedRecoveredCount => Interlocked.Read(ref _statLeakBlockedRecovered);
public long LeakBlockedSuppressedCount => Interlocked.Read(ref _statLeakBlockedSuppressed);
public long Ipv6BlockedCount => Interlocked.Read(ref _statFlowIPv6Blocked);
public long DnsRedirectCount => Interlocked.Read(ref _redirectCount);
public long ActiveRouteCount => _addedRoutes.Count;
@@ -540,13 +542,13 @@ public partial class TrafficRouterService : IDisposable
long netOutFail = Interlocked.Read(ref _statNetOutSendFailed);
string mode = _fullRouteEnabled ? "full-route" : "split";
string leakState = leakConfirmed > 0 ? "LEAK-DETECTED" :
(leakBlocked > 0 ? "BLOCKING-ATTEMPTS" : "OK");
(leakBlocked > 0 ? "PROTECTED" : "OK");
Logger.Info(
$"[STATS] mode={mode} health={leakState} " +
$"flows={flowEst}/{flowDel} targetHit={flowHit} excluded={flowExcl} ipv6Drop={ipv6Blocked} " +
$"routes={Interlocked.Read(ref _statRoutesAdded)}({Interlocked.Read(ref _statRoutesFailed)}fail)/{_addedRoutes.Count}active " +
$"rewriteOut={netOutRw} rewriteIn={netInRw} rewriteFail={netOutFail} nat={_natTable.Count} " +
$"leakConfirmed={leakConfirmed} leakBlocked={leakBlocked} recovered={leakBlockedRecovered} suppressed={leakBlockedSuppressed} " +
$"leakConfirmed={leakConfirmed} protectedBlocked={leakBlocked} recovered={leakBlockedRecovered} suppressed={leakBlockedSuppressed} " +
$"targets={_targetExecutables.Count} blockedApps={_blockedExecutables.Count}");
// Loop health check — warn if any background loop has exited unexpectedly
@@ -235,11 +235,11 @@ public partial class TrafficRouterService
leakLogCount++;
if (recovered)
Logger.Warning($"[LEAK-BLOCKED] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked locally, route restored for retransmit via VPN");
Logger.Info($"[LEAK-PROTECTED] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked locally, route restored for retransmit via VPN");
else if (graceSuppressed)
Logger.Info($"[LEAK-BLOCKED-TRANSITION] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked during policy transition grace");
Logger.Info($"[LEAK-PROTECTED-TRANSITION] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked during policy transition grace");
else
Logger.Warning($"[LEAK-BLOCKED] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked by split policy, route not restored");
Logger.Info($"[LEAK-PROTECTED] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked by split policy, route not restored");
}
}
}
+116 -3
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 => "🔌 اتصال",
@@ -403,20 +459,20 @@ public partial class MainViewModel : INotifyPropertyChanged
? (_trafficRouter.LeakCount == 0
? (_trafficRouter.LeakBlockedCount == 0
? "Leak: OK"
: $"Leak: OK (blocked {_trafficRouter.LeakBlockedCount})")
: $"Leak: Protected {_trafficRouter.LeakBlockedCount}")
: $"Leak: {_trafficRouter.LeakCount}")
: "Leak: -";
public string HeaderLeakColor => !IsConnected
? "#6CCB5F"
: _trafficRouter.LeakCount > 0
? "#E05252"
: (_trafficRouter.LeakBlockedCount > 0 ? "#E07820" : "#6CCB5F");
: "#6CCB5F";
public string HealthLeakText => IsConnected
? (_trafficRouter.LeakCount == 0
? (_trafficRouter.LeakBlockedCount == 0
? "0 leak"
: $"0 leak / {_trafficRouter.LeakBlockedCount} blocked")
: $"0 leak / {_trafficRouter.LeakBlockedCount} protected")
: $"{_trafficRouter.LeakCount} leak")
: "-";
public string HealthDnsText => IsConnected
@@ -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>
+8
View File
@@ -2,6 +2,14 @@
## Unreleased
## 1.2.23
- Added GitHub release checking from the Help tab.
- Added automatic tray notification when a newer release is available.
- Added tray notifications for connection, disconnection, and connection errors.
- Added a tray menu action for checking updates.
- Moved remaining future VPN-manager improvements into the public roadmap.
## 1.2.22
- Fixed Help page data binding so GitHub and donation buttons work.
+1 -1
View File
@@ -21,7 +21,7 @@ dotnet publish AppTunnel\AppTunnel.csproj -c Release -r win-x64 --self-contained
Rename the final executable with the app version:
```powershell
TunnelX-v1.2.22-standalone-compressed.exe
TunnelX-v1.2.23-standalone-compressed.exe
```
## 32-bit Windows
+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