Update release packaging and connection UX

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MaxFan
2026-05-18 14:14:47 +03:30
parent bae98cf9e9
commit 04d8d52ba9
15 changed files with 399 additions and 44 deletions
+73 -1
View File
@@ -95,10 +95,12 @@ jobs:
}
$artifactName = "TunnelX-$tag-standalone-compressed.exe"
$frameworkArtifactName = "TunnelX-$tag-framework-dependent-win-x64.zip"
"version=$version" >> $env:GITHUB_OUTPUT
"tag=$tag" >> $env:GITHUB_OUTPUT
"artifact_name=$artifactName" >> $env:GITHUB_OUTPUT
"framework_artifact_name=$frameworkArtifactName" >> $env:GITHUB_OUTPUT
- name: Update version and changelog
id: release_notes
@@ -213,6 +215,63 @@ jobs:
"checksum=$checksum" >> $env:GITHUB_OUTPUT
"sha256=$hash" >> $env:GITHUB_OUTPUT
- name: Publish framework-dependent package
run: >
dotnet publish AppTunnel\AppTunnel.csproj
-c Release
-r win-x64
--self-contained false
-p:DebugType=None
-p:DebugSymbols=false
-o publish\TunnelX-framework-dependent
- name: Package framework-dependent asset
id: framework_package
shell: pwsh
run: |
$publishDir = "publish/TunnelX-framework-dependent"
$assetName = "${{ steps.meta.outputs.framework_artifact_name }}"
$asset = "publish/$assetName"
$checksum = "$asset.sha256"
$readme = Join-Path $publishDir "README.txt"
if (-not (Test-Path (Join-Path $publishDir "TunnelX.exe"))) {
throw "Framework-dependent executable was not found in $publishDir"
}
$readmeLines = @(
"TunnelX framework-dependent package",
"===================================",
"",
"This package is smaller than the standalone download because it does not include the .NET runtime.",
"",
"Use this ZIP only if Microsoft .NET 8 Desktop Runtime (x64) is already installed on this Windows PC.",
"If .NET 8 Desktop Runtime is not installed, download the standalone TunnelX EXE instead.",
"",
"Download .NET 8 Desktop Runtime:",
"https://dotnet.microsoft.com/en-us/download/dotnet/8.0",
"",
"Run:",
"1. Extract the ZIP to a folder.",
"2. Run TunnelX.exe as Administrator.",
"",
"Recommended for most users:",
"TunnelX standalone compressed EXE."
)
($readmeLines -join "`r`n") | Set-Content -Encoding UTF8 -LiteralPath $readme
if (Test-Path $asset) {
Remove-Item -LiteralPath $asset -Force
}
Compress-Archive -Path (Join-Path $publishDir "*") -DestinationPath $asset -Force
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $asset).Hash.ToLowerInvariant()
"$hash $assetName" | Set-Content -Encoding ASCII -LiteralPath $checksum
"asset=$asset" >> $env:GITHUB_OUTPUT
"checksum=$checksum" >> $env:GITHUB_OUTPUT
"sha256=$hash" >> $env:GITHUB_OUTPUT
- name: Upload workflow artifact
uses: actions/upload-artifact@v6
with:
@@ -220,6 +279,8 @@ jobs:
path: |
${{ steps.package.outputs.asset }}
${{ steps.package.outputs.checksum }}
${{ steps.framework_package.outputs.asset }}
${{ steps.framework_package.outputs.checksum }}
if-no-files-found: error
- name: Create GitHub release
@@ -231,9 +292,17 @@ jobs:
$title = "TunnelX $tag"
$runUrl = "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
$sha256 = "${{ steps.package.outputs.sha256 }}".ToUpperInvariant()
$frameworkSha256 = "${{ steps.framework_package.outputs.sha256 }}".ToUpperInvariant()
$artifactName = "${{ steps.meta.outputs.artifact_name }}"
$frameworkArtifactName = "${{ steps.meta.outputs.framework_artifact_name }}"
$notes = Get-Content -Raw -LiteralPath "${{ steps.release_notes.outputs.notes_file }}"
$downloadLines = @(
"## Downloads",
"",
"- Recommended for most users: ``$artifactName``. This is the standalone self-contained EXE and does not require a separate .NET installation.",
"- Smaller package for users who already have Microsoft .NET 8 Desktop Runtime (x64): ``$frameworkArtifactName``. Extract the ZIP and run ``TunnelX.exe`` as Administrator. The ZIP includes ``README.txt`` with the same requirement."
)
$provenanceLines = @(
"<!-- release-provenance:start -->",
"## Build provenance",
@@ -243,9 +312,10 @@ jobs:
"- Run: $runUrl",
"- Commit: ``${{ steps.release_commit.outputs.sha }}``",
"- SHA256: ``$sha256 $artifactName``",
"- SHA256: ``$frameworkSha256 $frameworkArtifactName``",
"<!-- release-provenance:end -->"
)
$notes = "$($notes.Trim())`n`n$($provenanceLines -join "`n")"
$notes = "$($notes.Trim())`n`n$($downloadLines -join "`n")`n`n$($provenanceLines -join "`n")"
$notesFile = Join-Path $env:RUNNER_TEMP "final-release-notes.md"
$notes | Set-Content -Encoding UTF8 -LiteralPath $notesFile
@@ -254,6 +324,8 @@ jobs:
"release", "create", $tag,
"${{ steps.package.outputs.asset }}",
"${{ steps.package.outputs.checksum }}",
"${{ steps.framework_package.outputs.asset }}",
"${{ steps.framework_package.outputs.checksum }}",
"--title", $title,
"--notes-file", $notesFile,
"--latest"
+33 -26
View File
@@ -275,8 +275,8 @@
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="وضعیت اتصال VPN و کنترل اتصال">
<TabItem.Header>
<StackPanel>
<TextBlock Text="⚡" FontSize="13" HorizontalAlignment="Center"/>
<TextBlock Text="اتصال VPN" FontSize="11" FontWeight="Medium" TextAlignment="Center"/>
<TextBlock Text="⚡" FontSize="12" HorizontalAlignment="Center"/>
<TextBlock Text="اتصال VPN" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<views:ConnectionTabView/>
@@ -284,8 +284,8 @@
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="انتخاب برنامه‌هایی که باید از تونل عبور کنند">
<TabItem.Header>
<StackPanel>
<TextBlock Text="📱" FontSize="13" HorizontalAlignment="Center"/>
<TextBlock Text="برنامه‌ها" FontSize="11" FontWeight="Medium" TextAlignment="Center"/>
<TextBlock Text="📱" FontSize="12" HorizontalAlignment="Center"/>
<TextBlock Text="برنامه‌ها" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<views:AppsTabView/>
@@ -294,8 +294,8 @@
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="تنظیمات عمومی تونل و DNS">
<TabItem.Header>
<StackPanel>
<TextBlock Text="⚙" FontSize="13" HorizontalAlignment="Center"/>
<TextBlock Text="تنظیمات" FontSize="11" FontWeight="Medium" TextAlignment="Center"/>
<TextBlock Text="⚙" FontSize="12" HorizontalAlignment="Center"/>
<TextBlock Text="تنظیمات" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<views:SettingsTabView/>
@@ -305,8 +305,8 @@
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="قوانین Include و Exclude مسیرها">
<TabItem.Header>
<StackPanel>
<TextBlock Text="🧭" FontSize="13" HorizontalAlignment="Center"/>
<TextBlock Text="قوانین مسیر" FontSize="11" FontWeight="Medium" TextAlignment="Center"/>
<TextBlock Text="🧭" FontSize="12" HorizontalAlignment="Center"/>
<TextBlock Text="قوانین مسیر" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
@@ -548,8 +548,8 @@
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="نمایش ترافیک، تاریخچه و آمار اتصال">
<TabItem.Header>
<StackPanel>
<TextBlock Text="📊" FontSize="13" HorizontalAlignment="Center"/>
<TextBlock Text="ترافیک/تاریخچه" FontSize="10.5" FontWeight="Medium" TextAlignment="Center"/>
<TextBlock Text="📊" FontSize="12" HorizontalAlignment="Center"/>
<TextBlock Text="ترافیک/تاریخچه" FontSize="9.5" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
@@ -753,6 +753,17 @@
</ScrollViewer>
</TabItem>
<!-- ███ TAB 6: HELP ███ -->
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="راهنما و عیب‌یابی">
<TabItem.Header>
<StackPanel>
<TextBlock Text="❔" FontSize="12" HorizontalAlignment="Center"/>
<TextBlock Text="راهنما" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<views:HelpTabView/>
</TabItem>
</TabControl>
</Grid>
@@ -801,6 +812,18 @@
Margin="8,0,0,0"/>
</StackPanel>
<TextBlock Grid.Column="1"
Text="{Binding GitHubInstallCountText}"
Visibility="{Binding HasGitHubInstallCount, Converter={StaticResource BoolToVis}}"
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="12"
FontWeight="SemiBold"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FlowDirection="{Binding AppFlowDirection}"
TextTrimming="CharacterEllipsis"
Margin="12,0"/>
<StackPanel Grid.Column="3"
Orientation="Horizontal"
HorizontalAlignment="{Binding AppEndHorizontalAlignment}"
@@ -822,22 +845,6 @@
Margin="8,0,0,0"
VerticalAlignment="Center"
ToolTip="حمایت از پروژه"/>
<Button Content="راهنما"
Click="OnShowHelpClick"
Style="{StaticResource SecondaryButton}"
Padding="9,4"
FontSize="10"
Margin="8,0,0,0"
VerticalAlignment="Center"
ToolTip="راهنما و عیب‌یابی"/>
<Button Content="GitHub"
Command="{Binding OpenGitHubCommand}"
Style="{StaticResource SecondaryButton}"
Padding="9,4"
FontSize="10"
Margin="8,0,0,0"
VerticalAlignment="Center"
ToolTip="باز کردن صفحه GitHub پروژه TunnelX"/>
</StackPanel>
</Grid>
</Border>
+7 -7
View File
@@ -299,7 +299,7 @@
<!-- Tab header bar with bottom border -->
<Border Grid.Row="0" Background="{StaticResource SurfaceBrush}"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="0,0,0,1"
Padding="6,6,6,4">
Padding="4,4,4,3">
<TabPanel IsItemsHost="True" HorizontalAlignment="Center"/>
</Border>
<!-- Tab content -->
@@ -319,9 +319,9 @@
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
<Setter Property="Padding" Value="6,6"/>
<Setter Property="Width" Value="104"/>
<Setter Property="MinHeight" Value="50"/>
<Setter Property="Padding" Value="4,4"/>
<Setter Property="Width" Value="88"/>
<Setter Property="MinHeight" Value="44"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
@@ -341,13 +341,13 @@
<ContentPresenter Grid.Row="0" ContentSource="Header"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,0,0,5"/>
Margin="0,0,0,3"/>
<!-- Bottom indicator line -->
<Border x:Name="indicator" Grid.Row="1"
Height="3" CornerRadius="1.5"
Height="2" CornerRadius="1"
Background="Transparent"
HorizontalAlignment="Stretch"
Margin="10,0"/>
Margin="8,0"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
+1
View File
@@ -6,6 +6,7 @@ public static class AppInfo
public const string CreatorName = "MaxFan";
public const string GitHubUrl = "https://github.com/MaxiFan/TunnelX";
public const string LatestReleaseUrl = GitHubUrl + "/releases/latest";
public const string TelegramContactUrl = "https://t.me/maxifaan";
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";
@@ -1,4 +1,5 @@
using System.Net.Http;
using System.Net;
using System.Text.Json;
namespace AppTunnel.Services;
@@ -14,6 +15,8 @@ public static class GitHubReleaseChecker
{
private const string LatestReleaseApi =
"https://api.github.com/repos/MaxiFan/TunnelX/releases/latest";
private const string ReleasesApi =
"https://api.github.com/repos/MaxiFan/TunnelX/releases";
public static async Task<GitHubReleaseInfo?> GetLatestReleaseAsync(CancellationToken ct)
{
@@ -47,9 +50,78 @@ public static class GitHubReleaseChecker
return new GitHubReleaseInfo(version, tag, name, url, prerelease);
}
public static async Task<long?> GetAppDownloadCountAsync(CancellationToken ct, int proxyPort = 0)
{
if (proxyPort > 0)
{
var proxied = await TryGetAppDownloadCountAsync(ct, proxyPort);
if (proxied.HasValue)
return proxied;
}
return await TryGetAppDownloadCountAsync(ct, proxyPort: 0);
}
private static async Task<long?> TryGetAppDownloadCountAsync(CancellationToken ct, int proxyPort)
{
using var handler = new HttpClientHandler();
if (proxyPort > 0)
{
handler.Proxy = new WebProxy($"http://127.0.0.1:{proxyPort}");
handler.UseProxy = true;
}
using var http = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(10)
};
http.DefaultRequestHeaders.UserAgent.ParseAdd("TunnelX");
using var response = await http.GetAsync(ReleasesApi, ct);
if (!response.IsSuccessStatusCode)
return null;
await using var stream = await response.Content.ReadAsStreamAsync(ct);
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
if (json.RootElement.ValueKind != JsonValueKind.Array)
return null;
long total = 0;
foreach (var release in json.RootElement.EnumerateArray())
{
if (!release.TryGetProperty("assets", out var assets) || assets.ValueKind != JsonValueKind.Array)
continue;
foreach (var asset in assets.EnumerateArray())
{
var name = asset.TryGetProperty("name", out var nameElement)
? nameElement.GetString() ?? ""
: "";
if (!IsAppDownloadAsset(name))
continue;
if (asset.TryGetProperty("download_count", out var countElement) &&
countElement.TryGetInt64(out var count))
total += count;
}
}
return total;
}
public static bool TryParseVersion(string value, out Version version)
{
value = (value ?? "").Trim().TrimStart('v', 'V');
return Version.TryParse(value, out version!);
}
private static bool IsAppDownloadAsset(string name)
{
if (string.IsNullOrWhiteSpace(name))
return false;
return (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) &&
!name.EndsWith(".sha256", StringComparison.OrdinalIgnoreCase);
}
}
@@ -435,6 +435,9 @@ public sealed class LocalizationService : INotifyPropertyChanged
["حمایت و تماس"] = "Support and Contact",
["حمایت با پی‌پل"] = "Donate with PayPal",
["حمایت"] = "Donate",
["محل تبلیغات شما"] = "Your ad space",
["درخواست تبلیغ"] = "Request advertising",
["تبلیغ شما می‌تواند در معرض دید کاربران TunnelX با بیش از {0} نصب از GitHub باشد."] = "Your ad can reach TunnelX users backed by more than {0} GitHub installs.",
["کپی اطلاعات حمایت"] = "Copy donation info",
["اگر TunnelX برایتان مفید بوده، می‌توانید با PayPal یا کریپتو از توسعه آن حمایت کنید."] = "If TunnelX has been useful, you can support its development with PayPal or crypto.",
["پرداخت با PayPal"] = "Pay with PayPal",
@@ -448,6 +451,7 @@ public sealed class LocalizationService : INotifyPropertyChanged
["تنظیمات این کانفیگ بعد از ذخیره در لیست پروفایل‌ها نمایش داده می‌شود."] = "This config appears in the profile list after saving.",
["اطلاعات پروفایل"] = "Profile Info",
["نام پروفایل"] = "Profile Name",
["نام پروفایل *"] = "Profile Name *",
["مثلاً کار، تلگرام، گیمینگ..."] = "e.g. Work, Telegram, Gaming...",
["تنظیمات اتصال"] = "Connection Settings",
["کانفیگ V2Ray / Xray"] = "V2Ray / Xray Config",
@@ -532,6 +536,8 @@ public sealed class LocalizationService : INotifyPropertyChanged
["حالت انتخابی"] = "Selected Mode",
["ترافیک کل سیستم از تونل عبور خواهد کرد؛ برای وقتی مناسب است که همه برنامه‌ها باید پشت تونل باشند."] = "All system traffic will use the tunnel; useful when every app must be behind the tunnel.",
["فقط برنامه‌ها و مقصدهای انتخابی از تونل عبور می‌کنند؛ بقیه ترافیک مستقیم می‌ماند."] = "Only selected apps and destinations use the tunnel; the rest stays direct.",
["پروفایل فعال"] = "Active profile",
["پروفایل فعال: {0}"] = "Active profile: {0}",
["متصل به پراکسی"] = "Connected to proxy",
["متصل به VPN"] = "Connected to VPN",
["توقف تست"] = "Stop test",
@@ -544,6 +550,7 @@ public sealed class LocalizationService : INotifyPropertyChanged
["بررسی نسخه جدید ناموفق بود. اتصال اینترنت یا GitHub را بررسی کنید."] = "Version check failed. Check your internet connection or GitHub access.",
["نسخه جدید آماده است: {0} - برای دانلود از GitHub باز کنید."] = "New version available: {0} - open GitHub to download.",
["TunnelX به‌روز است. نسخه فعلی: {0}"] = "TunnelX is up to date. Current version: {0}",
["تعداد نصب این برنامه از گیت هاب: {0}"] = "Installations from GitHub: {0}",
["بررسی بروزرسانی به زمان مجاز نرسید."] = "Update check timed out.",
["بررسی بروزرسانی ناموفق بود: {0}"] = "Update check failed: {0}",
["آماده تست و اتصال"] = "Ready to test and connect",
+1
View File
@@ -91,6 +91,7 @@ public class ProfileService
public bool AutoConnectOnStartup { get; set; } = false;
public string? LastActiveProfileId { get; set; } = null;
public string Language { get; set; } = LocalizationService.AutoLanguage;
public long? GitHubAppDownloadCount { get; set; } = null;
}
/// <summary>
@@ -161,6 +161,8 @@ public partial class MainViewModel
_currentVpnGatewayIp = _vpnService.Status.VpnGatewayIp;
_connectionStartTime = DateTime.Now;
LastActiveProfileId = _selectedProfile?.Id;
OnPropertyChanged(nameof(ConnectedProfileName));
OnPropertyChanged(nameof(SelectedProfileSummaryText));
RaiseHealthStatusChanged();
StartTrafficRouterForCurrentStatus(resetAppCounters: true);
@@ -172,6 +174,7 @@ public partial class MainViewModel
? _vpnService.Status.SingBoxMixedPort
: _trafficRouter.Socks5Port;
_ = RefreshExitIpAsync(exitIpProxyPort);
_ = RefreshGitHubInstallCountAsync(exitIpProxyPort);
}
else
@@ -463,6 +466,7 @@ public partial class MainViewModel
? _vpnService.Status.SingBoxMixedPort
: _trafficRouter.Socks5Port;
_ = RefreshExitIpAsync(exitIpProxyPort);
_ = RefreshGitHubInstallCountAsync(exitIpProxyPort);
if (wasFullRoute)
{
_isFullRouteEnabled = _trafficRouter.SetFullRouteEnabled(true);
@@ -73,6 +73,7 @@ public partial class MainViewModel : INotifyPropertyChanged
OpenOpenVpnCommunityDownloadCommand = new RelayCommand(_ => OpenExternalLink(OpenVpnCommunityDownloadUrl));
OpenGitHubCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.GitHubUrl));
OpenDonateCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.PayPalDonateUrl));
OpenAdRequestCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.TelegramContactUrl));
CopyDonationInfoCommand = new RelayCommand(_ => CopyDonationInfoToClipboard());
CheckForUpdatesCommand = new RelayCommand(_ => _ = CheckForUpdatesAsync(false), _ => !IsCheckingForUpdates);
OpenLatestReleaseCommand = new RelayCommand(_ => OpenExternalLink(LatestReleaseUrl), _ => !string.IsNullOrWhiteSpace(LatestReleaseUrl));
@@ -380,6 +381,11 @@ public partial class MainViewModel : INotifyPropertyChanged
public string AppGitHubUrl => AppInfo.GitHubUrl;
public string AppLicenseText => AppInfo.LicenseName;
public string AppLicenseDisplayText => LocalizationService.Instance.Format("لایسنس: {0}", AppInfo.LicenseName);
public string AdPlaceholderTitleText => LocalizationService.Instance.T("محل تبلیغات شما");
public string AdRequestButtonText => LocalizationService.Instance.T("درخواست تبلیغ");
public string AdAudienceText => _githubInstallCount.HasValue
? LocalizationService.Instance.Format("تبلیغ شما می‌تواند در معرض دید کاربران TunnelX با بیش از {0} نصب از GitHub باشد.", GitHubInstallCountDisplay)
: "";
public string DonatePayPalText => LocalizationService.Instance.IsRightToLeft
? $"پی‌پل: {AppInfo.PayPalEmail}"
: $"PayPal: {AppInfo.PayPalEmail}";
@@ -438,6 +444,20 @@ public partial class MainViewModel : INotifyPropertyChanged
}
}
private long? _githubInstallCount;
private int _githubInstallCountRequestId;
public bool HasGitHubInstallCount => _githubInstallCount.HasValue;
public string GitHubInstallCountText => _githubInstallCount.HasValue
? LocalizationService.Instance.Format(
"تعداد نصب این برنامه از گیت هاب: {0}",
GitHubInstallCountDisplay)
: "";
private string GitHubInstallCountDisplay =>
(_githubInstallCount ?? 0).ToString("N0", System.Globalization.CultureInfo.InvariantCulture);
public string UpdateButtonText => IsCheckingForUpdates
? LocalizationService.Instance.T("در حال بررسی...")
: LocalizationService.Instance.T("بررسی بروزرسانی");
@@ -819,6 +839,14 @@ public partial class MainViewModel : INotifyPropertyChanged
? LocalizationService.Instance.T("متصل به پراکسی")
: LocalizationService.Instance.T("متصل به VPN");
public string ConnectedProfileName => string.IsNullOrWhiteSpace(SelectedProfileName)
? LocalizationService.Instance.T("پروفایل فعال")
: SelectedProfileName;
public string SelectedProfileSummaryText => LocalizationService.Instance.Format(
"پروفایل فعال: {0}",
ConnectedProfileName);
private string ActiveCoreName => CurrentTunnelType switch
{
TunnelType.L2tpIpsec => "L2TP",
@@ -1036,6 +1064,7 @@ public partial class MainViewModel : INotifyPropertyChanged
public ICommand OpenOpenVpnCommunityDownloadCommand { get; }
public ICommand OpenGitHubCommand { get; }
public ICommand OpenDonateCommand { get; }
public ICommand OpenAdRequestCommand { get; }
public ICommand CopyDonationInfoCommand { get; }
public ICommand CheckForUpdatesCommand { get; }
public ICommand OpenLatestReleaseCommand { get; }
@@ -1134,6 +1163,61 @@ public partial class MainViewModel : INotifyPropertyChanged
}
}
private async Task RefreshGitHubInstallCountAsync(int proxyPort)
{
var requestId = ++_githubInstallCountRequestId;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(12));
var count = await GitHubReleaseChecker.GetAppDownloadCountAsync(cts.Token, proxyPort);
if (requestId != _githubInstallCountRequestId || !IsConnected)
return;
if (count.HasValue)
{
SetGitHubInstallCount(count.Value, persist: true);
Logger.Info($"[GITHUB-STATS] App downloads={count.Value}");
}
else
{
Logger.Warning("[GITHUB-STATS] App download count unavailable");
}
}
catch (OperationCanceledException)
{
Logger.Warning("[GITHUB-STATS] App download count timed out");
}
catch (Exception ex)
{
Logger.Warning($"[GITHUB-STATS] App download count failed: {ex.Message}");
}
}
private void SetGitHubInstallCount(long? count, bool persist = false)
{
if (_githubInstallCount == count)
{
if (persist && _appSettings.GitHubAppDownloadCount != count)
{
_appSettings.GitHubAppDownloadCount = count;
_profileService.SaveAppSettings(_appSettings);
}
return;
}
_githubInstallCount = count;
if (persist)
{
_appSettings.GitHubAppDownloadCount = count;
_profileService.SaveAppSettings(_appSettings);
}
OnPropertyChanged(nameof(HasGitHubInstallCount));
OnPropertyChanged(nameof(GitHubInstallCountText));
OnPropertyChanged(nameof(AdAudienceText));
}
private void PasteConfigFromClipboard()
{
try
@@ -1347,6 +1431,8 @@ public partial class MainViewModel : INotifyPropertyChanged
_selectedProfile.Name = remark;
OnPropertyChanged(nameof(SelectedProfileName));
OnPropertyChanged(nameof(ConnectedProfileName));
OnPropertyChanged(nameof(SelectedProfileSummaryText));
}
private static string ExtractConfigRemark(string config)
@@ -1395,8 +1481,12 @@ public partial class MainViewModel : INotifyPropertyChanged
LocalizationService.Instance.Initialize(_appSettings.Language);
_startWithWindows = _appSettings.StartWithWindows;
_autoConnectOnStartup = _appSettings.AutoConnectOnStartup;
_githubInstallCount = _appSettings.GitHubAppDownloadCount;
OnPropertyChanged(nameof(StartWithWindows));
OnPropertyChanged(nameof(AutoConnectOnStartup));
OnPropertyChanged(nameof(HasGitHubInstallCount));
OnPropertyChanged(nameof(GitHubInstallCountText));
OnPropertyChanged(nameof(AdAudienceText));
OnPropertyChanged(nameof(LanguageToggleText));
OnPropertyChanged(nameof(AppIsRightToLeft));
OnPropertyChanged(nameof(AppFlowDirection));
@@ -1429,8 +1519,12 @@ public partial class MainViewModel : INotifyPropertyChanged
OnPropertyChanged(nameof(DonatePayPalText));
OnPropertyChanged(nameof(CryptoDonationText));
OnPropertyChanged(nameof(AppCreatorText));
OnPropertyChanged(nameof(AdPlaceholderTitleText));
OnPropertyChanged(nameof(AdRequestButtonText));
OnPropertyChanged(nameof(AdAudienceText));
OnPropertyChanged(nameof(UpdateButtonText));
OnPropertyChanged(nameof(UpdateStatusText));
OnPropertyChanged(nameof(GitHubInstallCountText));
OnPropertyChanged(nameof(ConnectButtonText));
OnPropertyChanged(nameof(ConnectButtonToolTip));
OnPropertyChanged(nameof(StatusText));
@@ -1452,6 +1546,8 @@ public partial class MainViewModel : INotifyPropertyChanged
OnPropertyChanged(nameof(HealthIpv6Text));
OnPropertyChanged(nameof(HealthRoutesText));
OnPropertyChanged(nameof(ConnectedBadgeText));
OnPropertyChanged(nameof(ConnectedProfileName));
OnPropertyChanged(nameof(SelectedProfileSummaryText));
OnPropertyChanged(nameof(PingButtonText));
OnPropertyChanged(nameof(ConnectedServerPingButtonText));
OnPropertyChanged(nameof(ServerPingButtonText));
@@ -24,6 +24,8 @@ public partial class MainViewModel
_selectedProfile = value;
OnPropertyChanged();
OnPropertyChanged(nameof(SelectedProfileName));
OnPropertyChanged(nameof(ConnectedProfileName));
OnPropertyChanged(nameof(SelectedProfileSummaryText));
RaiseProfileCardChanged();
if (value != null)
LoadProfileIntoUi(value);
@@ -86,6 +88,8 @@ public partial class MainViewModel
_selectedProfile = Profiles[0];
OnPropertyChanged(nameof(SelectedProfile));
OnPropertyChanged(nameof(SelectedProfileName));
OnPropertyChanged(nameof(ConnectedProfileName));
OnPropertyChanged(nameof(SelectedProfileSummaryText));
LoadProfileIntoUi(Profiles[0]);
}
@@ -253,7 +257,7 @@ public partial class MainViewModel
SaveCurrentProfileState();
var profile = new ConnectionProfile
{
Name = LocalizationService.Instance.Format("پروفایل {0}", Profiles.Count + 1),
Name = "",
MixedProxyPort = MixedProxyPort,
AutoTuneMtu = AutoTuneMtu,
EnableDnsOptimization = IsDnsOptimizationEnabled,
@@ -379,6 +383,8 @@ public partial class MainViewModel
private void RaiseProfileCardChanged()
{
OnPropertyChanged(nameof(ProfileCountText));
OnPropertyChanged(nameof(ConnectedProfileName));
OnPropertyChanged(nameof(SelectedProfileSummaryText));
OnPropertyChanged(nameof(ActiveProfileTypeText));
OnPropertyChanged(nameof(ActiveProfileEndpointText));
OnPropertyChanged(nameof(ProfileSaveHintText));
+50 -6
View File
@@ -252,12 +252,11 @@
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock FontSize="10"
Text="{Binding SelectedProfileSummaryText}"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,4,0,0"
TextTrimming="CharacterEllipsis">
<Run Text="پروفایل فعال: "/>
<Run Text="{Binding SelectedProfile.Name}" FontWeight="SemiBold"/>
</TextBlock>
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<Button Grid.Column="2"
@@ -671,10 +670,13 @@
Foreground="{StaticResource SuccessBrush}"
HorizontalAlignment="Center"
Margin="0,2,0,0"/>
<TextBlock Text="{Binding SelectedProfile.Name}" FontSize="9"
<TextBlock Text="{Binding ConnectedProfileName}" FontSize="10"
FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"
HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
TextAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxWidth="120"/>
</StackPanel>
</Border>
@@ -909,6 +911,48 @@
</Button.Style>
</Button>
<Border Background="#24E8803A"
BorderBrush="#66E8803A"
BorderThickness="1"
CornerRadius="20"
Padding="24,18"
Margin="0,16,0,0"
MinWidth="560"
MaxWidth="760"
HorizontalAlignment="Stretch">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
FlowDirection="{Binding AppFlowDirection}">
<TextBlock Text="{Binding AdPlaceholderTitleText}"
FontSize="20"
FontWeight="Bold"
Foreground="{StaticResource TextPrimaryBrush}"
HorizontalAlignment="Center"
TextAlignment="Center"/>
<TextBlock Text="{Binding AdAudienceText}"
Visibility="{Binding HasGitHubInstallCount, Converter={StaticResource BoolToVis}}"
FontSize="12"
FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"
HorizontalAlignment="Center"
TextAlignment="Center"
TextWrapping="Wrap"
MaxWidth="560"
Margin="0,6,0,0"/>
<Button
Content="{Binding AdRequestButtonText}"
Command="{Binding OpenAdRequestCommand}"
Style="{StaticResource PrimaryButton}"
Padding="18,10"
FontSize="13"
MinWidth="130"
HorizontalAlignment="Center"
Margin="0,12,0,0"
VerticalAlignment="Center"
ToolTip="{Binding AdRequestButtonText}"/>
</StackPanel>
</Border>
</StackPanel>
<!-- End Connected State -->
+13 -3
View File
@@ -73,10 +73,20 @@
<Border Style="{StaticResource CardPanel}" Padding="12" Margin="0,0,0,8">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="اطلاعات پروفایل"/>
<TextBlock Style="{StaticResource FieldLabel}" Text="نام پروفایل"/>
<TextBox Style="{StaticResource ModernTextBox}"
<TextBlock Style="{StaticResource FieldLabel}" Text="نام پروفایل *"/>
<TextBox x:Name="ProfileNameField"
Style="{StaticResource ModernTextBox}"
Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Tag="مثلاً کار، تلگرام، گیمینگ..."/>
Tag="مثلاً کار، تلگرام، گیمینگ..."
TextChanged="OnProfileNameTextChanged"/>
<TextBlock x:Name="ProfileNameValidationText"
Text="نام پروفایل را وارد کنید"
Visibility="Collapsed"
FontSize="10"
FontWeight="SemiBold"
Foreground="{StaticResource WarningBrush}"
Margin="0,4,0,0"
TextWrapping="Wrap"/>
<TextBlock Style="{StaticResource FieldLabel}" Text="نوع اتصال" Margin="0,8,0,3"/>
<ComboBox Style="{StaticResource DarkComboBox}"
@@ -79,6 +79,13 @@ public partial class ProfileEditorDialog : Window
private void OnSaveClick(object sender, RoutedEventArgs e)
{
_profile.Name = (_profile.Name ?? "").Trim();
if (string.IsNullOrWhiteSpace(_profile.Name))
{
ShowProfileNameError();
return;
}
_profile.Password = L2tpPasswordField.Password;
_profile.PreSharedKey = PskField.Password;
_profile.OpenVpnPassword = OpenVpnPasswordField.Password;
@@ -125,6 +132,30 @@ public partial class ProfileEditorDialog : Window
return true;
}
private void OnProfileNameTextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
{
if (!string.IsNullOrWhiteSpace(ProfileNameField.Text))
ClearProfileNameError();
}
private void ShowProfileNameError()
{
var warningBrush = TryFindResource("WarningBrush") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Orange;
ProfileNameField.BorderBrush = warningBrush;
ProfileNameField.BorderThickness = new Thickness(2);
ProfileNameValidationText.Text = LocalizationService.Instance.T("نام پروفایل را وارد کنید");
ProfileNameValidationText.Visibility = Visibility.Visible;
ValidationText.Text = "";
ProfileNameField.Focus();
}
private void ClearProfileNameError()
{
ProfileNameField.ClearValue(BorderBrushProperty);
ProfileNameField.ClearValue(BorderThicknessProperty);
ProfileNameValidationText.Visibility = Visibility.Collapsed;
}
private void OnCancelClick(object sender, RoutedEventArgs e)
{
DialogResult = false;
+2
View File
@@ -136,6 +136,8 @@ dotnet publish AppTunnel\AppTunnel.csproj -c Release -r win-x64 --self-contained
خدمات پولی می‌تواند به صورت جداگانه برای پشتیبانی خصوصی، راه‌اندازی، بیلد اختصاصی، سفارشی‌سازی برای شرکت‌ها، یا توسعه برنامه‌ای مشابه ارائه شود. این خدمات پولی حقوقی را که مجوز <span dir="ltr">GPL</span> به کاربران می‌دهد محدود نمی‌کند.
پذیرش تبلیغات ثابت داخل <span dir="ltr">TunnelX</span> امکان‌پذیر است. تبلیغات به‌صورت مستقیم با نگهدارنده هماهنگ می‌شود، از طریق شبکه‌های تبلیغاتی یا سایت‌های واسط نمایش داده نمی‌شود و با هدف ساده، ثابت و امن ماندن تجربه کاربر انجام می‌شود.
گزینه‌های حمایت مالی از طریق <span dir="ltr">GitHub Sponsors/Funding</span> یا فایل <span dir="ltr">`docs/DONATE.md`</span> در دسترس هستند.
## نکته ایمنی و سلب مسئولیت
+2
View File
@@ -146,6 +146,8 @@ For direct contact, support requests, private customization, or paid development
Paid services may be available separately for private support, deployment help, custom builds, company-specific customization, or development of a similar application. These paid services do not limit the rights granted by the GPL license.
Fixed advertising placements may be available inside TunnelX. Advertising is handled directly with the maintainer, is not served through third-party ad networks or intermediary websites, and is intended to stay simple, static, and safe for users.
Use the GitHub funding button or see `docs/DONATE.md` for donation options.
## Safety Notice