23 Commits

Author SHA1 Message Date
github-actions[bot] cfaf2ae481 Prepare release v1.2.32 2026-05-18 11:20:03 +00:00
MaxFan ae61021c7c Fix REALITY configs and localize log controls
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:47:57 +03:30
github-actions[bot] 3145b9a01e Prepare release v1.2.31 2026-05-18 10:46:28 +00:00
MaxFan 04d8d52ba9 Update release packaging and connection UX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:14:47 +03:30
MaxFan bae98cf9e9 Update README contact and localization notes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 13:17:46 +03:30
github-actions[bot] 2ca962ddd6 Prepare release v1.2.30 2026-05-18 09:39:38 +00:00
MaxFan 0bc8992813 Prepare bilingual UI and reliability update
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 12:59:33 +03:30
MaxFan b713074ee2 Prepare release v1.2.29
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:46:16 +03:30
MaxFan d9a2a3b7d2 Prepare release v1.2.28
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:34:10 +03:30
MaxFan dfe623dd99 Fix release workflow for prepared versions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:26:05 +03:30
MaxFan 8283b9d6d1 Prepare release v1.2.27
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:20:44 +03:30
MaxFan a686edd027 Fix OpenVPN reconnect routing
Update the router when OpenVPN reconnects with a new runtime endpoint, and keep the release notes and README status in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 15:53:14 +03:30
github-actions[bot] 83ea7560f4 Prepare release v1.2.26 2026-05-17 12:02:03 +00:00
MaxFan 4906a1e123 Prepare OpenVPN notes for release workflow
Keeps the project version at 1.2.25 so the release workflow can publish v1.2.26 and moves the OpenVPN release notes back under Unreleased.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 15:31:01 +03:30
MaxiFan 3b9a917645 Merge pull request #17 from MaxiFan/feature/openvpn-external
Add OpenVPN split-tunnel support
2026-05-17 15:27:44 +03:30
MaxFan d877fe2c70 Merge main into OpenVPN feature branch
Resolves version and changelog conflicts while keeping the OpenVPN release as 1.2.26 on top of the existing 1.2.25 changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 15:24:59 +03:30
MaxFan b311473df4 Add OpenVPN split-tunnel support
Adds external OpenVPN Community integration with split-compatible routing, safer connection readiness checks, profile persistence, UI guidance, and release documentation for the new version.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 15:20:37 +03:30
github-actions[bot] a4e90aa2d7 Prepare release v1.2.25 2026-05-16 10:09:00 +00:00
MaxiFan 559674a44c Merge pull request #13 from BlacKSnowDot0/pr-clean
پشتیبانی از پروکسی SOCKS5/HTTP از طریق sing-box outbound
2026-05-15 17:26:52 +03:30
MaxiFan 8696da6494 Merge pull request #16 from mohammad-parvizi-dev/main
Add startup and auto-connect app settings
2026-05-15 17:09:24 +03:30
MaxFan 2d866e9cba Improve tab headers, theme styling, and tray notifications
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 16:59:12 +03:30
mohamad parvizi aa345b1680 Add startup and auto-connect app settings
Persist global app settings and expose startup/auto-connect controls. Adds AppSettings (StartWithWindows, AutoConnectOnStartup, LastActiveProfileId) plus LoadAppSettings/SaveAppSettings to ProfileService (appsettings.json). MainViewModel now loads/saves these settings, exposes StartWithWindows/AutoConnectOnStartup/LastActiveProfileId properties, restores last profile and triggers auto-connect on startup when enabled, and updates the HKCU Run registry entry to enable/disable startup (with a user warning message on enable). Adds corresponding UI controls to SettingsTabView and includes defensive error handling and sensible defaults.
2026-05-14 13:38:46 +03:30
BlacKSnowDot0 605eb20d23 feat(proxy): SOCKS5/HTTP via V2Ray/sing-box, add MixedProxyServer, remove standalone proxy types and local auth 2026-05-14 11:12:45 +03:30
70 changed files with 8088 additions and 1214 deletions
+13
View File
@@ -0,0 +1,13 @@
---
description: WPF localization and direction rules for TunnelX
alwaysApply: true
---
# TunnelX Localization Rules
- Treat Persian XAML literals as source keys. Every visible `Text`, `Content`, `Header`, `ToolTip`, `Tag`, and `Run.Text` literal must have an English entry in `LocalizationService.EnglishTranslations()`.
- Prefer bindings for dynamic UI text. Expose localization-aware ViewModel properties for stateful text, and raise `PropertyChanged` for them from `OnLanguageChanged()`.
- For DataTemplate, lazy-loaded, and virtualized controls, do not rely on one-time traversal only. Ensure controls can be re-localized on `Loaded` and after language changes.
- Preserve the original source string. If a control is first seen while English is active, recover the Persian source key from the reverse translation table instead of storing the English value as the original.
- Bind layout direction to `AppFlowDirection` or `LocalizationService.FlowDirection`. Keep technical fields (`IP`, ports, paths, config, logs, crypto addresses) explicitly `LeftToRight` and left-aligned.
- After adding UI, test both language directions by toggling Persian -> English -> Persian once and checking newly loaded tabs/templates.
+90 -2
View File
@@ -81,7 +81,7 @@ jobs:
}
}
if ($nextVersion -le $currentVersion) {
if ($nextVersion -lt $currentVersion -or (-not $requestedVersion -and $nextVersion -le $currentVersion)) {
throw "Release version ($nextVersion) must be greater than current project version ($currentVersion)."
}
@@ -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
@@ -120,6 +122,11 @@ jobs:
throw "CHANGELOG.md must contain a '## Unreleased' section."
}
$existingReleaseMatch = [regex]::Match($changelog, "(?ms)^## $([regex]::Escape($version))\s+-\s+.*?\r?\n(?<notes>.*?)(?=^##\s|\z)")
if ($existingReleaseMatch.Success) {
$notes = $existingReleaseMatch.Groups["notes"].Value.Trim()
}
else {
$unreleasedMatch = [regex]::Match($changelog, '(?ms)^## Unreleased\s*(?<notes>.*?)(?=^##\s|\z)')
$notes = $unreleasedMatch.Groups["notes"].Value.Trim()
@@ -142,6 +149,7 @@ jobs:
$replacement = "## Unreleased`n`n$releaseHeading`n`n$notes`n`n"
$changelog = [regex]::Replace($changelog, '(?ms)^## Unreleased\s*(?<notes>.*?)(?=^##\s|\z)', $replacement, 1)
Set-Content -Encoding UTF8 -LiteralPath $changelogPath -Value $changelog
}
$notesFile = Join-Path $env:RUNNER_TEMP "release-notes.md"
$notes | Set-Content -Encoding UTF8 -LiteralPath $notesFile
@@ -155,7 +163,12 @@ jobs:
$tag = "${{ steps.meta.outputs.tag }}"
git add AppTunnel/AppTunnel.csproj CHANGELOG.md
if (git diff --cached --quiet) {
Write-Host "No release metadata changes to commit."
}
else {
git commit -m "Prepare release $tag"
}
$releaseSha = git rev-parse HEAD
git tag $tag
git push origin HEAD:${{ github.ref_name }}
@@ -202,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:
@@ -209,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
@@ -220,9 +292,22 @@ 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 = @(
"## دانلودها",
"",
"- پیشنهادشده برای بیشتر کاربران: ``$artifactName``. این نسخه مستقل است و نیازی به نصب جداگانه .NET ندارد.",
"- بسته کم‌حجم‌تر برای کاربرانی که Microsoft .NET 8 Desktop Runtime نسخه x64 را نصب دارند: ``$frameworkArtifactName``. فایل ZIP را استخراج کنید و ``TunnelX.exe`` را با دسترسی Administrator اجرا کنید. داخل ZIP یک ``README.txt`` هم برای توضیح همین پیش‌نیاز قرار دارد.",
"",
"## 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",
@@ -232,9 +317,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
@@ -243,6 +329,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"
+17 -2
View File
@@ -8,19 +8,28 @@
</ResourceDictionary.MergedDictionaries>
<!-- Embedded Persian Font (Vazirmatn) with fallbacks -->
<FontFamily x:Key="PersianFont">pack://application:,,,/Fonts/#Vazirmatn</FontFamily>
<FontFamily x:Key="AppFont">pack://application:,,,/Fonts/#Vazirmatn, Segoe UI, Tahoma</FontFamily>
<FontFamily x:Key="PersianFont">pack://application:,,,/AppTunnel;component/Fonts/#Vazirmatn</FontFamily>
<FontFamily x:Key="AppFont">pack://application:,,,/AppTunnel;component/Fonts/#Vazirmatn, Vazirmatn, Vazir, Segoe UI, Tahoma</FontFamily>
<!-- Global Font Settings -->
<Style TargetType="{x:Type Window}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
</Style>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
</Style>
<Style TargetType="{x:Type TextBox}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
<Style TargetType="{x:Type PasswordBox}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
<Style TargetType="{x:Type Button}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
@@ -33,6 +42,12 @@
<Style TargetType="{x:Type CheckBox}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
<Style TargetType="{x:Type ListBox}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
<Style TargetType="{x:Type ToolTip}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Background" Value="#252525"/>
+5
View File
@@ -63,6 +63,11 @@ public partial class App : Application
// registered and before any DllImport call is made.
EnsureNativeLibsExtracted();
// Load the language before the main window is created so the first
// rendered frame follows the saved setting or the system UI language.
LocalizationService.Instance.Initialize(new ProfileService().LoadAppSettings().Language);
TextInputBehavior.Register();
// Register a resolver so that DllImport("WinDivert.dll") and
// DllImport("wintun.dll") load from AppDataDir rather than relying on
// the default search order (which would only find them next to the exe).
+3 -3
View File
@@ -21,9 +21,9 @@
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
<NeutralLanguage>fa-IR</NeutralLanguage>
<!-- Version Management -->
<Version>1.2.24</Version>
<AssemblyVersion>1.2.24.0</AssemblyVersion>
<FileVersion>1.2.24.0</FileVersion>
<Version>1.2.32</Version>
<AssemblyVersion>1.2.32.0</AssemblyVersion>
<FileVersion>1.2.32.0</FileVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
+3 -8
View File
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
using AppTunnel.Services;
using Color = System.Windows.Media.Color;
using ColorConverter = System.Windows.Media.ColorConverter;
using FlowDirection = System.Windows.FlowDirection;
@@ -85,9 +86,7 @@ public class EnumToVisibilityConverter : IValueConverter
}
/// <summary>
/// تشخیص خودکار جهت متن بر اساس محتوا
/// اگر متن شامل حروف فارسی یا عربی باشد: RightToLeft
/// اگر متن شامل حروف انگلیسی باشد: LeftToRight
/// Detects text direction from content and falls back to the current app language.
/// </summary>
public class TextToFlowDirectionConverter : IValueConverter
{
@@ -95,24 +94,20 @@ public class TextToFlowDirectionConverter : IValueConverter
{
if (value is string text && !string.IsNullOrWhiteSpace(text))
{
// بررسی اولین کاراکتر معنادار
foreach (char c in text)
{
if (char.IsWhiteSpace(c) || char.IsPunctuation(c) || char.IsDigit(c))
continue;
// محدوده Unicode برای فارسی و عربی: 0x0600-0x06FF
if (c >= 0x0600 && c <= 0x06FF)
return FlowDirection.RightToLeft;
// محدوده Unicode برای حروف لاتین: A-Z, a-z
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))
return FlowDirection.LeftToRight;
}
}
// پیش‌فرض: راست به چپ (برای اپلیکیشن فارسی)
return FlowDirection.RightToLeft;
return LocalizationService.Instance.FlowDirection;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+8 -7
View File
@@ -1,5 +1,6 @@
using System.Windows;
using AppTunnel.Views;
using AppTunnel.Services;
using Application = System.Windows.Application;
namespace AppTunnel.Helpers;
@@ -19,7 +20,7 @@ public static class DialogService
/// <returns>true اگر کاربر "بله" را انتخاب کند</returns>
public static bool Confirm(string message, string title = "تاییدیه", Window? owner = null)
{
return ModernDialog.ShowConfirm(message, title, owner);
return ModernDialog.ShowConfirm(LocalizationService.Instance.T(message), LocalizationService.Instance.T(title), owner);
}
/// <summary>
@@ -27,7 +28,7 @@ public static class DialogService
/// </summary>
public static void Info(string message, string title = "اطلاعات", Window? owner = null)
{
ModernDialog.ShowInfo(message, title, owner);
ModernDialog.ShowInfo(LocalizationService.Instance.T(message), LocalizationService.Instance.T(title), owner);
}
/// <summary>
@@ -35,7 +36,7 @@ public static class DialogService
/// </summary>
public static void Success(string message, string title = "موفقیت", Window? owner = null)
{
ModernDialog.ShowSuccess(message, title, owner);
ModernDialog.ShowSuccess(LocalizationService.Instance.T(message), LocalizationService.Instance.T(title), owner);
}
/// <summary>
@@ -43,7 +44,7 @@ public static class DialogService
/// </summary>
public static void Error(string message, string title = "خطا", Window? owner = null)
{
ModernDialog.ShowError(message, title, owner);
ModernDialog.ShowError(LocalizationService.Instance.T(message), LocalizationService.Instance.T(title), owner);
}
/// <summary>
@@ -51,7 +52,7 @@ public static class DialogService
/// </summary>
public static void Warning(string message, string title = "هشدار", Window? owner = null)
{
ModernDialog.ShowWarning(message, title, owner);
ModernDialog.ShowWarning(LocalizationService.Instance.T(message), LocalizationService.Instance.T(title), owner);
}
/// <summary>
@@ -59,7 +60,7 @@ public static class DialogService
/// </summary>
public static void ShowCopied(string itemName = "متن")
{
ShowToast($"{itemName} کپی شد", "✅");
ShowToast(LocalizationService.Instance.Format("{0} کپی شد", itemName), "✅");
}
/// <summary>
@@ -69,7 +70,7 @@ public static class DialogService
{
if (Application.Current.MainWindow is MainWindow mainWindow)
{
mainWindow.ShowToast(message, icon);
mainWindow.ShowToast(LocalizationService.Instance.T(message), icon);
}
}
}
+18 -7
View File
@@ -1,6 +1,7 @@
<Window x:Class="AppTunnel.LogWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:services="clr-namespace:AppTunnel.Services"
Title="TunnelX — جزئیات عملکرد"
Width="400" Height="600"
MinWidth="350" MinHeight="400"
@@ -8,7 +9,7 @@
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
FlowDirection="RightToLeft"
FlowDirection="{Binding Source={x:Static services:LocalizationService.Instance}, Path=FlowDirection}"
MouseLeftButtonDown="OnTitleBarMouseDown">
<Border Background="{StaticResource BackgroundBrush}"
@@ -31,19 +32,26 @@
CornerRadius="12,12,0,0"
Padding="16,12"
MouseLeftButtonDown="OnTitleBarMouseDown">
<Grid>
<TextBlock Text="🔍 جزئیات عملکرد — TunnelX"
<Grid FlowDirection="{Binding Source={x:Static services:LocalizationService.Instance}, Path=FlowDirection}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="🔍 جزئیات عملکرد — TunnelX"
FontSize="14"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
VerticalAlignment="Center"/>
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<Button Content="✕"
<Button Grid.Column="1"
Content="✕"
Click="OnCloseClick"
Style="{StaticResource WindowControlButton}"
Width="32" Height="24"
FontSize="13"
HorizontalAlignment="Right"
HorizontalAlignment="{Binding Source={x:Static services:LocalizationService.Instance}, Path=EndHorizontalAlignment}"
FlowDirection="LeftToRight"/>
</Grid>
</Border>
@@ -60,6 +68,7 @@
IsReadOnly="True"
TextWrapping="Wrap"
FlowDirection="LeftToRight"
TextAlignment="Left"
VerticalScrollBarVisibility="Auto"/>
</Border>
@@ -67,7 +76,9 @@
<Border Grid.Row="2" Background="{StaticResource SurfaceBrush}"
CornerRadius="0,0,12,12"
Padding="12,10">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="{Binding Source={x:Static services:LocalizationService.Instance}, Path=EndHorizontalAlignment}"
FlowDirection="{Binding Source={x:Static services:LocalizationService.Instance}, Path=FlowDirection}">
<Button Content="🗑 پاک کردن"
Click="OnClearClick"
Style="{StaticResource SecondaryButton}"
+21 -3
View File
@@ -11,9 +11,20 @@ public partial class LogWindow : Window
public LogWindow()
{
InitializeComponent();
Loaded += (_, _) => LocalizationService.Instance.ApplyTo(this);
LocalizationService.Instance.LanguageChanged += OnLanguageChanged;
LoadLogs();
Logger.LogAdded += OnLogAdded;
Closed += (_, _) => Logger.LogAdded -= OnLogAdded;
Closed += (_, _) =>
{
Logger.LogAdded -= OnLogAdded;
LocalizationService.Instance.LanguageChanged -= OnLanguageChanged;
};
}
private void OnLanguageChanged(object? sender, EventArgs e)
{
LocalizationService.Instance.ApplyTo(this);
}
private void LoadLogs()
@@ -46,19 +57,26 @@ public partial class LogWindow : Window
{
Logger.Clear();
LogTextBox.Clear();
DialogService.Success(LocalizationService.Instance.T("لاگ‌ها پاک شدند"));
}
private void OnCopyClick(object sender, RoutedEventArgs e)
{
try
{
if (string.IsNullOrWhiteSpace(LogTextBox.Text))
{
DialogService.Info(LocalizationService.Instance.T("لاگی برای کپی وجود ندارد"), "TunnelX");
return;
}
Clipboard.SetText(LogTextBox.Text);
DialogService.ShowCopied("لاگ‌ها");
DialogService.ShowCopied(LocalizationService.Instance.T("لاگ‌ها"));
}
catch (Exception ex)
{
Logger.Error($"Failed to copy logs: {ex}");
DialogService.Error($"خطا در کپی کردن:\n{ex.Message}", "خطا");
DialogService.Error(LocalizationService.Instance.Format("خطا در کپی کردن:\n{0}", ex.Message), "خطا");
}
}
}
+364 -226
View File
@@ -6,13 +6,14 @@
xmlns:vm="clr-namespace:AppTunnel.ViewModels"
xmlns:model="clr-namespace:AppTunnel.Models"
Title="TunnelX — Split Traffic Per App"
Width="580" Height="760"
MinWidth="540" MinHeight="680"
Width="620" Height="740"
MinWidth="560" MinHeight="620"
ResizeMode="NoResize"
WindowStartupLocation="CenterScreen"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
FlowDirection="RightToLeft"
FlowDirection="{Binding AppFlowDirection}"
MouseLeftButtonDown="OnTitleBarMouseDown">
<Window.Resources>
@@ -43,7 +44,7 @@
</Grid.ColumnDefinitions>
<!-- ═══ MAIN CONTENT ═══ -->
<Grid Grid.Column="0" FlowDirection="RightToLeft">
<Grid Grid.Column="0" FlowDirection="{Binding AppFlowDirection}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
@@ -51,49 +52,87 @@
</Grid.RowDefinitions>
<!-- ═══ CUSTOM TITLE BAR ═══ -->
<Border Grid.Row="0" Background="{StaticResource SurfaceBrush}"
CornerRadius="12,0,0,0" Padding="16,10">
<Border x:Name="MainTitleBar"
Grid.Row="0" Background="{StaticResource SurfaceBrush}"
BorderBrush="#12FFFFFF"
BorderThickness="0,0,0,1"
CornerRadius="12,0,0,0" Padding="14,8">
<Grid FlowDirection="LeftToRight">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Logo & Title -->
<StackPanel Grid.Column="0" Orientation="Horizontal">
<!-- Tx Icon SVG -->
<Viewbox Width="28" Height="28" Margin="0,0,10,0">
<Canvas Width="48" Height="48">
<Rectangle Width="48" Height="48" RadiusX="8" RadiusY="8" Fill="{StaticResource PrimaryBrush}"/>
<TextBlock Text="Tx" FontSize="24" FontWeight="Bold"
Foreground="White" Canvas.Left="6" Canvas.Top="8"
FontFamily="Segoe UI"/>
</Canvas>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Viewbox Width="30" Height="30" Margin="0,0,10,0">
<Grid Width="48" Height="48">
<Border CornerRadius="7"
Background="{StaticResource PrimaryBrush}">
<Border.Effect>
<DropShadowEffect Color="#C2410C"
BlurRadius="8"
Opacity="0.22"
ShadowDepth="0"/>
</Border.Effect>
</Border>
<TextBlock Text="Tx"
FontSize="24"
FontWeight="Bold"
Foreground="White"
FontFamily="Segoe UI"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,-1,0,0"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType"/>
</Grid>
</Viewbox>
<StackPanel VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Tunnel" FontSize="17" FontWeight="Bold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="X" FontSize="17" FontWeight="Bold"
Foreground="{StaticResource AccentBrush}"/>
</StackPanel>
<TextBlock Text="Split Traffic Per App" FontSize="9"
Foreground="{StaticResource TextSecondaryBrush}"/>
<StackPanel VerticalAlignment="Bottom" Margin="0,0,0,1">
<Grid HorizontalAlignment="Left">
<TextBlock Text="{Binding AppTitleText}"
FontSize="17"
FontWeight="Bold"
Foreground="{StaticResource TextPrimaryBrush}"
FlowDirection="{Binding AppTitleFlowDirection}"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType"/>
<TextBlock Text="{Binding AppTitleAccentText}"
FontSize="17"
FontWeight="Bold"
Foreground="{StaticResource AccentBrush}"
FlowDirection="{Binding AppTitleFlowDirection}"
HorizontalAlignment="{Binding AppTitleAccentAlignment}"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType"/>
</Grid>
<TextBlock Text="تفکیک ترافیک برنامه ها" FontSize="9"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,-3,0,0"/>
</StackPanel>
</StackPanel>
<!-- Connection Status + Compact Health Chips -->
<StackPanel Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center">
<Border HorizontalAlignment="Center"
CornerRadius="12" Padding="12,5" Background="#18FFFFFF">
CornerRadius="12"
Padding="12,5"
Background="#121212"
BorderBrush="#20FFFFFF"
BorderThickness="1">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center" Margin="0,0,8,0"
Fill="{Binding StatusColor, Converter={StaticResource StringToColor}}"/>
<TextBlock Text="{Binding StatusText}"
Foreground="{StaticResource TextPrimaryBrush}"
FontSize="12" FontWeight="SemiBold" VerticalAlignment="Center"/>
FontSize="12"
FontWeight="SemiBold"
VerticalAlignment="Center"
MaxWidth="210"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,5,0,0"
@@ -113,15 +152,77 @@
</StackPanel>
</StackPanel>
<!-- Details Button -->
<Button Grid.Column="2" Content="🔍 جزئیات"
Click="OnShowLogClick"
Background="#11FFFFFF"
Foreground="{StaticResource TextSecondaryBrush}"
<!-- Update Button: visible only when a newer release exists -->
<Button Grid.Column="2"
Content="بروزرسانی"
Command="{Binding OpenLatestReleaseCommand}"
Visibility="{Binding IsUpdateAvailable, Converter={StaticResource BoolToVis}}"
Background="{StaticResource PrimaryBrush}"
Foreground="White"
BorderBrush="Transparent"
BorderThickness="0"
Padding="10,6"
Margin="0,0,8,0"
FontSize="11"
FontWeight="SemiBold"
Cursor="Hand"
VerticalAlignment="Center"
RenderTransformOrigin="0.5,0.5"
ToolTip="دانلود نسخه جدید از صفحه Releases در GitHub">
<Button.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1"/>
</Button.RenderTransform>
<Button.Style>
<Style TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="9"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding IsUpdateAvailable}" Value="True">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever" AutoReverse="True">
<DoubleAnimation Storyboard.TargetProperty="Opacity"
To="0.72"
Duration="0:0:0.9"/>
<DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleX)"
To="1.04"
Duration="0:0:0.9"/>
<DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleY)"
To="1.04"
Duration="0:0:0.9"/>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
</DataTrigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="0.92"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<!-- Details Button -->
<Button Grid.Column="3" Content="جزئیات"
Click="OnShowLogClick"
Background="#121212"
Foreground="{StaticResource TextSecondaryBrush}"
BorderBrush="#20FFFFFF"
BorderThickness="1"
Padding="11,6"
Margin="0,0,8,0"
FontSize="11"
Cursor="Hand"
VerticalAlignment="Center">
<Button.Style>
@@ -130,7 +231,9 @@
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="6" Padding="{TemplateBinding Padding}">
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
@@ -146,7 +249,7 @@
</Button>
<!-- Window Controls: Minimize → Close (standard Windows order) -->
<StackPanel Grid.Column="3" Orientation="Horizontal">
<StackPanel Grid.Column="4" Orientation="Horizontal">
<Button Content="—" Click="OnMinimizeClick"
ToolTip="کوچک کردن به System Tray"
Style="{StaticResource WindowControlButton}"
@@ -169,79 +272,97 @@
<TabControl Grid.Row="0" Style="{StaticResource ModernTabControl}" Margin="8,6,8,6">
<!-- ███ TAB 1: CONNECTION ███ -->
<TabItem Style="{StaticResource ModernTabItem}">
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="وضعیت اتصال VPN و کنترل اتصال">
<TabItem.Header>
<TextBlock Text="⚡ اتصال"/>
<StackPanel>
<TextBlock Text="⚡" FontSize="12" HorizontalAlignment="Center"/>
<TextBlock Text="اتصال VPN" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<views:ConnectionTabView/>
</TabItem>
<TabItem Style="{StaticResource ModernTabItem}">
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="انتخاب برنامه‌هایی که باید از تونل عبور کنند">
<TabItem.Header>
<TextBlock Text="📱 برنامه‌ها"/>
<StackPanel>
<TextBlock Text="📱" FontSize="12" HorizontalAlignment="Center"/>
<TextBlock Text="برنامه‌ها" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<views:AppsTabView/>
</TabItem>
<TabItem Style="{StaticResource ModernTabItem}">
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="تنظیمات عمومی تونل و DNS">
<TabItem.Header>
<TextBlock Text="⚙ تنظیمات"/>
<StackPanel>
<TextBlock Text="⚙" FontSize="12" HorizontalAlignment="Center"/>
<TextBlock Text="تنظیمات" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<views:SettingsTabView/>
</TabItem>
<!-- ███ TAB 3: ROUTING RULES ███ -->
<TabItem Style="{StaticResource ModernTabItem}">
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="قوانین Include و Exclude مسیرها">
<TabItem.Header>
<TextBlock Text="🧭 قوانین مسیر"/>
<StackPanel>
<TextBlock Text="🧭" FontSize="12" HorizontalAlignment="Center"/>
<TextBlock Text="قوانین مسیر" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,12,0,0">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,10,0,0">
<StackPanel HorizontalAlignment="Stretch" Margin="16,0">
<Border Background="#12FFFFFF" CornerRadius="8" Padding="12,8" Margin="0,0,0,12">
<TextBlock Text="استثناها مقصدها را مستقیم نگه می‌دارند؛ لزومی‌ها مقصدها را حتی بدون انتخاب برنامه از تونل عبور می‌دهند."
<Border Background="#121212" BorderBrush="#18FFFFFF" BorderThickness="1"
CornerRadius="10" Padding="12,8" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="قوانین مسیر"
FontSize="13"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="مقصدهای مستقیم و مقصدهای اجباری تونل را اینجا مدیریت کنید."
TextWrapping="Wrap"
FontSize="11"
FontSize="10"
Margin="0,2,0,0"
Foreground="{StaticResource TextSecondaryBrush}"/>
</StackPanel>
</Border>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<!-- Info Card -->
<Border Style="{StaticResource CardPanel}" Margin="0,0,0,12">
<Border Style="{StaticResource CardPanel}" Margin="0,0,0,8" BorderBrush="#2247A3F3">
<StackPanel>
<TextBlock Text="🚫 لیست استثنا (Exclude List)" FontSize="16" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}" Margin="0,0,0,6"/>
<TextBlock TextWrapping="Wrap" Foreground="{StaticResource TextSecondaryBrush}" FontSize="12"
Text="دامنه یا آی‌پی‌هایی که نمی‌خواهید از تونل عبور کنند را اینجا وارد کنید. حتی اگر برنامه‌های هدف بخواهند به این آدرس‌ها وصل شوند، ترافیک مستقیم (بدون VPN) ارسال می‌شود."/>
<TextBlock Text="🚫 مستقیم بماند" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}" Margin="0,0,0,4"/>
<TextBlock TextWrapping="Wrap" Foreground="{StaticResource TextSecondaryBrush}" FontSize="10"
Text="این مقصدها از تونل عبور نمی‌کنند."/>
</StackPanel>
</Border>
<!-- Tip for Excludes -->
<Border Background="#1547A3F3" CornerRadius="8" Padding="12,8" Margin="0,0,0,12">
<Border Background="#1247A3F3" BorderBrush="#2647A3F3" BorderThickness="1"
CornerRadius="9" Padding="10,7" Margin="0,0,0,8">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="💡" FontSize="12" Margin="0,0,6,0"/>
<TextBlock Text="کاربرد استثنا" FontSize="11" FontWeight="SemiBold"
<StackPanel Orientation="Horizontal" Margin="0,0,0,3">
<TextBlock Text="💡" FontSize="11" Margin="0,0,5,0"/>
<TextBlock Text="نمونه کاربرد" FontSize="10" FontWeight="SemiBold"
Foreground="#47A3F3"/>
</StackPanel>
<TextBlock TextWrapping="Wrap" FontSize="10" LineHeight="16"
Foreground="{StaticResource TextSecondaryBrush}">
• سایت‌های ایرانی را استثنا کنید تا مستقیم وصل شوند (سرعت بهتر)
• سرورهای بازی داخلی را استثنا کنید تا پینگ کم شود
• آی‌پی سرور SSH/FTP داخلی را اضافه کنید
</TextBlock>
<TextBlock Text="سایت‌های داخلی یا سرورهای بازی را مستقیم نگه دارید."
TextWrapping="Wrap" FontSize="10" LineHeight="15"
Foreground="{StaticResource TextSecondaryBrush}"/>
</StackPanel>
</Border>
<!-- Add Entry -->
<Border Style="{StaticResource CardPanel}" Margin="0,0,0,12">
<Border Style="{StaticResource CardPanel}" Margin="0,0,0,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -251,25 +372,25 @@
Text="{Binding ExcludeInput, UpdateSourceTrigger=PropertyChanged}"
Tag="دامنه یا آی‌پی (مثلاً google.com یا 1.2.3.4)"
FlowDirection="LeftToRight"
FontSize="13" Padding="10,8"
Margin="0,0,8,0">
FontSize="12" Padding="9,6"
Margin="0,0,7,0">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding AddExcludeCommand}"/>
</TextBox.InputBindings>
</TextBox>
<Button Grid.Column="1" Style="{StaticResource PrimaryButton}"
Content=" افزودن"
Content="افزودن"
Command="{Binding AddExcludeCommand}"
FontSize="12" Padding="16,8"/>
FontSize="11" Padding="12,6"/>
</Grid>
</Border>
<!-- Exclude List -->
<Border Style="{StaticResource CardPanel}">
<Border Style="{StaticResource CardPanel}" BorderBrush="#2247A3F3">
<DockPanel>
<TextBlock DockPanel.Dock="Top" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}" Margin="0,0,0,8"
Text="آدرس‌های استثنا شده:"/>
<TextBlock DockPanel.Dock="Top" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}" Margin="0,0,0,6"
Text="آدرس‌های مستقیم"/>
<ListView ItemsSource="{Binding ExcludedDestinations}"
Background="Transparent"
BorderThickness="0"
@@ -279,8 +400,8 @@
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListView.ItemTemplate>
<DataTemplate>
<Border Background="#11FFFFFF" CornerRadius="6"
Padding="10,6" Margin="0,2" Cursor="Arrow">
<Border Background="#11FFFFFF" CornerRadius="7"
Padding="9,5" Margin="0,2" Cursor="Arrow">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@@ -291,10 +412,10 @@
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Grid.Column="1" Text="{Binding}"
Foreground="{StaticResource TextPrimaryBrush}"
FontSize="13" VerticalAlignment="Center"
FontSize="12" VerticalAlignment="Center"
FlowDirection="LeftToRight"/>
<Button Grid.Column="2" Style="{StaticResource DangerButton}"
Content="" FontSize="14"
Content="حذف" FontSize="10" Padding="7,3"
Command="{Binding DataContext.RemoveExcludeCommand,
RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
@@ -327,17 +448,17 @@
<StackPanel Grid.Column="2">
<!-- Include Info -->
<Border Style="{StaticResource CardPanel}" Margin="0,0,0,12">
<Border Style="{StaticResource CardPanel}" Margin="0,0,0,8" BorderBrush="#2233C481">
<StackPanel>
<TextBlock Text="✅ لیست لزومی (Include List)" FontSize="16" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}" Margin="0,0,0,6"/>
<TextBlock TextWrapping="Wrap" Foreground="{StaticResource TextSecondaryBrush}" FontSize="12"
Text="دامنه یا آی‌پی‌هایی که حتماً باید از تونل عبور کنند را اینجا وارد کنید. حتی اگر برنامه در لیست برنامه‌های تونل نباشد، این مقصدها از VPN ارسال می‌شوند."/>
<TextBlock Text="✅ از تونل عبور کند" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}" Margin="0,0,0,4"/>
<TextBlock TextWrapping="Wrap" Foreground="{StaticResource TextSecondaryBrush}" FontSize="10"
Text="این مقصدها همیشه از تونل عبور می‌کنند."/>
</StackPanel>
</Border>
<!-- Add Include Entry -->
<Border Style="{StaticResource CardPanel}" Margin="0,0,0,12">
<Border Style="{StaticResource CardPanel}" Margin="0,0,0,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -347,25 +468,25 @@
Text="{Binding IncludeInput, UpdateSourceTrigger=PropertyChanged}"
Tag="دامنه یا آی‌پی (مثلاً example.com یا 1.2.3.4)"
FlowDirection="LeftToRight"
FontSize="13" Padding="10,8"
Margin="0,0,8,0">
FontSize="12" Padding="9,6"
Margin="0,0,7,0">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding AddIncludeCommand}"/>
</TextBox.InputBindings>
</TextBox>
<Button Grid.Column="1" Style="{StaticResource PrimaryButton}"
Content=" افزودن"
Content="افزودن"
Command="{Binding AddIncludeCommand}"
FontSize="12" Padding="16,8"/>
FontSize="11" Padding="12,6"/>
</Grid>
</Border>
<!-- Include List -->
<Border Style="{StaticResource CardPanel}">
<Border Style="{StaticResource CardPanel}" BorderBrush="#2233C481">
<DockPanel>
<TextBlock DockPanel.Dock="Top" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}" Margin="0,0,0,8"
Text="آدرس‌های لزومی:"/>
<TextBlock DockPanel.Dock="Top" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}" Margin="0,0,0,6"
Text="آدرس‌های اجباری"/>
<ListView ItemsSource="{Binding IncludedDestinations}"
Background="Transparent"
BorderThickness="0"
@@ -375,8 +496,8 @@
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListView.ItemTemplate>
<DataTemplate>
<Border Background="#11FFFFFF" CornerRadius="6"
Padding="10,6" Margin="0,2" Cursor="Arrow">
<Border Background="#11FFFFFF" CornerRadius="7"
Padding="9,5" Margin="0,2" Cursor="Arrow">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@@ -387,10 +508,10 @@
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Grid.Column="1" Text="{Binding}"
Foreground="{StaticResource TextPrimaryBrush}"
FontSize="13" VerticalAlignment="Center"
FontSize="12" VerticalAlignment="Center"
FlowDirection="LeftToRight"/>
<Button Grid.Column="2" Style="{StaticResource DangerButton}"
Content="" FontSize="14"
Content="حذف" FontSize="10" Padding="7,3"
Command="{Binding DataContext.RemoveIncludeCommand,
RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
@@ -424,16 +545,19 @@
</TabItem>
<!-- ███ TAB 5: TRAFFIC MONITOR ███ -->
<TabItem Style="{StaticResource ModernTabItem}">
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="نمایش ترافیک، تاریخچه و آمار اتصال">
<TabItem.Header>
<TextBlock Text="📊 ترافیک"/>
<StackPanel>
<TextBlock Text="📊" FontSize="12" HorizontalAlignment="Center"/>
<TextBlock Text="ترافیک/تاریخچه" FontSize="9.5" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,12,0,0">
<StackPanel HorizontalAlignment="Stretch" Margin="16,0">
<!-- Summary Card -->
<Border Style="{StaticResource CardPanel}">
<Border Style="{StaticResource CardPanel}" BorderBrush="#22E8803A">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -485,7 +609,7 @@
</Border>
<!-- Split Tunnel Health -->
<Border Style="{StaticResource CardPanel}">
<Border Style="{StaticResource CardPanel}" BorderBrush="#2233C481">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -527,12 +651,15 @@
<Expander Header="📜 تاریخچه اتصالات"
IsExpanded="False"
Foreground="{StaticResource TextPrimaryBrush}"
Background="#121212"
BorderBrush="#18FFFFFF"
Padding="10,6"
Margin="0,0,0,8">
<views:HistoryTabView/>
</Expander>
<!-- Per-App Traffic -->
<Border Style="{StaticResource CardPanel}">
<Border Style="{StaticResource CardPanel}" BorderBrush="#2247A3F3">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}"
Text="مصرف تونل به تفکیک برنامه"/>
@@ -626,123 +753,98 @@
</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>
<!-- Global Connect/Disconnect Action (sticky in page, all tabs) -->
<Grid Grid.Row="1" Margin="8,0,8,10">
<Button Command="{Binding ConnectCommand}"
MinWidth="210"
Height="42"
HorizontalAlignment="Center"
Padding="20,8"
Cursor="Hand"
ToolTip="اتصال سریع VPN">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#10B981"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderBrush" Value="#7DFFF4D0"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Content" Value="اتصال VPN"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="18"
Padding="{TemplateBinding Padding}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" FlowDirection="LeftToRight">
<Ellipse Width="8" Height="8" Fill="#E8FFFFFF" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="⏻" FontSize="13" Margin="0,0,6,0" VerticalAlignment="Center"/>
<ContentPresenter VerticalAlignment="Center"/>
</StackPanel>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#0EA371"/>
<Setter Property="BorderBrush" Value="#CCFFFFFF"/>
</Trigger>
<DataTrigger Binding="{Binding ConnectionState}" Value="{x:Static model:ConnectionState.Connected}">
<Setter Property="Content" Value="قطع VPN"/>
<Setter Property="Background" Value="#DC2626"/>
<Setter Property="BorderBrush" Value="#80FFD2D2"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="ToolTip" Value="قطع سریع اتصال VPN"/>
</DataTrigger>
<DataTrigger Binding="{Binding ConnectionState}" Value="{x:Static model:ConnectionState.Connecting}">
<Setter Property="Content" Value="لغو اتصال"/>
<Setter Property="Background" Value="#D97706"/>
<Setter Property="BorderBrush" Value="#80FFE3BF"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="ToolTip" Value="لغو تلاش اتصال"/>
</DataTrigger>
<DataTrigger Binding="{Binding ConnectionState}" Value="{x:Static model:ConnectionState.Disconnecting}">
<Setter Property="Content" Value="در حال قطع"/>
<Setter Property="Background" Value="#52525B"/>
<Setter Property="BorderBrush" Value="#7DFFFFFF"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="ToolTip" Value="در حال قطع اتصال VPN"/>
</DataTrigger>
<DataTrigger Binding="{Binding ConnectionState}" Value="{x:Static model:ConnectionState.Error}">
<Setter Property="Content" Value="اتصال مجدد"/>
<Setter Property="Background" Value="#B91C1C"/>
<Setter Property="BorderBrush" Value="#80FFD2D2"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="ToolTip" Value="اتصال مجدد VPN"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</Grid>
</Grid>
<!-- ═══ FOOTER ═══ -->
<Border Grid.Row="2" Background="{StaticResource SurfaceBrush}"
CornerRadius="0,0,0,12" Padding="16,8">
<Grid>
<Border x:Name="MainFooterBar"
Grid.Row="2"
Background="#121212"
BorderBrush="#18FFFFFF"
BorderThickness="0,1,0,0"
CornerRadius="0,0,0,12"
Padding="12,6">
<Grid VerticalAlignment="Center" FlowDirection="{Binding AppFlowDirection}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Content="❓ راهنما"
Click="OnShowHelpClick"
Style="{StaticResource SecondaryButton}"
Padding="10,5"
FontSize="10"
ToolTip="راهنما و عیب‌یابی"/>
<TextBlock Grid.Column="1"
Text="TunnelX نرم‌افزاری آزاد و رایگان برای مدیریت تونل و Split Tunneling است؛ ساخته شده توسط MaxFan."
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="10"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="12,0"/>
<StackPanel Grid.Column="2"
<StackPanel Grid.Column="0"
Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center"
FlowDirection="LeftToRight">
FlowDirection="{Binding AppFlowDirection}"
HorizontalAlignment="{Binding AppStartHorizontalAlignment}"
VerticalAlignment="Center">
<TextBlock Text="ساخته شده توسط Maxifan"
FontSize="10"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
VerticalAlignment="Center"/>
<Border Background="#18E8803A"
CornerRadius="8"
Padding="7,2"
Margin="8,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding AppVersion}"
FontSize="9"
Foreground="{StaticResource AccentBrush}"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="TunnelX"
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="10"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<Button Content="GitHub پروژه"
Command="{Binding OpenGitHubCommand}"
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}"
VerticalAlignment="Center"
FlowDirection="{Binding AppFlowDirection}">
<Button Content="{Binding LanguageToggleText}"
Command="{Binding ToggleLanguageCommand}"
Style="{StaticResource SecondaryButton}"
Padding="10,5"
Padding="9,4"
FontSize="10"
ToolTip="باز کردن صفحه GitHub پروژه TunnelX"/>
Margin="0,0,0,0"
VerticalAlignment="Center"
ToolTip="تغییر زبان"/>
<Button Content="حمایت"
Click="OnDonateClick"
Style="{StaticResource SecondaryButton}"
Padding="9,4"
FontSize="10"
Margin="8,0,0,0"
VerticalAlignment="Center"
ToolTip="حمایت از پروژه"/>
</StackPanel>
</Grid>
</Border>
@@ -751,8 +853,12 @@
<!-- ═══ LOG PANEL (RIGHT SIDE) ═══ -->
<Border x:Name="LogPanel" Grid.Column="1" Visibility="Collapsed"
Width="0"
Opacity="0"
ClipToBounds="True"
Background="{StaticResource SurfaceBrush}"
BorderBrush="{StaticResource CardBrush}" BorderThickness="1,0,0,0">
BorderBrush="{StaticResource CardBrush}" BorderThickness="1,0,0,0"
FlowDirection="{Binding AppFlowDirection}">
<Grid Width="350">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
@@ -760,21 +866,31 @@
</Grid.RowDefinitions>
<!-- Log Header -->
<Border Grid.Row="0" Padding="12,10" Background="#11FFFFFF">
<Grid>
<Border Grid.Row="0" Padding="8,6" Background="#11FFFFFF">
<Grid FlowDirection="{Binding AppFlowDirection}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="🔍 جزئیات عملکرد" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"
Text="🔍 جزئیات عملکرد" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
VerticalAlignment="Center"
Margin="0,0,0,4"
TextTrimming="CharacterEllipsis"/>
<ComboBox x:Name="LogFilterCombo"
Grid.Column="1"
Grid.Row="1"
Grid.Column="0"
SelectedIndex="0"
SelectionChanged="OnLogFilterChanged"
Margin="0,0,6,0"
MinWidth="86"
Margin="0,0,4,0"
MinWidth="74"
HorizontalAlignment="{Binding AppStartHorizontalAlignment}"
FontSize="10"
Style="{StaticResource DarkComboBox}">
<ComboBoxItem Content="همه" Tag="All"/>
<ComboBoxItem Content="خطا" Tag="Error"/>
@@ -782,16 +898,19 @@
<ComboBoxItem Content="DNS" Tag="Dns"/>
<ComboBoxItem Content="Route" Tag="Route"/>
</ComboBox>
<StackPanel Grid.Column="2" Orientation="Horizontal">
<Button Content="🗑" ToolTip="پاک کردن" Click="OnLogClearClick"
<StackPanel Grid.Row="1"
Grid.Column="1"
Orientation="Horizontal"
FlowDirection="{Binding AppFlowDirection}">
<Button Content="{Binding LogClearButtonText}" ToolTip="{Binding LogClearToolTipText}" Click="OnLogClearClick"
Style="{StaticResource SecondaryButton}"
Padding="8,4" FontSize="12" Margin="0,0,4,0"/>
<Button Content="⚠" ToolTip="کپی آخرین خطا یا هشدار" Click="OnLogCopyLastErrorClick"
Padding="6,3" FontSize="9.5" Margin="0,0,3,0"/>
<Button Content="{Binding LogCopyErrorButtonText}" ToolTip="{Binding LogCopyErrorToolTipText}" Click="OnLogCopyLastErrorClick"
Style="{StaticResource SecondaryButton}"
Padding="8,4" FontSize="12" Margin="0,0,4,0"/>
<Button Content="📋" ToolTip="کپی کردن" Click="OnLogCopyClick"
Padding="6,3" FontSize="9.5" Margin="0,0,3,0"/>
<Button Content="{Binding LogCopyAllButtonText}" ToolTip="{Binding LogCopyAllToolTipText}" Click="OnLogCopyClick"
Style="{StaticResource SecondaryButton}"
Padding="8,4" FontSize="12"/>
Padding="6,3" FontSize="9.5"/>
</StackPanel>
</Grid>
</Border>
@@ -807,6 +926,7 @@
IsReadOnly="True"
TextWrapping="Wrap"
FlowDirection="LeftToRight"
TextAlignment="Left"
VerticalScrollBarVisibility="Auto"/>
</Grid>
</Border>
@@ -837,21 +957,39 @@
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
<!-- Logo -->
<Viewbox Width="60" Height="60" HorizontalAlignment="Center" Margin="0,0,0,20">
<Canvas Width="48" Height="48">
<Rectangle Width="48" Height="48" RadiusX="8" RadiusY="8"
Fill="{StaticResource PrimaryBrush}"/>
<TextBlock Text="Tx" FontSize="24" FontWeight="Bold"
Foreground="White" Canvas.Left="6" Canvas.Top="8"
FontFamily="Segoe UI"/>
</Canvas>
<Grid Width="48" Height="48">
<Border CornerRadius="7"
Background="{StaticResource PrimaryBrush}"/>
<TextBlock Text="Tx"
FontSize="24"
FontWeight="Bold"
Foreground="White"
FontFamily="Segoe UI"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,-1,0,0"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType"/>
</Grid>
</Viewbox>
<!-- App name -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,6">
<TextBlock Text="Tunnel" FontSize="24" FontWeight="Bold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="X" FontSize="24" FontWeight="Bold"
Foreground="{StaticResource AccentBrush}"/>
</StackPanel>
<Grid HorizontalAlignment="Center" Margin="0,0,0,6">
<TextBlock Text="{Binding AppTitleText}"
FontSize="24"
FontWeight="Bold"
Foreground="{StaticResource TextPrimaryBrush}"
FlowDirection="{Binding AppTitleFlowDirection}"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType"/>
<TextBlock Text="{Binding AppTitleAccentText}"
FontSize="24"
FontWeight="Bold"
Foreground="{StaticResource AccentBrush}"
FlowDirection="{Binding AppTitleFlowDirection}"
HorizontalAlignment="{Binding AppTitleAccentAlignment}"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType"/>
</Grid>
<TextBlock Text="Split Traffic Per App" FontSize="11"
HorizontalAlignment="Center"
Foreground="{StaticResource TextSecondaryBrush}"
+227 -22
View File
@@ -1,6 +1,7 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media.Animation;
using System.Windows.Media;
using AppTunnel.Models;
@@ -19,18 +20,77 @@ public partial class MainWindow : Window
private bool _isRealExit;
private ConnectionState _lastNotifiedConnectionState = ConnectionState.Disconnected;
private bool _updateNotificationShown;
private const int WmSysCommand = 0x0112;
private const int WmGetMinMaxInfo = 0x0024;
private const int ScSize = 0xF000;
private const int ScMove = 0xF010;
private const int ScMaximize = 0xF030;
private const int ScKeyMenu = 0xF100;
private const int ScRestore = 0xF120;
public MainWindow()
{
InitializeComponent();
_viewModel = new MainViewModel();
DataContext = _viewModel;
LocalizationService.Instance.ApplyTo(this);
LocalizationService.Instance.LanguageChanged += (_, _) =>
{
Dispatcher.BeginInvoke(() =>
{
LocalizationService.Instance.ApplyTo(this);
RefreshTrayText();
});
};
InitializeTrayIcon();
Loaded += OnLoaded;
Closing += OnClosing;
}
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
ApplyAdaptiveWindowSize();
MaxWidth = Width;
MaxHeight = Height;
if (PresentationSource.FromVisual(this) is HwndSource source)
source.AddHook(WindowMessageHook);
}
private void ApplyAdaptiveWindowSize()
{
var workArea = SystemParameters.WorkArea;
const double screenMargin = 24;
Width = Math.Min(Width, Math.Max(MinWidth, workArea.Width - screenMargin));
Height = Math.Min(Height, Math.Max(MinHeight, workArea.Height - screenMargin));
Left = workArea.Left + Math.Max(0, (workArea.Width - Width) / 2);
Top = workArea.Top + Math.Max(0, (workArea.Height - Height) / 2);
}
private IntPtr WindowMessageHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WmSysCommand)
{
var command = wParam.ToInt32() & 0xFFF0;
if (command is ScMaximize or ScSize or ScKeyMenu)
{
handled = true;
return IntPtr.Zero;
}
}
else if (msg == WmGetMinMaxInfo)
{
MaxWidth = Width;
MaxHeight = Height;
}
return IntPtr.Zero;
}
private void InitializeTrayIcon()
{
var iconStream = Application.GetResourceStream(
@@ -38,7 +98,7 @@ public partial class MainWindow : Window
_trayIcon = new System.Windows.Forms.NotifyIcon
{
Text = "TunnelX — Split Traffic Per App",
Text = LocalizationService.Instance.T("TunnelX — Split Traffic Per App"),
Visible = false
};
@@ -58,7 +118,7 @@ public partial class MainWindow : Window
menu.Items.Add(new System.Windows.Forms.ToolStripSeparator());
var statusItem = new System.Windows.Forms.ToolStripMenuItem("وضعیت: آماده");
var statusItem = new System.Windows.Forms.ToolStripMenuItem($"{LocalizationService.Instance.T("وضعیت")}: {_viewModel.StatusText}");
statusItem.Enabled = false;
menu.Items.Add(statusItem);
@@ -69,7 +129,7 @@ public partial class MainWindow : Window
{
Dispatcher.BeginInvoke(() =>
{
statusItem.Text = $"وضعیت: {_viewModel.StatusText}";
statusItem.Text = $"{LocalizationService.Instance.T("وضعیت")}: {_viewModel.StatusText}";
});
}
else if (args.PropertyName == nameof(MainViewModel.ConnectionState))
@@ -120,6 +180,23 @@ public partial class MainWindow : Window
menu.Items.Add(exitItem);
_trayIcon.ContextMenuStrip = menu;
RefreshTrayText();
}
private void RefreshTrayText()
{
if (_trayIcon?.ContextMenuStrip == null) return;
_trayIcon.Text = LocalizationService.Instance.T("TunnelX — Split Traffic Per App");
var items = _trayIcon.ContextMenuStrip.Items;
if (items.Count > 0 && items[0] is System.Windows.Forms.ToolStripMenuItem showItem)
showItem.Text = LocalizationService.Instance.T("نمایش TunnelX");
if (items.Count > 2 && items[2] is System.Windows.Forms.ToolStripMenuItem statusItem)
statusItem.Text = $"{LocalizationService.Instance.T("وضعیت")}: {_viewModel.StatusText}";
if (items.Count > 3 && items[3] is System.Windows.Forms.ToolStripMenuItem updateItem)
updateItem.Text = LocalizationService.Instance.T("بررسی بروزرسانی");
if (items.Count > 5 && items[5] is System.Windows.Forms.ToolStripMenuItem exitItem)
exitItem.Text = LocalizationService.Instance.T("خروج از برنامه");
}
private void ShowFromTray()
@@ -150,8 +227,8 @@ public partial class MainWindow : Window
_trayIcon.Visible = true;
_trayIcon.ShowBalloonTip(
2000,
"TunnelX",
"برنامه در System Tray فعال است. برای نمایش دوبار کلیک کنید.",
LocalizationService.Instance.T("TunnelX در پس‌زمینه فعال است"),
LocalizationService.Instance.T("برای باز کردن پنجره، روی آیکن کنار ساعت دوبار کلیک کنید."),
System.Windows.Forms.ToolTipIcon.Info);
}
}
@@ -167,15 +244,15 @@ public partial class MainWindow : Window
switch (state)
{
case ConnectionState.Connected:
ShowTrayNotification("TunnelX متصل شد", _viewModel.StatusText,
ShowTrayNotification(LocalizationService.Instance.T("تونل فعال شد"), GetConnectedTrayMessage(),
System.Windows.Forms.ToolTipIcon.Info);
break;
case ConnectionState.Disconnected:
ShowTrayNotification("TunnelX قطع شد", _viewModel.StatusText,
ShowTrayNotification(LocalizationService.Instance.T("تونل خاموش شد"), LocalizationService.Instance.T("ارتباط امن متوقف شده و ترافیک دیگر از TunnelX عبور نمی‌کند."),
System.Windows.Forms.ToolTipIcon.Info);
break;
case ConnectionState.Error:
ShowTrayNotification("خطا در اتصال TunnelX", _viewModel.StatusText,
ShowTrayNotification(LocalizationService.Instance.T("اتصال برقرار نشد"), GetErrorTrayMessage(),
System.Windows.Forms.ToolTipIcon.Warning);
break;
}
@@ -187,11 +264,34 @@ public partial class MainWindow : Window
return;
_updateNotificationShown = true;
ShowTrayNotification("بروزرسانی TunnelX آماده است",
_viewModel.UpdateStatusText,
ShowTrayNotification(LocalizationService.Instance.T("نسخه جدید آماده است"),
LocalizationService.Instance.T("از منوی System Tray یا بخش بروزرسانی، صفحه دانلود TunnelX را باز کنید."),
System.Windows.Forms.ToolTipIcon.Info);
}
private string GetConnectedTrayMessage()
{
var profileName = _viewModel.SelectedProfileName;
if (!string.IsNullOrWhiteSpace(profileName))
return LocalizationService.Instance.Format("پروفایل «{0}» فعال است و ترافیک انتخاب‌شده از تونل عبور می‌کند.", profileName);
return LocalizationService.Instance.T("ترافیک انتخاب‌شده از TunnelX عبور می‌کند.");
}
private string GetErrorTrayMessage()
{
var status = _viewModel.StatusText?.Trim();
if (string.IsNullOrWhiteSpace(status) ||
status == LocalizationService.Instance.T("خطا") ||
status.Equals("Error", StringComparison.OrdinalIgnoreCase))
return LocalizationService.Instance.T("جزئیات خطا را در پنجره برنامه یا لاگ‌ها بررسی کنید.");
return status.StartsWith(LocalizationService.Instance.T("خطا"), StringComparison.OrdinalIgnoreCase) ||
status.StartsWith("Error", StringComparison.OrdinalIgnoreCase)
? status
: LocalizationService.Instance.Format("جزئیات: {0}", status);
}
private void ShowTrayNotification(string title, string message, System.Windows.Forms.ToolTipIcon icon)
{
if (_trayIcon == null) return;
@@ -201,6 +301,10 @@ public partial class MainWindow : Window
private void OnLoaded(object sender, RoutedEventArgs e)
{
LocalizationService.Instance.ApplyTo(this);
UpdateLogPanelCornerState(LogPanel.Visibility == Visibility.Visible);
RefreshTrayText();
// Force window to foreground — borderless/transparent windows sometimes
// start behind other windows or appear unfocused on slower machines.
Activate();
@@ -211,7 +315,7 @@ public partial class MainWindow : Window
// Dismiss the startup overlay quickly; app discovery continues in the
// background so the first screen becomes usable immediately.
var ct = _loadCts.Token;
LoadingStatusText.Text = "بارگذاری لیست برنامه‌های نصب‌شده...";
LoadingStatusText.Text = LocalizationService.Instance.T("بارگذاری لیست برنامه‌های نصب‌شده...");
_ = DismissLoadingOverlaySoonAsync(ct);
Task.Run(() =>
{
@@ -339,17 +443,15 @@ public partial class MainWindow : Window
}
private bool _logPanelLoaded;
private bool _isLogPanelAnimating;
private const double LogPanelWidth = 350;
private static readonly Duration LogPanelAnimationDuration = TimeSpan.FromMilliseconds(220);
private string _logFilter = "All";
private void OnShowLogClick(object sender, RoutedEventArgs e)
{
if (LogPanel.Visibility == Visibility.Visible)
{
LogPanel.Visibility = Visibility.Collapsed;
Width -= LogPanelWidth;
if (_isLogPanelAnimating)
return;
}
if (!_logPanelLoaded)
{
@@ -359,11 +461,78 @@ public partial class MainWindow : Window
Logger.LogAdded += OnLogAdded;
}
Width += LogPanelWidth;
var shouldOpen = LogPanel.Visibility != Visibility.Visible;
AnimateLogPanel(shouldOpen);
}
private void AnimateLogPanel(bool open)
{
_isLogPanelAnimating = true;
UpdateLogPanelCornerState(open);
var easing = new CubicEase { EasingMode = EasingMode.EaseOut };
var currentPanelWidth = LogPanel.Visibility == Visibility.Visible ? LogPanel.ActualWidth : 0;
var maxWindowWidth = SystemParameters.WorkArea.Width - 24;
var availablePanelWidth = Math.Max(0, maxWindowWidth - (Width - currentPanelWidth));
var targetPanelWidth = open ? Math.Min(LogPanelWidth, availablePanelWidth) : 0;
var targetWindowWidth = open
? Math.Min(maxWindowWidth, Width + (targetPanelWidth - currentPanelWidth))
: Math.Max(MinWidth, Width - currentPanelWidth);
if (open)
{
LogPanel.Visibility = Visibility.Visible;
LogTextBox.ScrollToEnd();
}
var windowAnimation = new DoubleAnimation(targetWindowWidth, LogPanelAnimationDuration)
{
EasingFunction = easing
};
var panelAnimation = new DoubleAnimation(targetPanelWidth, LogPanelAnimationDuration)
{
EasingFunction = easing
};
var opacityAnimation = new DoubleAnimation(open ? 1 : 0, LogPanelAnimationDuration)
{
EasingFunction = easing
};
panelAnimation.Completed += (_, _) =>
{
BeginAnimation(WidthProperty, null);
LogPanel.BeginAnimation(FrameworkElement.WidthProperty, null);
LogPanel.BeginAnimation(OpacityProperty, null);
Width = targetWindowWidth;
LogPanel.Width = targetPanelWidth;
LogPanel.Opacity = open ? 1 : 0;
if (!open)
LogPanel.Visibility = Visibility.Collapsed;
_isLogPanelAnimating = false;
};
BeginAnimation(WidthProperty, windowAnimation, HandoffBehavior.SnapshotAndReplace);
LogPanel.BeginAnimation(FrameworkElement.WidthProperty, panelAnimation, HandoffBehavior.SnapshotAndReplace);
LogPanel.BeginAnimation(OpacityProperty, opacityAnimation, HandoffBehavior.SnapshotAndReplace);
}
private void UpdateLogPanelCornerState(bool logOpen)
{
if (logOpen)
{
MainTitleBar.CornerRadius = new CornerRadius(12, 0, 0, 0);
MainFooterBar.CornerRadius = new CornerRadius(0, 0, 0, 12);
}
else
{
MainTitleBar.CornerRadius = new CornerRadius(12, 12, 0, 0);
MainFooterBar.CornerRadius = new CornerRadius(0, 0, 12, 12);
}
}
private void OnLogAdded(string logEntry)
{
Dispatcher.BeginInvoke(() =>
@@ -378,12 +547,27 @@ public partial class MainWindow : Window
{
Logger.Clear();
LogTextBox.Clear();
ShowToast(LocalizationService.Instance.T("لاگ‌ها پاک شدند"));
}
private void OnLogCopyClick(object sender, RoutedEventArgs e)
{
try { System.Windows.Clipboard.SetText(LogTextBox.Text); }
catch { }
try
{
if (string.IsNullOrWhiteSpace(LogTextBox.Text))
{
ShowToast(LocalizationService.Instance.T("لاگی برای کپی وجود ندارد"), "");
return;
}
System.Windows.Clipboard.SetText(LogTextBox.Text);
ShowToast(LocalizationService.Instance.T("لاگ کپی شد"));
}
catch (Exception ex)
{
Logger.Warning($"[UI] Copy logs failed: {ex.Message}");
ShowToast(LocalizationService.Instance.T("کپی لاگ ناموفق بود"), "⚠");
}
}
private void OnLogCopyLastErrorClick(object sender, RoutedEventArgs e)
@@ -396,10 +580,20 @@ public partial class MainWindow : Window
l.Contains("[ERROR]", StringComparison.OrdinalIgnoreCase)) ??
lines.Reverse().FirstOrDefault(l =>
l.Contains("[WARN]", StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(line))
System.Windows.Clipboard.SetText(line);
if (string.IsNullOrWhiteSpace(line))
{
ShowToast(LocalizationService.Instance.T("آخرین خطا یا هشدار پیدا نشد"), "");
return;
}
System.Windows.Clipboard.SetText(line);
ShowToast(LocalizationService.Instance.T("آخرین خطا یا هشدار کپی شد"));
}
catch (Exception ex)
{
Logger.Warning($"[UI] Copy last error/warning failed: {ex.Message}");
ShowToast(LocalizationService.Instance.T("کپی لاگ ناموفق بود"), "⚠");
}
catch { }
}
private void OnLogFilterChanged(object sender, SelectionChangedEventArgs e)
@@ -443,9 +637,20 @@ public partial class MainWindow : Window
Owner = this,
DataContext = _viewModel
};
LocalizationService.Instance.ApplyTo(helpWindow);
helpWindow.ShowDialog();
}
private void OnDonateClick(object sender, RoutedEventArgs e)
{
var donationDialog = new AppTunnel.Views.DonationDialog
{
Owner = this
};
LocalizationService.Instance.ApplyTo(donationDialog);
donationDialog.ShowDialog();
}
private void OnNestedScrollPreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (sender is not DependencyObject source) return;
+138 -5
View File
@@ -1,13 +1,23 @@
using System.Text.Json.Serialization;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
using AppTunnel.Services;
namespace AppTunnel.Models;
public enum TunnelType
{
L2tpIpsec,
V2Ray
V2Ray,
OpenVpn,
SocksProxy
}
public enum ProxyProtocol
{
Socks5,
Http
}
/// <summary>
@@ -28,11 +38,30 @@ public class ConnectionProfile : INotifyPropertyChanged
private List<string> _excludedDestinations = new();
private TunnelType _tunnelType = TunnelType.L2tpIpsec;
private string _v2RayConfig = "";
private int _socks5Port = 1080;
private string _openVpnConfig = "";
private string _openVpnConfigPath = "";
private string _openVpnUsername = "";
private string _openVpnPassword = "";
private ProxyProtocol _proxyProtocol = ProxyProtocol.Socks5;
private string _proxyServerAddress = "";
private int _proxyPort = 1080;
private string _proxyUsername = "";
private string _proxyPassword = "";
private int _mixedProxyPort = 1080;
private bool _autoTuneMtu = true;
private bool _enableDnsOptimization = true;
private bool _enableGameMode = false;
public ConnectionProfile()
{
LocalizationService.Instance.LanguageChanged += (_, _) =>
{
OnPropertyChanged(nameof(TunnelTypeDisplay));
OnPropertyChanged(nameof(EndpointDisplay));
OnPropertyChanged(nameof(ReadinessText));
};
}
public string Id
{
get => _id;
@@ -110,10 +139,65 @@ public class ConnectionProfile : INotifyPropertyChanged
set => SetField(ref _v2RayConfig, value);
}
public int Socks5Port
public string OpenVpnConfig
{
get => _socks5Port;
set => SetField(ref _socks5Port, value);
get => _openVpnConfig;
set => SetField(ref _openVpnConfig, value);
}
public string OpenVpnConfigPath
{
get => _openVpnConfigPath;
set => SetField(ref _openVpnConfigPath, value);
}
public string OpenVpnUsername
{
get => _openVpnUsername;
set => SetField(ref _openVpnUsername, value);
}
public string OpenVpnPassword
{
get => _openVpnPassword;
set => SetField(ref _openVpnPassword, value);
}
public ProxyProtocol ProxyProtocol
{
get => _proxyProtocol;
set => SetField(ref _proxyProtocol, value);
}
public string ProxyServerAddress
{
get => _proxyServerAddress;
set => SetField(ref _proxyServerAddress, value);
}
public int ProxyPort
{
get => _proxyPort;
set => SetField(ref _proxyPort, value);
}
public string ProxyUsername
{
get => _proxyUsername;
set => SetField(ref _proxyUsername, value);
}
public string ProxyPassword
{
get => _proxyPassword;
set => SetField(ref _proxyPassword, value);
}
[JsonPropertyName("socks5Port")]
public int MixedProxyPort
{
get => _mixedProxyPort;
set => SetField(ref _mixedProxyPort, value);
}
public bool AutoTuneMtu
@@ -137,6 +221,42 @@ public class ConnectionProfile : INotifyPropertyChanged
[JsonIgnore]
public string ConnectionName => $"TunnelX-{Id}";
[JsonIgnore]
public string TunnelTypeDisplay => TunnelType switch
{
TunnelType.L2tpIpsec => "L2TP/IPsec",
TunnelType.V2Ray => "V2Ray / Xray",
TunnelType.OpenVpn => "OpenVPN",
TunnelType.SocksProxy => ProxyProtocol == ProxyProtocol.Http ? "HTTP Proxy" : "SOCKS5 Proxy",
_ => LocalizationService.Instance.T("نامشخص")
};
[JsonIgnore]
public string EndpointDisplay => TunnelType switch
{
TunnelType.L2tpIpsec => string.IsNullOrWhiteSpace(ServerAddress) ? LocalizationService.Instance.T("آدرس سرور وارد نشده") : ServerAddress,
TunnelType.V2Ray => string.IsNullOrWhiteSpace(V2RayConfig) ? LocalizationService.Instance.T("کانفیگ وارد نشده") : LocalizationService.Instance.T("کانفیگ آماده"),
TunnelType.OpenVpn => string.IsNullOrWhiteSpace(OpenVpnConfigPath) ? LocalizationService.Instance.T("فایل .ovpn انتخاب نشده") : Path.GetFileName(OpenVpnConfigPath),
TunnelType.SocksProxy => string.IsNullOrWhiteSpace(ProxyServerAddress) ? LocalizationService.Instance.T("آدرس پراکسی وارد نشده") : $"{ProxyServerAddress}:{ProxyPort}",
_ => ""
};
[JsonIgnore]
public bool IsReady => TunnelType switch
{
TunnelType.L2tpIpsec => !string.IsNullOrWhiteSpace(ServerAddress),
TunnelType.V2Ray => !string.IsNullOrWhiteSpace(V2RayConfig),
TunnelType.OpenVpn => !string.IsNullOrWhiteSpace(OpenVpnConfig),
TunnelType.SocksProxy => !string.IsNullOrWhiteSpace(ProxyServerAddress) && ProxyPort is > 0 and <= 65535,
_ => false
};
[JsonIgnore]
public string ReadinessText => IsReady ? LocalizationService.Instance.T("آماده اتصال") : LocalizationService.Instance.T("نیاز به تکمیل");
[JsonIgnore]
public string ReadinessColor => IsReady ? "#6CCB5F" : "#E0A020";
public ServerConfig ToServerConfig() => new()
{
ServerAddress = ServerAddress,
@@ -146,6 +266,14 @@ public class ConnectionProfile : INotifyPropertyChanged
ConnectionName = ConnectionName,
TunnelType = TunnelType,
V2RayConfig = V2RayConfig,
OpenVpnConfig = OpenVpnConfig,
OpenVpnUsername = OpenVpnUsername,
OpenVpnPassword = OpenVpnPassword,
ProxyProtocol = ProxyProtocol,
ProxyServerAddress = ProxyServerAddress,
ProxyPort = ProxyPort,
ProxyUsername = ProxyUsername,
ProxyPassword = ProxyPassword,
AutoTuneMtu = AutoTuneMtu,
EnableDnsOptimization = EnableDnsOptimization,
EnableGameMode = EnableGameMode
@@ -163,6 +291,11 @@ public class ConnectionProfile : INotifyPropertyChanged
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(propertyName);
OnPropertyChanged(nameof(TunnelTypeDisplay));
OnPropertyChanged(nameof(EndpointDisplay));
OnPropertyChanged(nameof(IsReady));
OnPropertyChanged(nameof(ReadinessText));
OnPropertyChanged(nameof(ReadinessColor));
return true;
}
}
+3
View File
@@ -16,6 +16,9 @@ public class ConnectionStatus
public DateTime? ConnectedSince { get; set; }
public string VpnLocalIp { get; set; } = string.Empty;
public string VpnServerIp { get; set; } = string.Empty;
public string VpnServerHost { get; set; } = string.Empty;
public int VpnServerPort { get; set; }
public string VpnGatewayIp { get; set; } = string.Empty;
public int VpnInterfaceIndex { get; set; } = -1;
/// <summary>
+22 -1
View File
@@ -1,7 +1,7 @@
namespace AppTunnel.Models;
/// <summary>
/// L2TP/IPsec server connection configuration.
/// Server connection configuration (L2TP/IPsec, V2Ray).
/// </summary>
public class ServerConfig
{
@@ -12,7 +12,28 @@ public class ServerConfig
public string ConnectionName { get; set; } = "TunnelX";
public TunnelType TunnelType { get; set; } = TunnelType.L2tpIpsec;
public string V2RayConfig { get; set; } = "";
public string OpenVpnConfig { get; set; } = "";
public string OpenVpnExePath { get; set; } = "";
public string OpenVpnUsername { get; set; } = "";
public string OpenVpnPassword { get; set; } = "";
public string OpenVpnPrivateKeyPassword { get; set; } = "";
public ProxyProtocol ProxyProtocol { get; set; } = ProxyProtocol.Socks5;
public string ProxyServerAddress { get; set; } = "";
public int ProxyPort { get; set; } = 1080;
public string ProxyUsername { get; set; } = "";
public string ProxyPassword { get; set; } = "";
public bool AutoTuneMtu { get; set; } = true;
public bool EnableDnsOptimization { get; set; } = true;
public bool EnableGameMode { get; set; } = false;
public string BuildProxyUri()
{
var scheme = ProxyProtocol == ProxyProtocol.Http ? "http" : "socks5";
var port = ProxyPort > 0 ? ProxyPort : (ProxyProtocol == ProxyProtocol.Http ? 3128 : 1080);
var auth = string.IsNullOrWhiteSpace(ProxyUsername)
? ""
: $"{Uri.EscapeDataString(ProxyUsername)}:{Uri.EscapeDataString(ProxyPassword ?? "")}@";
return $"{scheme}://{auth}{ProxyServerAddress.Trim()}:{port}#TunnelX-Proxy";
}
}
+67 -12
View File
@@ -1,10 +1,13 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<FontFamily x:Key="PersianFont">pack://application:,,,/AppTunnel;component/Fonts/#Vazirmatn</FontFamily>
<FontFamily x:Key="AppFont">pack://application:,,,/AppTunnel;component/Fonts/#Vazirmatn, Vazirmatn, Vazir, Segoe UI, Tahoma</FontFamily>
<!-- Color Palette: Windows 11 Dark Mode + Orange Accent -->
<Color x:Key="PrimaryColor">#E8803A</Color>
<Color x:Key="PrimaryDarkColor">#C46A2A</Color>
<Color x:Key="AccentColor">#F09848</Color>
<Color x:Key="PrimaryColor">#F97316</Color>
<Color x:Key="PrimaryDarkColor">#EA580C</Color>
<Color x:Key="AccentColor">#F97316</Color>
<Color x:Key="BackgroundColor">#202020</Color>
<Color x:Key="SurfaceColor">#282828</Color>
<Color x:Key="CardColor">#2D2D2D</Color>
@@ -19,6 +22,10 @@
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource PrimaryColor}"/>
<SolidColorBrush x:Key="PrimaryDarkBrush" Color="{StaticResource PrimaryDarkColor}"/>
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/>
<LinearGradientBrush x:Key="PrimaryGradientBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#F97316" Offset="0"/>
<GradientStop Color="#F97316" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="BackgroundBrush" Color="{StaticResource BackgroundColor}"/>
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}"/>
<SolidColorBrush x:Key="CardBrush" Color="{StaticResource CardColor}"/>
@@ -31,12 +38,16 @@
<!-- TextBox Style with placeholder support via Tag property -->
<Style x:Key="ModernTextBox" TargetType="TextBox">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Background" Value="{StaticResource SurfaceBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
<Setter Property="CaretBrush" Value="{StaticResource AccentBrush}"/>
<Setter Property="Cursor" Value="IBeam"/>
<Setter Property="Template">
@@ -51,8 +62,10 @@
<!-- Placeholder text (shown when empty and not focused) -->
<TextBlock x:Name="placeholder"
Text="{TemplateBinding Tag}"
FontFamily="{TemplateBinding FontFamily}"
Foreground="#555555"
FontSize="{TemplateBinding FontSize}"
TextAlignment="{TemplateBinding TextAlignment}"
Padding="{TemplateBinding Padding}"
VerticalAlignment="Center"
IsHitTestVisible="False"
@@ -82,12 +95,16 @@
<!-- PasswordBox Style -->
<Style x:Key="ModernPasswordBox" TargetType="PasswordBox">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Background" Value="{StaticResource SurfaceBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
<Setter Property="CaretBrush" Value="{StaticResource AccentBrush}"/>
<Setter Property="Cursor" Value="IBeam"/>
<Setter Property="Template">
@@ -111,12 +128,16 @@
<!-- Primary Button Style -->
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Background" Value="{StaticResource PrimaryBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="24,12"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
@@ -142,6 +163,7 @@
<!-- Secondary / Outline Button -->
<Style x:Key="SecondaryButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource PrimaryBrush}"/>
@@ -149,6 +171,9 @@
<Setter Property="Padding" Value="16,8"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
@@ -177,11 +202,15 @@
<!-- Danger Button -->
<Style x:Key="DangerButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="8,4"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
@@ -215,6 +244,7 @@
<!-- Section Header -->
<Style x:Key="SectionHeader" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
@@ -223,6 +253,7 @@
<!-- Label Style -->
<Style x:Key="FieldLabel" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="Margin" Value="0,0,0,3"/>
@@ -268,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="4,0">
Padding="4,4,4,3">
<TabPanel IsItemsHost="True" HorizontalAlignment="Center"/>
</Border>
<!-- Tab content -->
@@ -280,18 +311,28 @@
</Style>
<Style x:Key="ModernTabItem" TargetType="TabItem">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="14,10"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
<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>
<ControlTemplate TargetType="TabItem">
<Border x:Name="border"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="1"
CornerRadius="8"
Padding="{TemplateBinding Padding}"
Margin="2,0">
Margin="2,0,2,2">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
@@ -300,29 +341,33 @@
<ContentPresenter Grid.Row="0" ContentSource="Header"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,0,0,4"/>
Margin="0,0,0,3"/>
<!-- Bottom indicator line -->
<Border x:Name="indicator" Grid.Row="1"
Height="2.5" CornerRadius="1.5"
Height="2" CornerRadius="1"
Background="Transparent"
HorizontalAlignment="Stretch"
Margin="6,0"/>
Margin="8,0"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter TargetName="border" Property="Background" Value="#1FE8803A"/>
<Setter TargetName="border" Property="BorderBrush" Value="{StaticResource PrimaryBrush}"/>
<Setter TargetName="indicator" Property="Background" Value="{StaticResource PrimaryBrush}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="#0AFFFFFF"/>
<Setter TargetName="border" Property="Background" Value="#10FFFFFF"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True"/>
<Condition Property="IsMouseOver" Value="True"/>
</MultiTrigger.Conditions>
<Setter TargetName="border" Property="Background" Value="#0AFFFFFF"/>
<Setter TargetName="border" Property="Background" Value="#29E8803A"/>
<Setter TargetName="border" Property="BorderBrush" Value="{StaticResource PrimaryBrush}"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -354,6 +399,7 @@
<!-- Window Control Button Style -->
<Style x:Key="WindowControlButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
@@ -379,12 +425,16 @@
<!-- Dark ComboBox Style -->
<Style x:Key="DarkComboBox" TargetType="ComboBox">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Background" Value="{StaticResource SurfaceBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
@@ -409,6 +459,7 @@
VerticalAlignment="Center"
HorizontalAlignment="Stretch"
IsHitTestVisible="False"
TextElement.FontFamily="{StaticResource AppFont}"
TextElement.Foreground="{StaticResource TextPrimaryBrush}"/>
<ToggleButton Grid.ColumnSpan="2"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
@@ -461,10 +512,14 @@
<!-- ComboBoxItem Dark Style -->
<Style TargetType="ComboBoxItem">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
+11 -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";
@@ -16,11 +17,20 @@ public static class AppInfo
"TON: UQD65oL2Vu2OJDSrwQ0wLLSw3g668SREMJ3VPW9k8b6Sy-Yf\n" +
"BNB Smart Chain: 0xE2a5b01cE2b3713D435Bc16d92eAdd88A82159f0\n" +
"Dogecoin: DSZRNY65yF679uvjAh6sUAt6YiEEQHwKGb";
public const string CryptoDonationTextEn =
"Tron / USDT on TRC20: TNWV867fQDT6zpLunHgbeMjrN6ic63LQSu\n" +
"Bitcoin: bc1qgx3g47c458fu6smnpqpu0l05hha82rq2xjet4y\n" +
"Ethereum / USDT on ERC20: 0x72d94Bb250E8802441a0ED05686Ee925BC99Fef5\n" +
"TON: UQD65oL2Vu2OJDSrwQ0wLLSw3g668SREMJ3VPW9k8b6Sy-Yf\n" +
"BNB Smart Chain: 0xE2a5b01cE2b3713D435Bc16d92eAdd88A82159f0\n" +
"Dogecoin: DSZRNY65yF679uvjAh6sUAt6YiEEQHwKGb";
public static string VersionText =>
"v" + (System.Reflection.Assembly.GetExecutingAssembly()
.GetName().Version?.ToString(3) ?? "1.0.0");
public static string ReleaseText => $"{AppName} {VersionText}";
public static string CreatorText => $"ساخته شده توسط {CreatorName}";
public static string CreatorText => LocalizationService.Instance.IsRightToLeft
? $"ساخته شده توسط {CreatorName}"
: $"Made by {CreatorName}";
}
@@ -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);
}
}
+24 -24
View File
@@ -24,7 +24,7 @@ public class L2tpTunnelProvider : ITunnelProvider
_config = config;
_vpnInterfaceIndex = -1;
Status.State = ConnectionState.Connecting;
Status.Message = "در حال ایجاد اتصال VPN...";
Status.Message = LocalizationService.Instance.T("در حال ایجاد اتصال VPN...");
Logger.Info($"Starting VPN connection to {config.ServerAddress}");
try
@@ -107,13 +107,13 @@ public class L2tpTunnelProvider : ITunnelProvider
if (!createResult.Success)
{
Status.State = ConnectionState.Error;
Status.Message = $"خطا در ایجاد VPN: {createResult.Error}";
Status.Message = LocalizationService.Instance.Format("خطا در ایجاد VPN: {0}", createResult.Error);
Logger.Error($"VPN creation failed: {createResult.Error}");
return false;
}
Logger.Info("VPN connection profile created successfully");
Status.Message = "در حال اتصال به سرور...";
Status.Message = LocalizationService.Instance.T("در حال اتصال به سرور...");
// Step 3: Connect using rasdial (60-second timeout for L2TP negotiation)
var connectResult = await RunProcessAsync(
@@ -126,7 +126,7 @@ public class L2tpTunnelProvider : ITunnelProvider
{
Status.State = ConnectionState.Error;
var errorMsg = MapRasDialError(connectResult.ExitCode, connectResult.Output, connectResult.Error);
Status.Message = $"خطا: {errorMsg}";
Status.Message = LocalizationService.Instance.Format("خطا: {0}", errorMsg);
Logger.Error($"VPN connection failed: ExitCode={connectResult.ExitCode}, Error={errorMsg}");
return false;
}
@@ -143,7 +143,7 @@ public class L2tpTunnelProvider : ITunnelProvider
Status.VpnLocalIp = vpnInfo.localIp;
Status.VpnServerIp = config.ServerAddress;
Status.VpnInterfaceIndex = vpnInfo.interfaceIndex;
Status.Message = $"متصل — IP: {vpnInfo.localIp}";
Status.Message = LocalizationService.Instance.Format("متصل — IP: {0}", vpnInfo.localIp);
Logger.Info($"VPN fully connected. Local IP: {vpnInfo.localIp}, Interface Index: {vpnInfo.interfaceIndex}");
return true;
@@ -151,14 +151,14 @@ public class L2tpTunnelProvider : ITunnelProvider
catch (OperationCanceledException)
{
Status.State = ConnectionState.Disconnected;
Status.Message = "اتصال لغو شد";
Status.Message = LocalizationService.Instance.T("اتصال لغو شد");
Logger.Warning("VPN connection cancelled by user");
return false;
}
catch (Exception ex)
{
Status.State = ConnectionState.Error;
Status.Message = $"خطا: {ex.Message}";
Status.Message = LocalizationService.Instance.Format("خطا: {0}", ex.Message);
Logger.Error("VPN connection failed with exception", ex);
return false;
}
@@ -173,7 +173,7 @@ public class L2tpTunnelProvider : ITunnelProvider
if (_config == null) return;
Status.State = ConnectionState.Disconnecting;
Status.Message = "در حال قطع اتصال...";
Status.Message = LocalizationService.Instance.T("در حال قطع اتصال...");
try
{
@@ -195,7 +195,7 @@ public class L2tpTunnelProvider : ITunnelProvider
Status.ConnectedSince = null;
Status.VpnLocalIp = string.Empty;
Status.VpnInterfaceIndex = -1;
Status.Message = "قطع شد";
Status.Message = LocalizationService.Instance.T("قطع شد");
}
}
@@ -281,23 +281,23 @@ public class L2tpTunnelProvider : ITunnelProvider
private static string MapRasDialError(int exitCode, string output, string error) =>
exitCode switch
{
691 => "نام کاربری یا رمز عبور اشتباه است",
692 => "پورت یا دستگاه اشغال است",
718 => "انتظار برای پاسخ سرور به اتمام رسید (زمان‌بر)",
720 => "پروتکل PPP بین کلاینت و سرور مطابقت ندارد",
734 => "پروتکل Link Control قطع شد",
735 => "آدرس درخواستی توسط سرور رد شد",
742 => "رایانه به اینترنت متصل نیست",
768 => "رمزنگاری L2TP/IPsec شکست خورد - PSK یا تنظیمات سرور را بررسی کنید",
769 => "مقصد در دسترس نیست (سرور خاموش یا آدرس اشتباه)",
781 => "Pre-Shared Key (PSK) اشتباه است",
787 => "رمزنگاری L2TP شکست خورد",
800 => "سرور VPN در دسترس نیست",
809 => "نوع شبکه را نمی‌توان مشخص کرد (فایروال مسدود کرده)",
812 => "اتصال قبلی باعث تضاد شده",
691 => LocalizationService.Instance.T("نام کاربری یا رمز عبور اشتباه است"),
692 => LocalizationService.Instance.T("پورت یا دستگاه اشغال است"),
718 => LocalizationService.Instance.T("انتظار برای پاسخ سرور به اتمام رسید (زمان‌بر)"),
720 => LocalizationService.Instance.T("پروتکل PPP بین کلاینت و سرور مطابقت ندارد"),
734 => LocalizationService.Instance.T("پروتکل Link Control قطع شد"),
735 => LocalizationService.Instance.T("آدرس درخواستی توسط سرور رد شد"),
742 => LocalizationService.Instance.T("رایانه به اینترنت متصل نیست"),
768 => LocalizationService.Instance.T("رمزنگاری L2TP/IPsec شکست خورد - PSK یا تنظیمات سرور را بررسی کنید"),
769 => LocalizationService.Instance.T("مقصد در دسترس نیست (سرور خاموش یا آدرس اشتباه)"),
781 => LocalizationService.Instance.T("Pre-Shared Key (PSK) اشتباه است"),
787 => LocalizationService.Instance.T("رمزنگاری L2TP شکست خورد"),
800 => LocalizationService.Instance.T("سرور VPN در دسترس نیست"),
809 => LocalizationService.Instance.T("نوع شبکه را نمی‌توان مشخص کرد (فایروال مسدود کرده)"),
812 => LocalizationService.Instance.T("اتصال قبلی باعث تضاد شده"),
_ when !string.IsNullOrWhiteSpace(error) => error.Trim(),
_ when !string.IsNullOrWhiteSpace(output) => output.Trim(),
_ => $"خطای ناشناخته (exit code: {exitCode})"
_ => LocalizationService.Instance.Format("خطای ناشناخته (exit code: {0})", exitCode)
};
private static string SanitizeForPowerShell(string value) => value.Replace("'", "''");
@@ -0,0 +1,62 @@
using System.IO;
using System.Net;
using System.Net.Sockets;
namespace AppTunnel.Services;
internal sealed class LocalPortReservation : IDisposable
{
private readonly TcpListener _listener;
private LocalPortReservation(TcpListener listener)
{
_listener = listener;
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
}
public int Port { get; }
public static LocalPortReservation ReservePreferredOrRandom(int preferredPort, params int[] excludedPorts)
{
if (preferredPort > 0 && !excludedPorts.Contains(preferredPort) &&
TryReserve(preferredPort, out var preferred))
return preferred;
for (var i = 0; i < 20; i++)
{
var reservation = Reserve(0);
if (!excludedPorts.Contains(reservation.Port))
return reservation;
reservation.Dispose();
}
throw new IOException("Could not reserve a free local port.");
}
public void Dispose()
{
_listener.Stop();
}
private static bool TryReserve(int port, out LocalPortReservation reservation)
{
try
{
reservation = Reserve(port);
return true;
}
catch (SocketException)
{
reservation = null!;
return false;
}
}
private static LocalPortReservation Reserve(int port)
{
var listener = new TcpListener(IPAddress.Loopback, port);
listener.Start();
return new LocalPortReservation(listener);
}
}
+747
View File
@@ -0,0 +1,747 @@
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Threading;
namespace AppTunnel.Services;
public sealed class LocalizationService : INotifyPropertyChanged
{
public const string AutoLanguage = "auto";
public const string PersianLanguage = "fa-IR";
public const string EnglishLanguage = "en-US";
public static LocalizationService Instance { get; } = new();
private readonly Dictionary<string, Dictionary<string, string>> _translations = new(StringComparer.Ordinal)
{
[EnglishLanguage] = EnglishTranslations()
};
private string _languageSetting = AutoLanguage;
private string _effectiveLanguage = PersianLanguage;
private LocalizationService()
{
EventManager.RegisterClassHandler(
typeof(FrameworkElement),
FrameworkElement.LoadedEvent,
new RoutedEventHandler(OnFrameworkElementLoaded),
handledEventsToo: true);
}
public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler? LanguageChanged;
public string LanguageSetting => _languageSetting;
public string EffectiveLanguage => _effectiveLanguage;
public bool IsRightToLeft => _effectiveLanguage.StartsWith("fa", StringComparison.OrdinalIgnoreCase);
public System.Windows.FlowDirection FlowDirection => IsRightToLeft
? System.Windows.FlowDirection.RightToLeft
: System.Windows.FlowDirection.LeftToRight;
public System.Windows.TextAlignment TextAlignment => IsRightToLeft
? System.Windows.TextAlignment.Right
: System.Windows.TextAlignment.Left;
public System.Windows.HorizontalAlignment StartHorizontalAlignment => IsRightToLeft
? System.Windows.HorizontalAlignment.Right
: System.Windows.HorizontalAlignment.Left;
public System.Windows.HorizontalAlignment EndHorizontalAlignment => IsRightToLeft
? System.Windows.HorizontalAlignment.Left
: System.Windows.HorizontalAlignment.Right;
public string ToggleLanguageText => IsRightToLeft ? "English" : "فارسی";
public void Initialize(string? savedLanguage)
{
SetLanguageInternal(string.IsNullOrWhiteSpace(savedLanguage) ? AutoLanguage : savedLanguage!, raiseChanged: false);
}
public void ToggleLanguage()
{
SetLanguage(IsRightToLeft ? EnglishLanguage : PersianLanguage);
}
public void SetLanguage(string language)
{
SetLanguageInternal(language, raiseChanged: true);
}
public string T(string source)
{
if (string.IsNullOrEmpty(source) || IsRightToLeft)
return source;
return _translations.TryGetValue(_effectiveLanguage, out var table) &&
table.TryGetValue(source, out var translated)
? translated
: source;
}
public string Format(string sourceFormat, params object?[] args)
=> string.Format(CultureInfo.CurrentCulture, T(sourceFormat), args);
public void ApplyToOpenWindows()
{
foreach (Window window in System.Windows.Application.Current.Windows)
ApplyTo(window);
}
public void ApplyTo(DependencyObject root)
{
ApplyTo(root, new HashSet<DependencyObject>());
}
private void ApplyTo(DependencyObject root, HashSet<DependencyObject> visited)
{
if (!visited.Add(root))
return;
ApplyFlowDirection(root);
ApplyText(root);
try
{
var visualCount = VisualTreeHelper.GetChildrenCount(root);
for (var i = 0; i < visualCount; i++)
ApplyTo(VisualTreeHelper.GetChild(root, i), visited);
}
catch (InvalidOperationException)
{
// Some logical children, such as Run, are not visual children.
}
foreach (var child in LogicalTreeHelper.GetChildren(root).OfType<DependencyObject>())
ApplyTo(child, visited);
}
private void OnFrameworkElementLoaded(object sender, RoutedEventArgs e)
{
if (sender is not DependencyObject element)
return;
ApplyTo(element);
}
private void SetLanguageInternal(string language, bool raiseChanged)
{
_languageSetting = NormalizeLanguageSetting(language);
_effectiveLanguage = ResolveEffectiveLanguage(_languageSetting);
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(_effectiveLanguage);
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(_effectiveLanguage);
OnPropertyChanged(nameof(LanguageSetting));
OnPropertyChanged(nameof(EffectiveLanguage));
OnPropertyChanged(nameof(IsRightToLeft));
OnPropertyChanged(nameof(FlowDirection));
OnPropertyChanged(nameof(TextAlignment));
OnPropertyChanged(nameof(StartHorizontalAlignment));
OnPropertyChanged(nameof(EndHorizontalAlignment));
OnPropertyChanged(nameof(ToggleLanguageText));
if (!raiseChanged) return;
LanguageChanged?.Invoke(this, EventArgs.Empty);
var dispatcher = System.Windows.Application.Current?.Dispatcher;
dispatcher?.BeginInvoke(ApplyToOpenWindows, DispatcherPriority.Loaded);
dispatcher?.BeginInvoke(ApplyToOpenWindows, DispatcherPriority.ContextIdle);
}
private static string NormalizeLanguageSetting(string language)
{
if (string.Equals(language, EnglishLanguage, StringComparison.OrdinalIgnoreCase))
return EnglishLanguage;
if (string.Equals(language, PersianLanguage, StringComparison.OrdinalIgnoreCase) ||
string.Equals(language, "fa", StringComparison.OrdinalIgnoreCase))
return PersianLanguage;
return AutoLanguage;
}
private static string ResolveEffectiveLanguage(string setting)
{
if (setting != AutoLanguage)
return setting;
var ui = CultureInfo.CurrentUICulture;
return ui.TwoLetterISOLanguageName.Equals("fa", StringComparison.OrdinalIgnoreCase)
? PersianLanguage
: EnglishLanguage;
}
private static bool HasBinding(DependencyObject element, DependencyProperty property)
=> BindingOperations.GetBindingExpressionBase(element, property) != null;
private static readonly DependencyProperty OriginalTextProperty =
DependencyProperty.RegisterAttached("OriginalText", typeof(string), typeof(LocalizationService));
private static readonly DependencyProperty OriginalContentProperty =
DependencyProperty.RegisterAttached("OriginalContent", typeof(string), typeof(LocalizationService));
private static readonly DependencyProperty OriginalHeaderProperty =
DependencyProperty.RegisterAttached("OriginalHeader", typeof(string), typeof(LocalizationService));
private static readonly DependencyProperty OriginalToolTipProperty =
DependencyProperty.RegisterAttached("OriginalToolTip", typeof(string), typeof(LocalizationService));
private static readonly DependencyProperty OriginalTagProperty =
DependencyProperty.RegisterAttached("OriginalTag", typeof(string), typeof(LocalizationService));
private void ApplyFlowDirection(DependencyObject element)
{
if (element is not FrameworkElement fe)
return;
if (HasBinding(fe, FrameworkElement.FlowDirectionProperty))
return;
var local = fe.ReadLocalValue(FrameworkElement.FlowDirectionProperty);
if (local == DependencyProperty.UnsetValue ||
local is System.Windows.FlowDirection.LeftToRight)
return;
// Explicit RTL follows the selected language. Explicit LTR is reserved
// for technical fields: IP, path, port, config and logs.
fe.FlowDirection = FlowDirection;
}
private void ApplyText(DependencyObject element)
{
switch (element)
{
case Window window when !HasBinding(window, Window.TitleProperty):
window.Title = TranslateProperty(window, Window.TitleProperty, OriginalTextProperty, window.Title);
break;
case TextBlock textBlock when !HasBinding(textBlock, TextBlock.TextProperty) && !textBlock.Inlines.Any():
textBlock.Text = TranslateProperty(textBlock, TextBlock.TextProperty, OriginalTextProperty, textBlock.Text);
if (textBlock.ReadLocalValue(TextBlock.TextAlignmentProperty) == DependencyProperty.UnsetValue)
textBlock.TextAlignment = textBlock.FlowDirection == System.Windows.FlowDirection.LeftToRight
? System.Windows.TextAlignment.Left
: TextAlignment;
break;
case Run run:
run.Text = TranslateProperty(run, Run.TextProperty, OriginalTextProperty, run.Text);
break;
case HeaderedContentControl headered when headered.Header is string header && !HasBinding(headered, HeaderedContentControl.HeaderProperty):
headered.Header = TranslateProperty(headered, HeaderedContentControl.HeaderProperty, OriginalHeaderProperty, header);
break;
case ContentControl contentControl when contentControl.Content is string content && !HasBinding(contentControl, ContentControl.ContentProperty):
contentControl.Content = TranslateProperty(contentControl, ContentControl.ContentProperty, OriginalContentProperty, content);
break;
case System.Windows.Controls.TextBox textBox when textBox.Tag is string tag && !HasBinding(textBox, FrameworkElement.TagProperty):
textBox.Tag = TranslateProperty(textBox, FrameworkElement.TagProperty, OriginalTagProperty, tag);
ApplyTextBoxAlignment(textBox);
break;
case System.Windows.Controls.TextBox textBox:
ApplyTextBoxAlignment(textBox);
break;
}
if (element is FrameworkElement fe && fe.ToolTip is string toolTip && !HasBinding(fe, FrameworkElement.ToolTipProperty))
fe.ToolTip = TranslateProperty(fe, FrameworkElement.ToolTipProperty, OriginalToolTipProperty, toolTip);
}
private void ApplyTextBoxAlignment(System.Windows.Controls.TextBox textBox)
{
if (textBox.ReadLocalValue(System.Windows.Controls.TextBox.TextAlignmentProperty) != DependencyProperty.UnsetValue)
return;
textBox.TextAlignment = textBox.FlowDirection == System.Windows.FlowDirection.LeftToRight
? TextAlignment.Left
: TextAlignment;
}
private string TranslateProperty(DependencyObject owner, DependencyProperty property, DependencyProperty originalProperty, string current)
{
var original = owner.GetValue(originalProperty) as string;
var resolvedCurrent = ResolveSourceText(current);
if (original == null || !string.Equals(original, ResolveSourceText(original), StringComparison.Ordinal))
{
original = resolvedCurrent;
owner.SetValue(originalProperty, original);
}
return T(original);
}
private string ResolveSourceText(string current)
{
if (string.IsNullOrEmpty(current))
return current;
foreach (var table in _translations.Values)
{
if (table.ContainsKey(current))
return current;
foreach (var pair in table)
{
if (string.Equals(pair.Value, current, StringComparison.Ordinal))
return pair.Key;
}
}
return current;
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
private static Dictionary<string, string> EnglishTranslations() => new(StringComparer.Ordinal)
{
["TunnelX — Split Traffic Per App"] = "TunnelX - Split Traffic Per App",
["Per-app split tunneling"] = "Per-app split tunneling",
["تانلکس"] = "TunnelX",
["تفکیک ترافیک برنامه ها"] = "Per-app traffic splitting",
["جزئیات"] = "Details",
["کوچک کردن به System Tray"] = "Minimize to system tray",
["خروج از برنامه"] = "Exit app",
["وضعیت اتصال VPN و کنترل اتصال"] = "VPN connection status and controls",
["اتصال VPN"] = "VPN Connection",
["انتخاب برنامه‌هایی که باید از تونل عبور کنند"] = "Select apps that should use the tunnel",
["برنامه‌ها"] = "Apps",
["تنظیمات عمومی تونل و DNS"] = "General tunnel and DNS settings",
["تنظیمات"] = "Settings",
["قوانین Include و Exclude مسیرها"] = "Include and exclude routing rules",
["قوانین مسیر"] = "Routing Rules",
["مقصدهای مستقیم و مقصدهای اجباری تونل را اینجا مدیریت کنید."] = "Manage direct and forced tunnel destinations here.",
["🚫 مستقیم بماند"] = "🚫 Stay Direct",
["این مقصدها از تونل عبور نمی‌کنند."] = "These destinations do not use the tunnel.",
["نمونه کاربرد"] = "Example",
["سایت‌های داخلی یا سرورهای بازی را مستقیم نگه دارید."] = "Keep internal sites or game servers direct.",
["دامنه یا آی‌پی (مثلاً google.com یا 1.2.3.4)"] = "Domain or IP, e.g. google.com or 1.2.3.4",
["افزودن"] = "Add",
["آدرس‌های مستقیم"] = "Direct Addresses",
["حذف"] = "Delete",
["✅ از تونل عبور کند"] = "✅ Use Tunnel",
["این مقصدها همیشه از تونل عبور می‌کنند."] = "These destinations always use the tunnel.",
["دامنه یا آی‌پی (مثلاً example.com یا 1.2.3.4)"] = "Domain or IP, e.g. example.com or 1.2.3.4",
["آدرس‌های اجباری"] = "Forced Addresses",
["نمایش ترافیک، تاریخچه و آمار اتصال"] = "Traffic, history, and connection stats",
["ترافیک/تاریخچه"] = "Traffic/History",
["ترافیک و تاریخچه"] = "Traffic and History",
["⏱ مدت"] = "⏱ Duration",
["🌐 IP"] = "🌐 IP",
["📊 تونل"] = "📊 Tunnel",
["📡 خارج تونل"] = "📡 Direct",
["📜 تاریخچه اتصالات"] = "📜 Connection History",
["مصرف تونل به تفکیک برنامه"] = "Tunnel Usage by App",
["اپ‌های تونل: "] = "Tunnel apps: ",
["سایر تونل: "] = "Other tunnel: ",
["هنوز برنامه‌ای اضافه نشده. از تب «برنامه‌ها» اضافه کنید."] = "No apps added yet. Add apps from the Apps tab.",
["ساخته شده توسط Maxifan"] = "Made by Maxifan",
["بروزرسانی"] = "Update",
["دانلود نسخه جدید از صفحه Releases در GitHub"] = "Download the new version from GitHub Releases",
["راهنما"] = "Help",
["راهنما و عیب‌یابی"] = "Help and troubleshooting",
["باز کردن صفحه GitHub پروژه TunnelX"] = "Open the TunnelX GitHub project",
["🔍 جزئیات عملکرد"] = "🔍 Runtime Details",
["همه"] = "All",
["خطا"] = "Error",
["هشدار"] = "Warning",
["پاک کردن"] = "Clear",
["کپی خطا"] = "Copy error",
["کپی همه"] = "Copy all",
["پاک کردن همه لاگ‌ها"] = "Clear all logs",
["کپی آخرین خطا یا هشدار"] = "Copy latest error or warning",
["کپی کردن همه لاگ‌ها"] = "Copy all logs",
["کپی کردن"] = "Copy",
["در حال بارگذاری..."] = "Loading...",
["در حال اتصال OpenVPN"] = "Connecting OpenVPN",
["تا قبل از بالا آمدن آداپتر، مسیرهای سیستم تغییر داده نمی‌شود. اگر اتصال طولانی شد، فایل .ovpn، نام کاربری/رمز یا نصب OpenVPN Community را بررسی کنید."] = "System routes are not changed until the adapter is ready. If connection takes too long, check the .ovpn file, credentials, or OpenVPN Community installation.",
["لغو اتصال"] = "Cancel",
["کانفیگ‌ها و پروفایل‌ها"] = "Configs and Profiles",
["یک کانفیگ را انتخاب کنید، ویرایش کنید یا کانفیگ جدید بسازید."] = "Select, edit, or create a connection profile.",
["افزودن کانفیگ جدید"] = "Add New Config",
["فعال"] = "Use",
["استفاده از این کانفیگ برای اتصال"] = "Use this config for connection",
["ویرایش"] = "Edit",
["کپی"] = "Copy",
["پروفایل فعال: "] = "Active profile: ",
["شروع اتصال با پروفایل انتخاب‌شده"] = "Start connection with the selected profile",
["🌐 تنظیمات سرور"] = "🌐 Server Settings",
["نوع اتصال"] = "Connection Type",
["آدرس سرور"] = "Server Address",
["نام کاربری"] = "Username",
["رمز عبور"] = "Password",
["کانفیگ V2Ray"] = "V2Ray Config",
["پیست"] = "Paste",
["خواندن کانفیگ از کلیپ‌بورد"] = "Read config from clipboard",
["فایل OpenVPN (.ovpn)"] = "OpenVPN File (.ovpn)",
["انتخاب فایل"] = "Choose File",
["حذف فایل"] = "Remove File",
["دانلود OpenVPN"] = "Download OpenVPN",
["نوع پراکسی"] = "Proxy Type",
["پورت"] = "Port",
["آدرس IP یا دامنه سرور"] = "Server IP or Domain",
["✅ برنامه‌های داخل تونل"] = "✅ Tunneled Apps",
["برنامه‌های فعال‌شده از مسیر تونل عبور می‌کنند"] = "Enabled apps use the tunnel route",
["افزودن دستی"] = "Add Manually",
["افزودن دستی فایل exe"] = "Manually add an exe file",
["فیلتر برنامه‌های تونل..."] = "Filter tunneled apps...",
["🔍 برنامه‌های نصب‌شده"] = "🔍 Installed Apps",
["روی برنامه کلیک کنید تا به لیست تونل اضافه شود"] = "Click an app to add it to the tunnel list",
["بارگذاری مجدد لیست برنامه‌ها"] = "Reload the app list",
["جستجوی برنامه..."] = "Search apps...",
["🧦 پروکسی محلی"] = "🧦 Local Proxy",
["پورت پروکسی محلی (SOCKS5/HTTP)"] = "Local proxy port (SOCKS5/HTTP)",
["پورت داخلی 127.0.0.1 برای پروکسی SOCKS5 و HTTP"] = "Internal 127.0.0.1 port for SOCKS5 and HTTP proxy",
["پورت‌های زیر 1024 و چند پورت رایج مثل 2080، 3000، 3389، 8080 و 9090 مجاز نیستند تا با سرویس‌های سیستم یا ابزارهای توسعه تداخل نداشته باشند."] = "Ports below 1024 and common ports like 2080, 3000, 3389, 8080, and 9090 are blocked to avoid conflicts with system services or developer tools.",
["🚀 بهینه‌سازی تونل"] = "🚀 Tunnel Optimization",
["MTU خودکار"] = "Automatic MTU",
["در زمان اتصال، MTU مناسب بر اساس شبکه فعلی انتخاب می‌شود."] = "At connection time, the best MTU is selected based on the current network.",
["برای Resolveها از cache کوتاه‌مدت استفاده می‌شود و DNS redirect مسیر پایدارتر می‌گیرد."] = "Uses a short-lived cache for resolves and keeps DNS redirects more stable.",
["Game Mode فعال است: Route نگهداری طولانی‌تر، DNS سریع‌تر و DSCP برای بسته‌های بازی اعمال می‌شود."] = "Game Mode is enabled: longer route retention, faster DNS, and DSCP for game packets.",
["Game Mode غیرفعال است: حالت متعادل برای مصرف عمومی."] = "Game Mode is disabled: balanced mode for general use.",
["🖥️ استارت‌آپ و اتصال خودکار"] = "🖥️ Startup and Auto-Connect",
["اجرای خودکار هنگام روشن شدن ویندوز"] = "Start automatically with Windows",
["⚠️ پس از فعال کردن، نباید محل فایل اجرایی TunnelX را تغییر دهید."] = "⚠️ After enabling this, do not move the TunnelX executable.",
["اتصال خودکار به آخرین کانکشن فعال"] = "Auto-connect to the last active connection",
["اگر آخرین بار یک پروفایل متصل بوده، هنگام اجرای برنامه به آن وصل می‌شود."] = "If a profile was connected last time, TunnelX connects to it when the app starts.",
["راهنمای TunnelX"] = "TunnelX Help",
["شروع سریع، پروفایل‌ها، بخش‌های اپ و عیب‌یابی در یک صفحه ساده."] = "Quick start, profiles, app areas, and troubleshooting in one simple page.",
["پروژه و بروزرسانی"] = "Project and Updates",
["صفحه انتشار"] = "Release Page",
["شروع سریع"] = "Quick Start",
["۱. پروفایل"] = "1. Profile",
["کانفیگ را بسازید و نوع اتصال را انتخاب کنید."] = "Create a config and select its connection type.",
["۲. برنامه‌ها"] = "2. Apps",
["برنامه‌های داخل تونل را انتخاب یا دستی اضافه کنید."] = "Select tunneled apps or add them manually.",
["۳. قوانین"] = "3. Rules",
["مقصدهای مستقیم یا اجباری را مشخص کنید."] = "Set direct or forced destinations.",
["۴. اتصال"] = "4. Connect",
["وصل شوید و سلامت، IP و مصرف را بررسی کنید."] = "Connect and check health, IP, and usage.",
["نوع پروفایل"] = "Profile Type",
["فقط فیلدهای مربوط به نوع انتخاب‌شده را پر کنید. هر پروفایل برنامه‌ها و قوانین مسیر خودش را نگه می‌دارد."] = "Only fill the fields for the selected type. Each profile keeps its own apps and routing rules.",
["بخش‌های اپ"] = "App Areas",
["پروفایل فعال، تست سرور، اتصال/قطع اتصال، IP خروجی، پینگ، مصرف و راهنمای پراکسی دستی اینجاست."] = "Active profile, server test, connect/disconnect, exit IP, ping, usage, and manual proxy guidance are here.",
["نکات مهم"] = "Essentials",
["حالت عادی: فقط برنامه‌های انتخاب‌شده و مقصدهای لزومی وارد تونل می‌شوند."] = "Normal mode: only selected apps and included destinations use the tunnel.",
["Full Route: کل سیستم وارد تونل می‌شود؛ استثناها می‌توانند مستقیم بمانند."] = "Full Route: the whole system uses the tunnel; exclusions can stay direct.",
["پراکسی داخلی"] = "Local Proxy",
["برای ابزارهایی که آدرس محلی می‌خواهند:"] = "For tools that need a local address:",
["سلامت: Leak باید صفر باشد. DNS، IPv6 و Route را بعد از اتصال بررسی کنید."] = "Health: Leak should be zero. Check DNS, IPv6, and Route after connecting.",
["عیب‌یابی سریع"] = "Quick Troubleshooting",
["حمایت و تماس"] = "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",
["پرداخت با کریپتو"] = "Pay with crypto",
["ترون / USDT روی TRC20"] = "Tron / USDT on TRC20",
["بیت‌کوین"] = "Bitcoin",
["اتریوم / USDT روی ERC20"] = "Ethereum / USDT on ERC20",
["کپی"] = "Copy",
["ویرایش پروفایل"] = "Edit Profile",
["تنظیمات این کانفیگ بعد از ذخیره در لیست پروفایل‌ها نمایش داده می‌شود."] = "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",
["پیست از کلیپ‌بورد"] = "Paste from Clipboard",
["فایل .ovpn و اطلاعات احراز هویت OpenVPN را وارد کنید. اگر سرور رمز نمی‌خواهد، فیلد رمز را خالی بگذارید."] = "Enter the .ovpn file and OpenVPN credentials. Leave password empty if the server does not require one.",
["برای SOCKS5 یا HTTP Proxy، اطلاعات سرور را جداگانه وارد کنید."] = "For SOCKS5 or HTTP Proxy, enter the server details separately.",
["لغو"] = "Cancel",
["ذخیره"] = "Save",
["تاریخچه اتصالات"] = "Connection History",
["سوابق اتصال و مصرف تونل"] = "Connection records and tunnel usage",
["مجموع مصرف تونل: "] = "Total tunnel usage: ",
["هنوز اتصالی ثبت نشده است."] = "No connection has been recorded yet.",
["آماده اتصال"] = "Ready to connect",
["نیاز به تکمیل"] = "Needs setup",
["نامشخص"] = "Unknown",
["آدرس سرور وارد نشده"] = "Server address is missing",
["کانفیگ وارد نشده"] = "Config is missing",
["کانفیگ آماده"] = "Config ready",
["فایل .ovpn انتخاب نشده"] = ".ovpn file not selected",
["آدرس پراکسی وارد نشده"] = "Proxy address is missing"
,
["وضعیت"] = "Status",
["نمایش TunnelX"] = "Show TunnelX",
["بررسی بروزرسانی"] = "Check for Updates",
["اتصال VPN فعال است. با خروج، اتصال قطع خواهد شد.\nآیا مطمئن هستید؟"] = "A VPN connection is active. Exiting will disconnect it.\nAre you sure?",
["TunnelX — خروج"] = "TunnelX - Exit",
["آیا می‌خواهید از TunnelX خارج شوید؟"] = "Do you want to exit TunnelX?",
["TunnelX در پس‌زمینه فعال است"] = "TunnelX is running in the background",
["برای باز کردن پنجره، روی آیکن کنار ساعت دوبار کلیک کنید."] = "Double-click the tray icon to open the window.",
["تونل فعال شد"] = "Tunnel enabled",
["تونل خاموش شد"] = "Tunnel disabled",
["ارتباط امن متوقف شده و ترافیک دیگر از TunnelX عبور نمی‌کند."] = "The secure connection stopped and traffic no longer passes through TunnelX.",
["اتصال برقرار نشد"] = "Connection failed",
["نسخه جدید آماده است"] = "New version available",
["از منوی System Tray یا بخش بروزرسانی، صفحه دانلود TunnelX را باز کنید."] = "Open the TunnelX download page from the system tray menu or the update section.",
["پروفایل «{0}» فعال است و ترافیک انتخاب‌شده از تونل عبور می‌کند."] = "Profile \"{0}\" is active and selected traffic uses the tunnel.",
["ترافیک انتخاب‌شده از TunnelX عبور می‌کند."] = "Selected traffic uses TunnelX.",
["جزئیات خطا را در پنجره برنامه یا لاگ‌ها بررسی کنید."] = "Check the app window or logs for error details.",
["جزئیات: {0}"] = "Details: {0}",
["بارگذاری لیست برنامه‌های نصب‌شده..."] = "Loading installed apps...",
["تاییدیه"] = "Confirmation",
["اطلاعات"] = "Information",
["موفقیت"] = "Success",
["بله"] = "Yes",
["خیر"] = "No",
["متوجه شدم"] = "OK",
["عالی"] = "Great",
["{0} کپی شد"] = "{0} copied",
["متن"] = "Text",
["انتخاب فایل OpenVPN"] = "Choose OpenVPN File",
["OpenVPN config (*.ovpn)|*.ovpn|All files (*.*)|*.*"] = "OpenVPN config (*.ovpn)|*.ovpn|All files (*.*)|*.*",
["خواندن فایل OpenVPN ناموفق بود: {0}"] = "Failed to read OpenVPN file: {0}",
["خواندن کلیپ‌بورد ناموفق بود: {0}"] = "Failed to read clipboard: {0}",
["نام پروفایل را وارد کنید"] = "Enter a profile name",
["آدرس سرور L2TP را وارد کنید"] = "Enter the L2TP server address",
["کانفیگ V2Ray/Xray را وارد کنید"] = "Enter the V2Ray/Xray config",
["فایل OpenVPN (.ovpn) را انتخاب کنید"] = "Choose an OpenVPN (.ovpn) file",
["آدرس سرور پراکسی را وارد کنید"] = "Enter the proxy server address",
["پورت پراکسی باید بین 1 تا 65535 باشد"] = "Proxy port must be between 1 and 65535",
["ذخیره شد"] = "Saved",
["پورت SOCKS5 را وارد کنید"] = "Enter the SOCKS5 port",
["فقط عدد مجاز است"] = "Only numbers are allowed",
["پیش‌نیاز آماده است: نسخه Community اوپن‌وی‌پی‌ان پیدا شد: {0}"] = "Prerequisite ready: OpenVPN Community found: {0}",
["اخطار: نسخه Community اوپن‌وی‌پی‌ان نصب نیست. برای استفاده از اسپلیت‌تانلینگ با این نوع اتصال، ابتدا آن را از لینک رسمی نصب کنید."] = "Warning: OpenVPN Community is not installed. Install it from the official link before using split tunneling with this connection type.",
["برای بررسی نسخه جدید، دکمه بررسی بروزرسانی را بزنید."] = "Click Check for Updates to look for a new version.",
["در حال بررسی..."] = "Checking...",
["🔌 اتصال"] = "🔌 Connect",
["❌ لغو اتصال"] = "❌ Cancel",
["🔴 قطع اتصال"] = "🔴 Disconnect",
["⏳ در حال قطع..."] = "⏳ Disconnecting...",
["🔌 اتصال مجدد"] = "🔌 Reconnect",
["اتصال"] = "Connect",
["لغو تلاش اتصال"] = "Cancel connection attempt",
["اتصال مجدد"] = "Reconnect",
["IP خروجی"] = "Exit IP",
["تغییر حالت Full Route ناموفق بود"] = "Failed to change Full Route mode",
["Full Route فعال است؛ کل سیستم از تونل عبور می‌کند"] = "Full Route is enabled; the whole system uses the tunnel",
["Split فعال است؛ فقط برنامه‌ها و مقصدهای انتخابی از تونل عبور می‌کنند"] = "Split is enabled; only selected apps and destinations use the tunnel",
["حالت کل سیستم"] = "Full-system Mode",
["حالت انتخابی"] = "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",
["تست مقصد"] = "Test target",
["در حال پینگ..."] = "Pinging...",
["پینگ سرور"] = "Ping server",
["در حال تست..."] = "Testing...",
["تست سرور"] = "Test server",
["در حال بررسی آخرین نسخه در GitHub..."] = "Checking the latest version on GitHub...",
["بررسی نسخه جدید ناموفق بود. اتصال اینترنت یا 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",
["OpenVPN Community نصب نیست؛ ابتدا آن را از لینک رسمی نصب کنید"] = "OpenVPN Community is not installed; install it from the official link first",
["فایل .ovpn را انتخاب کنید؛ TunnelX آن را در حالت split-compatible اجرا می‌کند"] = "Choose a .ovpn file; TunnelX runs it in split-compatible mode",
["کانفیگ انتخاب شد؛ اگر سرور احراز هویت دارد نام کاربری را وارد کنید"] = "Config selected; enter username if the server requires authentication",
["کانفیگ و نام کاربری OpenVPN آماده است"] = "OpenVPN config and username are ready",
["منتظر کانفیگ"] = "Waiting for config",
["کانفیگ V2Ray/Xray را وارد یا پیست کنید"] = "Enter or paste the V2Ray/Xray config",
["هسته: Xray-core"] = "Core: Xray-core",
["هسته: sing-box"] = "Core: sing-box",
["سرور: {0}:{1}"] = "Server: {0}:{1}",
["پراکسی آماده است: {0}"] = "Proxy is ready: {0}",
["پراکسی آماده است: {0} — توجه: این پراکسی محلی است؛ برنامه‌هایی که خودشان مستقیم از همین پراکسی استفاده کنند خارج از لیست برنامه‌های TunnelX هم پروکسی می‌شوند."] = "Proxy is ready: {0} - note: this is a local proxy; apps that directly use it will be proxied even outside TunnelX's app list.",
["آدرس IP یا دامنه سرور پراکسی را وارد کنید"] = "Enter the proxy server IP or domain",
["تنظیمات پراکسی آماده است"] = "Proxy settings are ready",
["پورت باید بین 1024 تا 65535 باشد"] = "Port must be between 1024 and 65535",
["این پورت رایج/حساس است؛ یک پورت آزاد مثل 1080، 1081 یا 18080 انتخاب کنید"] = "This port is common/sensitive; choose a free port like 1080, 1081, or 18080",
["این پورت همین حالا توسط برنامه دیگری استفاده می‌شود"] = "This port is currently used by another app",
["پورت SOCKS5 داخلی آماده است"] = "Internal SOCKS5 port is ready"
,
["خطای اتصال خودکار: {0}"] = "Auto-connect error: {0}",
["حمایت از پروژه"] = "Support the project",
["تغییر زبان"] = "Change language",
["پورت پراکسی را وارد کنید"] = "Enter the proxy port",
["پورت پراکسی باید عدد باشد"] = "Proxy port must be a number",
["در حال آماده‌سازی V2Ray..."] = "Preparing V2Ray...",
["فایل sing-box.exe پیدا نشد: {0}"] = "sing-box.exe was not found: {0}",
["خطا در پارس کانفیگ: {0}"] = "Config parse error: {0}",
["در حال انتظار برای interface TunnelX-V2Ray..."] = "Waiting for TunnelX-V2Ray interface...",
["sing-box زودتر خارج شد (exit code {0}) — کانفیگ را بررسی کنید"] = "sing-box exited early (exit code {0}) - check the config",
["interface TunnelX-V2Ray ظاهر نشد (timeout 10s)"] = "TunnelX-V2Ray interface did not appear (timeout 10s)",
["در حال قطع اتصال V2Ray..."] = "Disconnecting V2Ray...",
["در حال آماده سازی Xray..."] = "Preparing Xray...",
["فایل xray.exe پیدا نشد: {0}"] = "xray.exe was not found: {0}",
["در حال قطع اتصال Xray..."] = "Disconnecting Xray...",
["در حال ایجاد اتصال VPN..."] = "Creating VPN connection...",
["خطا در ایجاد VPN: {0}"] = "VPN creation failed: {0}",
["در حال اتصال به سرور..."] = "Connecting to server...",
["متصل — IP: {0}"] = "Connected - IP: {0}",
["نام کاربری یا رمز عبور اشتباه است"] = "The username or password is incorrect",
["پورت یا دستگاه اشغال است"] = "The port or device is busy",
["انتظار برای پاسخ سرور به اتمام رسید (زمان‌بر)"] = "Timed out waiting for the server response",
["پروتکل PPP بین کلاینت و سرور مطابقت ندارد"] = "PPP protocol does not match between client and server",
["پروتکل Link Control قطع شد"] = "Link Control Protocol terminated",
["آدرس درخواستی توسط سرور رد شد"] = "The requested address was rejected by the server",
["رایانه به اینترنت متصل نیست"] = "The computer is not connected to the internet",
["رمزنگاری L2TP/IPsec شکست خورد - PSK یا تنظیمات سرور را بررسی کنید"] = "L2TP/IPsec encryption failed - check the PSK or server settings",
["مقصد در دسترس نیست (سرور خاموش یا آدرس اشتباه)"] = "Destination is unreachable (server is down or address is wrong)",
["Pre-Shared Key (PSK) اشتباه است"] = "Pre-Shared Key (PSK) is incorrect",
["رمزنگاری L2TP شکست خورد"] = "L2TP encryption failed",
["سرور VPN در دسترس نیست"] = "VPN server is not reachable",
["نوع شبکه را نمی‌توان مشخص کرد (فایروال مسدود کرده)"] = "Network type cannot be determined (firewall may be blocking it)",
["اتصال قبلی باعث تضاد شده"] = "A previous connection caused a conflict",
["خطای ناشناخته (exit code: {0})"] = "Unknown error (exit code: {0})",
["xray زودتر خارج شد (exit code {0})"] = "xray exited early (exit code {0})",
["sing-box bridge زودتر خارج شد (exit code {0})"] = "sing-box bridge exited early (exit code {0})",
["نوع تانل ناشناخته: {0}"] = "Unknown tunnel type: {0}",
["در حال لغو اتصال..."] = "Canceling connection...",
["آدرس سرور را وارد کنید"] = "Enter the server address",
["کانفیگ V2Ray را وارد کنید"] = "Enter the V2Ray config",
["کانفیگ OpenVPN (.ovpn) را وارد کنید"] = "Enter the OpenVPN (.ovpn) config",
["در حال آماده‌سازی OpenVPN..."] = "Preparing OpenVPN...",
["در حال اتصال..."] = "Connecting...",
["اتصال لغو شد"] = "Connection canceled",
["پراکسی متصل شد"] = "Proxy connected",
["در حال دریافت..."] = "Fetching...",
["در حال قطع اتصال..."] = "Disconnecting...",
["قطع شد"] = "Disconnected",
["اتصال VPN قطع شد..."] = "VPN connection dropped...",
["اتصال VPN به‌طور غیرمنتظره قطع شد"] = "VPN connection dropped unexpectedly",
["اتصال VPN به‌طور غیرمنتظره قطع شد.\nلطفاً دوباره متصل شوید."] = "VPN connection dropped unexpectedly.\nPlease reconnect.",
["قطع اتصال"] = "Disconnect",
["OpenVPN دوباره متصل شد؛ مسیرهای TunnelX در حال بروزرسانی است..."] = "OpenVPN reconnected; TunnelX routes are being updated...",
["OpenVPN دوباره متصل شد و مسیرها بروزرسانی شدند"] = "OpenVPN reconnected and routes were updated",
["آدرس سرور خالی است"] = "Server address is empty",
["remote سرور در فایل .ovpn پیدا نشد"] = "Server remote was not found in the .ovpn file",
["کانفیگ UDP است؛ تست دقیق قبل از اتصال ممکن نیست"] = "The config uses UDP; accurate pre-connect testing is not possible",
["هیچ remote قابل‌دسترسی نبود ({0})"] = "No reachable remote was found ({0})",
["خطا: {0}"] = "Error: {0}",
["در حال پینگ سرور..."] = "Pinging server...",
["پینگ سرور timeout شد"] = "Server ping timed out",
["فایل .ovpn انتخاب نشده است"] = ".ovpn file is not selected",
["کانفیگ خالی است"] = "Config is empty",
["endpoint سرور از کانفیگ تشخیص داده نشد"] = "Server endpoint could not be detected from the config",
["پارس کانفیگ ناموفق بود: {0}"] = "Failed to parse config: {0}",
["آدرس ss:// نامعتبر است"] = "Invalid ss:// address",
["سرور پیدا نشد"] = "Server not found",
["پورت نامعتبر است"] = "Invalid port",
["آدرس نامعتبر"] = "Invalid address",
[" [پایان]"] = " [done]",
["۱ پروفایل ذخیره‌شده"] = "1 saved profile",
["{0} پروفایل ذخیره‌شده"] = "{0} saved profiles",
["نوع اتصال نامشخص"] = "Unknown connection type",
["آدرس سرور هنوز وارد نشده"] = "Server address not entered yet",
["کانفیگ V2Ray/Xray آماده نمایش نیست"] = "V2Ray/Xray config is not ready to display",
["فایل OpenVPN انتخاب نشده"] = "OpenVPN file not selected",
["آدرس پراکسی هنوز وارد نشده"] = "Proxy address not entered yet",
["تغییرات این پروفایل به‌صورت خودکار ذخیره می‌شود"] = "Profile changes are saved automatically",
["در حال ذخیره..."] = "Saving...",
["پیش‌فرض"] = "Default",
["پروفایل {0}"] = "Profile {0}",
["{0} (کپی)"] = "{0} (copy)",
["کپی پروفایل"] = "Copy Profile",
["پروفایل «{0}» حذف شود؟"] = "Delete profile \"{0}\"?",
["حذف پروفایل"] = "Delete Profile",
["استارت‌آپ فعال شد.\n\n⚠️ برای کارکرد صحیح، پس از این نباید محل فایل اجرایی TunnelX را تغییر دهید."] = "Startup enabled.\n\n⚠️ For correct behavior, do not move the TunnelX executable after this.",
["TunnelX — استارت‌آپ"] = "TunnelX - Startup"
,
["برای سرورهای SOCKS5 یا HTTP Proxy، اطلاعات را جداگانه وارد کنید. TunnelX از همین پراکسی یک TUN داخلی می‌سازد تا اسپلیت‌تانلینگ برنامه‌های انتخابی مثل سایر نوع‌های اتصال کار کند."] = "For SOCKS5 or HTTP Proxy servers, enter the server details separately. TunnelX builds an internal TUN from this proxy so selected-app split tunneling works like other connection types.",
["اگر پراکسی شما نام کاربری یا رمز ندارد، فیلدهای احراز هویت را خالی بگذارید."] = "If your proxy has no username or password, leave the authentication fields empty.",
["🏓 سرور"] = "🏓 Server",
["قبل از اتصال، دسترسی و latency سرور را تست می‌کند"] = "Tests server reachability and latency before connecting",
["وضعیت و پروفایل فعال"] = "Status and active profile",
["مدت زمان اتصال فعلی از لحظه برقراری اتصال"] = "Current connection duration since connection started",
["مدت"] = "Duration",
["IP عمومی‌ای که مقصدهای اینترنتی شما را با آن می‌بینند"] = "The public IP seen by internet destinations",
["مجموع ترافیک ارسال و دریافت عبوری از تونل VPN (کل تونل)"] = "Total sent and received traffic through the VPN tunnel",
["تونل"] = "Tunnel",
["نمایش تشخیصی ترافیک خارج از تونل. این عدد در مصرف تونل و تاریخچه ثبت نمی‌شود."] = "Diagnostic view of traffic outside the tunnel. This number is not recorded as tunnel usage or history.",
["خارج تونل"] = "Direct",
["🌐 عبور کل سیستم"] = "🌐 Full Route",
["روشن: کل ویندوز از تونل. خاموش: فقط برنامه‌ها و قوانین انتخابی."] = "On: all Windows traffic uses the tunnel. Off: only selected apps and rules.",
["اگر برنامه‌ای خودکار وارد تونل نشد، این آدرس را در تنظیمات Proxy همان برنامه وارد کنید."] = "If an app does not enter the tunnel automatically, enter this address in that app's proxy settings.",
["🧦 پروکسی دستی"] = "🧦 Manual Proxy",
["این آدرس داخلی را در برنامه‌هایی وارد کنید که تنظیم Proxy جداگانه دارند یا خودکار وارد تونل نمی‌شوند."] = "Use this internal address in apps with separate proxy settings or apps that do not enter the tunnel automatically.",
["🏓 تست مسیر"] = "🏓 Route Test",
["یک دامنه یا IP را از داخل تونل تست کنید."] = "Test a domain or IP from inside the tunnel.",
["IP یا دامنه مقصد برای تست از داخل تونل"] = "Destination IP or domain to test through the tunnel",
["تست همین مقصد از داخل مسیر تونل"] = "Test this destination through the tunnel route",
["دسترسی به سرور همین اتصال را تست می‌کند"] = "Tests reachability to this connection's server",
["قطع اتصال فعلی"] = "Disconnect current connection",
["💡 در حالت انتخابی، فقط برنامه‌های فعال در تب "] = "💡 In selected mode, only apps enabled in the ",
["«برنامه‌ها»"] = "\"Apps\"",
[" از تونل عبور می‌کنند؛ بقیه مستقیم می‌مانند."] = " tab use the tunnel; the rest stays direct.",
["📌 برای تلگرام، واتس‌اپ و برنامه‌های Store، "] = "📌 For Telegram, WhatsApp, and Store apps, also add ",
[" را هم به لیست تونل اضافه کنید."] = " to the tunnel list."
,
["TunnelX — جزئیات عملکرد"] = "TunnelX - Runtime Details",
["🔍 جزئیات عملکرد — TunnelX"] = "🔍 Runtime Details - TunnelX",
["🗑 پاک کردن"] = "🗑 Clear",
["📋 کپی"] = "📋 Copy",
["لاگ‌ها"] = "Logs",
["خطا در کپی کردن:\n{0}"] = "Copy failed:\n{0}",
["لاگ‌ها پاک شدند"] = "Logs cleared",
["لاگی برای کپی وجود ندارد"] = "There are no logs to copy",
["لاگ کپی شد"] = "Log copied",
["آخرین خطا یا هشدار پیدا نشد"] = "No recent error or warning found",
["آخرین خطا یا هشدار کپی شد"] = "Latest error or warning copied",
["کپی لاگ ناموفق بود"] = "Failed to copy logs",
["باز کردن لینک ناموفق بود: {0}"] = "Failed to open link: {0}",
["کپی ناموفق بود: {0}"] = "Copy failed: {0}",
["آیا مطمئن هستید؟"] = "Are you sure?",
["در حال اجرای OpenVPN در حالت Split..."] = "Running OpenVPN in split mode...",
["فقط OpenVPN Connect پیدا شد. برای Split Tunneling باید OpenVPN Community (openvpn.exe) هم نصب باشد."] = "Only OpenVPN Connect was found. Split tunneling requires OpenVPN Community (openvpn.exe).",
["OpenVPN Community پیدا نشد. برای Split Tunneling باید openvpn.exe نصب باشد."] = "OpenVPN Community was not found. Split tunneling requires openvpn.exe.",
["کانفیگ OpenVPN (.ovpn) وارد نشده است."] = "OpenVPN (.ovpn) config is missing.",
["OpenVPN Community نصب نیست؛ ابتدا از لینک رسمی نصب کنید"] = "OpenVPN Community is not installed; install it from the official link first",
["OpenVPN در حال اتصال است؛ مسیرهای پیش‌فرض آن برای Split Tunnel نادیده گرفته می‌شوند..."] = "OpenVPN is connecting; its default routes are ignored for split tunneling...",
["OpenVPN زودتر از اتصال بسته شد (exit={0})"] = "OpenVPN exited before connecting (exit={0})",
["منتظر بالا آمدن آداپتر OpenVPN... ({0}s)"] = "Waiting for the OpenVPN adapter... ({0}s)",
["آداپتور OpenVPN بالا نیامد. لاگ OpenVPN را بررسی کنید؛ ممکن است ریموت اول پاسخ ندهد یا احراز هویت/شبکه مشکل داشته باشد."] = "The OpenVPN adapter did not come up. Check the OpenVPN log; the first remote may not respond or authentication/network may be failing.",
["OpenVPN متصل شد (Split Tunnel)"] = "OpenVPN connected (Split Tunnel)",
["در حال قطع اتصال OpenVPN..."] = "Disconnecting OpenVPN...",
[" • 📊 تونل "] = " • 📊 Tunnel ",
["برای سرورهای SOCKS5 یا HTTP Proxy، اطلاعات سرور را جداگانه وارد کنید."] = "For SOCKS5 or HTTP Proxy servers, enter the server details separately.",
["TunnelX فایل .ovpn را با OpenVPN Community اجرا می‌کند و مسیر/DNS پیش‌فرض OpenVPN را کنترل می‌کند تا فقط برنامه‌های انتخابی از تونل عبور کنند."] = "TunnelX runs the .ovpn file with OpenVPN Community and controls OpenVPN's default route/DNS so only selected apps use the tunnel.",
["OpenVPN Connect به‌تنهایی کافی نیست؛ اگر Community نصب نباشد، از دکمه دانلود پایین استفاده کنید."] = "OpenVPN Connect alone is not enough; if Community is not installed, use the download button below.",
["انتخاب برنامه"] = "Select App",
["لایسنس: {0}"] = "License: {0}",
["برای اتصال VPN ویندوز. آدرس سرور، نام کاربری، رمز عبور و Pre-Shared Key لازم است. اگر وصل نشد، PSK، فایروال و تنظیمات VPN ویندوز را بررسی کنید."] = "For Windows VPN connection. Server address, username, password, and Pre-Shared Key are required. If it does not connect, check the PSK, firewall, and Windows VPN settings.",
["لینک یا JSON کانفیگ را وارد کنید یا از کلیپ‌بورد پیست کنید. TunnelX معمولاً sing-box را اجرا می‌کند و برای قابلیت‌هایی مثل xhttp از Xray-core استفاده می‌کند."] = "Enter a config link or JSON, or paste it from the clipboard. TunnelX usually runs sing-box and uses Xray-core for features such as xhttp.",
["برای پراکسی خارجی آماده. نوع پراکسی، آدرس، پورت و در صورت نیاز نام کاربری/رمز را وارد کنید. این با پراکسی داخلی 127.0.0.1 فرق دارد."] = "For an external ready proxy. Enter protocol, address, port, and credentials if needed. This is different from the internal 127.0.0.1 proxy.",
["فایل ovpn را انتخاب کنید. OpenVPN Community باید جداگانه نصب باشد؛ OpenVPN Connect برای Split Tunneling مناسب نیست. اگر سرور رمز می‌خواهد، نام کاربری و رمز را در TunnelX وارد کنید."] = "Choose the ovpn file. OpenVPN Community must be installed separately; OpenVPN Connect is not suitable for split tunneling. If the server requires credentials, enter them in TunnelX.",
["از لیست برنامه‌های پیدا شده انتخاب کنید یا فایل exe را دستی اضافه کنید. برای Store/MSIX/WebView2 برنامه را باز نگه دارید و بروزرسانی لیست را بزنید."] = "Select from discovered apps or manually add an exe file. For Store/MSIX/WebView2 apps, keep the app open and refresh the list.",
["«مستقیم بماند» یعنی مقصد از تونل عبور نکند. «از تونل عبور کند» یعنی مقصد حتی بدون انتخاب برنامه وارد تونل شود. دامنه‌ها زیردامنه‌ها را هم پوشش می‌دهند."] = "\"Stay Direct\" means the destination bypasses the tunnel. \"Use Tunnel\" means the destination enters the tunnel even without selecting an app. Domains also cover subdomains.",
["پورت پراکسی محلی، MTU خودکار، DNS Optimization، Game Mode، اجرای خودکار ویندوز و اتصال خودکار اینجاست."] = "Local proxy port, automatic MTU, DNS Optimization, Game Mode, Windows startup, and auto-connect are here.",
["مدت اتصال، IP، مصرف تونل، مصرف خارج تونل، سلامت Split Tunnel، مصرف برنامه‌ها و تاریخچه اتصال‌ها را نشان می‌دهد."] = "Shows duration, IP, tunnel usage, direct usage, split tunnel health, app usage, and connection history.",
["جزئیات و لاگ‌ها"] = "Details and Logs",
["از دکمه جزئیات، لاگ‌ها را با فیلتر خطا، هشدار، DNS یا Route ببینید. قبل از ارسال عمومی لاگ، رمزها، کلیدها، UUID و endpoint خصوصی را حذف کنید."] = "Use Details to view logs filtered by error, warning, DNS, or route. Before sharing logs publicly, remove passwords, keys, UUIDs, and private endpoints.",
["اتصال برقرار نمی‌شود"] = "Connection Does Not Start",
["برنامه را با Administrator اجرا کنید. فایروال، آنتی‌ویروس، آدرس سرور، پورت، رمزها، PSK، نصب OpenVPN Community و اعتبار کانفیگ را بررسی کنید."] = "Run the app as Administrator. Check firewall, antivirus, server address, port, credentials, PSK, OpenVPN Community installation, and config validity.",
["ترافیک برنامه از تونل عبور نمی‌کند"] = "App Traffic Does Not Use Tunnel",
["برنامه را در تب برنامه‌ها فعال کنید. اگر چندپردازشی است، برنامه را باز نگه دارید و لیست برنامه‌ها را دوباره بارگذاری کنید."] = "Enable the app in the Apps tab. If it is multi-process, keep it open and reload the app list.",
["پراکسی کار نمی‌کند"] = "Proxy Does Not Work",
["برای پروفایل پراکسی، آدرس، پورت، نوع و اطلاعات ورود را بررسی کنید. برای ابزارهای محلی، آدرس 127.0.0.1 و پورت تنظیمات را وارد کنید."] = "For proxy profiles, check address, port, protocol, and credentials. For local tools, enter 127.0.0.1 and the configured port.",
["DNS، IPv6 یا Leak غیرعادی است"] = "DNS, IPv6, or Leak Looks Wrong",
["یک بار قطع و وصل کنید تا مسیرها و قوانین DNS دوباره ساخته شوند. اگر مشکل ماند، لاگ‌های DNS و Route را بررسی کنید."] = "Disconnect and reconnect once so routes and DNS rules are rebuilt. If it persists, check DNS and Route logs."
};
}
+480
View File
@@ -0,0 +1,480 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace AppTunnel.Services;
/// <summary>
/// Mixed SOCKS5/HTTP CONNECT proxy that forces every outgoing connection to
/// use the VPN adapter by binding the outbound socket to the VPN local IP.
/// Protocol auto-detection on the first byte: 0x05 → SOCKS5, 'C' → HTTP CONNECT.
/// </summary>
internal sealed class MixedProxyServer
{
private readonly int _listenPort;
private TcpListener? _listener;
private CancellationTokenSource? _cts;
private IPEndPoint? _bindEndpoint;
private long _connCount;
private long _connActive;
private Action<IPAddress>? _ensureRoute;
public MixedProxyServer(int listenPort = 1080)
{
_listenPort = listenPort;
}
public bool IsRunning => _listener != null;
public long TotalConnections => Interlocked.Read(ref _connCount);
public long ActiveConnections => Interlocked.Read(ref _connActive);
public void Start(string vpnLocalIp, Action<IPAddress>? ensureRoute = null)
{
if (_listener != null) return;
if (!IPAddress.TryParse(vpnLocalIp, out var bindIp))
{
Logger.Error($"[MIXED] Invalid VPN local IP '{vpnLocalIp}', server not started");
return;
}
_bindEndpoint = new IPEndPoint(bindIp, 0);
_ensureRoute = ensureRoute;
try
{
_listener = new TcpListener(IPAddress.Loopback, _listenPort);
_listener.Start();
_cts = new CancellationTokenSource();
Logger.Info($"[MIXED] Listening on 127.0.0.1:{_listenPort}, outbound bind={vpnLocalIp}");
_ = Task.Run(() => AcceptLoop(_cts.Token));
}
catch (Exception ex)
{
Logger.Error($"[MIXED] Failed to start listener on port {_listenPort}: {ex.Message}");
_listener = null;
}
}
public void Stop()
{
try { _cts?.Cancel(); } catch { }
try { _listener?.Stop(); } catch { }
_listener = null;
_cts = null;
}
private async Task AcceptLoop(CancellationToken ct)
{
var listener = _listener!;
while (!ct.IsCancellationRequested)
{
TcpClient client;
try
{
client = await listener.AcceptTcpClientAsync(ct);
}
catch (OperationCanceledException) { break; }
catch (ObjectDisposedException) { break; }
catch (Exception ex)
{
Logger.Warning($"[MIXED] Accept error: {ex.Message}");
continue;
}
_ = Task.Run(() => HandleClientAsync(client, ct));
}
}
private async Task HandleClientAsync(TcpClient client, CancellationToken ct)
{
long connId = Interlocked.Increment(ref _connCount);
Interlocked.Increment(ref _connActive);
try
{
client.NoDelay = true;
using var stream = client.GetStream();
stream.ReadTimeout = 15000;
stream.WriteTimeout = 15000;
// Peek first byte to decide protocol
var firstByte = new byte[1];
int peeked = await stream.ReadAsync(firstByte.AsMemory(0, 1), ct);
if (peeked == 0) return;
if (firstByte[0] == 0x05)
{
await HandleSocks5Async(stream, firstByte, connId, ct);
}
else if (firstByte[0] == (byte)'C' || firstByte[0] == (byte)'c')
{
await HandleHttpConnectAsync(stream, firstByte, connId, ct);
}
else
{
Logger.Warning($"[MIXED #{connId}] Unknown first byte 0x{firstByte[0]:X2}, closing");
try { client.Close(); } catch { }
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Logger.Warning($"[MIXED #{connId}] error: {ex.Message}");
}
finally
{
try { client.Dispose(); } catch { }
Interlocked.Decrement(ref _connActive);
}
}
// ─────────────────────────────────────────────────────────────────────────
// SOCKS5 handler (existing logic preserved)
// ─────────────────────────────────────────────────────────────────────────
private async Task HandleSocks5Async(NetworkStream stream, byte[] firstByte, long connId, CancellationToken ct)
{
TcpClient? upstream = null;
try
{
// We already read the first byte (0x05). Now read NMETHODS + methods.
var hdr = new byte[1];
if (!await ReadExactAsync(stream, hdr, 0, 1, ct)) return;
int nMethods = hdr[0];
if (nMethods <= 0 || nMethods > 32) return;
var methods = new byte[nMethods];
if (!await ReadExactAsync(stream, methods, 0, nMethods, ct)) return;
// No-auth only
if (!methods.Contains((byte)0x00))
{
await stream.WriteAsync(new byte[] { 0x05, 0xFF }, ct);
return;
}
await stream.WriteAsync(new byte[] { 0x05, 0x00 }, ct);
// Request
var req = new byte[4];
if (!await ReadExactAsync(stream, req, 0, 4, ct)) return;
if (req[0] != 0x05 || req[1] != 0x01)
{
await WriteSocks5ReplyAsync(stream, 0x07, ct);
return;
}
byte atyp = req[3];
string host;
IPAddress? remoteIp = null;
switch (atyp)
{
case 0x01:
{
var addr = new byte[4];
if (!await ReadExactAsync(stream, addr, 0, 4, ct)) return;
remoteIp = new IPAddress(addr);
host = remoteIp.ToString();
break;
}
case 0x03:
{
var lenBuf = new byte[1];
if (!await ReadExactAsync(stream, lenBuf, 0, 1, ct)) return;
int dlen = lenBuf[0];
var dbuf = new byte[dlen];
if (!await ReadExactAsync(stream, dbuf, 0, dlen, ct)) return;
host = Encoding.ASCII.GetString(dbuf);
break;
}
case 0x04:
{
var addr = new byte[16];
if (!await ReadExactAsync(stream, addr, 0, 16, ct)) return;
remoteIp = new IPAddress(addr);
host = remoteIp.ToString();
break;
}
default:
await WriteSocks5ReplyAsync(stream, 0x08, ct);
return;
}
var portBuf = new byte[2];
if (!await ReadExactAsync(stream, portBuf, 0, 2, ct)) return;
int port = (portBuf[0] << 8) | portBuf[1];
if (remoteIp == null)
{
try
{
remoteIp = await DnsResolverCache.ResolveFirstIpv4Async(host, ct);
if (remoteIp == null)
{
Logger.Warning($"[SOCKS5 #{connId}] DNS for '{host}' returned no IPv4");
await WriteSocks5ReplyAsync(stream, 0x04, ct);
return;
}
}
catch (Exception dnsEx)
{
Logger.Warning($"[SOCKS5 #{connId}] DNS resolve '{host}' failed: {dnsEx.Message}");
await WriteSocks5ReplyAsync(stream, 0x04, ct);
return;
}
}
try { _ensureRoute?.Invoke(remoteIp); } catch { }
upstream = await DialUpstreamAsync(remoteIp, port, connId, ct);
if (upstream == null)
{
await WriteSocks5ReplyAsync(stream, 0x05, ct);
return;
}
Logger.Info($"[SOCKS5 #{connId}] CONNECT → {host}:{port} (via {_bindEndpoint!.Address})");
await WriteSocks5ReplyAsync(stream, 0x00, ct);
await RelayAsync(stream, upstream.GetStream(), ct);
}
finally
{
try { upstream?.Dispose(); } catch { }
}
}
// ─────────────────────────────────────────────────────────────────────────
// HTTP CONNECT handler
// ─────────────────────────────────────────────────────────────────────────
private async Task HandleHttpConnectAsync(NetworkStream stream, byte[] firstByte, long connId, CancellationToken ct)
{
TcpClient? upstream = null;
try
{
// firstByte[0] is 'C' or 'c'. Read the rest of the first line.
var sb = new StringBuilder(Encoding.ASCII.GetString(firstByte));
var lineBuf = new byte[1];
// Read until \r\n
while (!ct.IsCancellationRequested)
{
int n = await stream.ReadAsync(lineBuf.AsMemory(0, 1), ct);
if (n == 0) return;
char c = (char)lineBuf[0];
if (c == '\n')
{
string line = sb.ToString().TrimEnd('\r');
if (string.IsNullOrEmpty(line)) continue; // skip empty lines before request
break;
}
sb.Append(c);
if (sb.Length > 4096) return; // line too long
}
string firstLine = sb.ToString().TrimEnd('\r');
var parts = firstLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 3 || !parts[0].Equals("CONNECT", StringComparison.OrdinalIgnoreCase))
{
await WriteHttpErrorAsync(stream, "400 Bad Request", ct);
return;
}
var target = parts[1];
if (!parts[2].StartsWith("HTTP/", StringComparison.OrdinalIgnoreCase))
{
await WriteHttpErrorAsync(stream, "400 Bad Request", ct);
return;
}
// Read remaining headers until empty line
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var headerSb = new StringBuilder();
int emptyLineCount = 0;
while (!ct.IsCancellationRequested)
{
int n = await stream.ReadAsync(lineBuf.AsMemory(0, 1), ct);
if (n == 0) return;
char c = (char)lineBuf[0];
if (c == '\n')
{
string hline = headerSb.ToString().TrimEnd('\r');
if (string.IsNullOrEmpty(hline))
{
emptyLineCount++;
if (emptyLineCount >= 1) break;
}
else
{
emptyLineCount = 0;
var colonIdx = hline.IndexOf(':');
if (colonIdx > 0)
{
var key = hline[..colonIdx].Trim();
var val = hline[(colonIdx + 1)..].Trim();
headers[key] = val;
}
}
headerSb.Clear();
if (headerSb.Length > 8192) return; // headers too long
}
else
{
headerSb.Append(c);
}
}
// Parse target host:port
string host;
int port;
var targetParts = target.Split(':', StringSplitOptions.RemoveEmptyEntries);
if (targetParts.Length == 2 && int.TryParse(targetParts[1], out port))
{
host = targetParts[0];
}
else
{
host = target;
port = 443;
}
IPAddress? remoteIp = null;
if (IPAddress.TryParse(host, out var parsedIp))
{
remoteIp = parsedIp;
}
else
{
try
{
remoteIp = await DnsResolverCache.ResolveFirstIpv4Async(host, ct);
if (remoteIp == null)
{
Logger.Warning($"[HTTP #{connId}] DNS for '{host}' returned no IPv4");
await WriteHttpErrorAsync(stream, "502 Bad Gateway", ct);
return;
}
}
catch (Exception dnsEx)
{
Logger.Warning($"[HTTP #{connId}] DNS resolve '{host}' failed: {dnsEx.Message}");
await WriteHttpErrorAsync(stream, "502 Bad Gateway", ct);
return;
}
}
try { _ensureRoute?.Invoke(remoteIp); } catch { }
upstream = await DialUpstreamAsync(remoteIp, port, connId, ct);
if (upstream == null)
{
await WriteHttpErrorAsync(stream, "502 Bad Gateway", ct);
return;
}
Logger.Info($"[HTTP #{connId}] CONNECT → {host}:{port} (via {_bindEndpoint!.Address})");
await WriteHttpAsync(stream, "HTTP/1.1 200 Connection established\r\n\r\n", ct);
await RelayAsync(stream, upstream.GetStream(), ct);
}
finally
{
try { upstream?.Dispose(); } catch { }
}
}
// ─────────────────────────────────────────────────────────────────────────
// Shared helpers
// ─────────────────────────────────────────────────────────────────────────
private async Task<TcpClient?> DialUpstreamAsync(IPAddress remoteIp, int port, long connId, CancellationToken ct)
{
const int maxAttempts = 2;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
var client = new TcpClient(AddressFamily.InterNetwork);
client.NoDelay = true;
try
{
client.Client.Bind(_bindEndpoint!);
}
catch (Exception bex)
{
Logger.Warning($"[MIXED #{connId}] bind to VPN IP failed: {bex.Message}");
client.Dispose();
return null;
}
try
{
using var dialCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
dialCts.CancelAfter(TimeSpan.FromSeconds(15));
await client.ConnectAsync(remoteIp, port, dialCts.Token);
return client;
}
catch (Exception dex) when (
attempt < maxAttempts &&
dex is SocketException sex &&
(sex.SocketErrorCode == SocketError.NetworkUnreachable ||
sex.SocketErrorCode == SocketError.HostUnreachable))
{
Logger.Info($"[MIXED #{connId}] connect {remoteIp}:{port} unreachable, retrying...");
client.Dispose();
await Task.Delay(600, ct);
}
catch (Exception dex)
{
Logger.Warning($"[MIXED #{connId}] connect {remoteIp}:{port} failed: {dex.Message}");
client.Dispose();
return null;
}
}
return null;
}
private static async Task RelayAsync(NetworkStream clientStream, NetworkStream upstreamStream, CancellationToken ct)
{
var t1 = PumpAsync(clientStream, upstreamStream, ct);
var t2 = PumpAsync(upstreamStream, clientStream, ct);
await Task.WhenAny(t1, t2);
try { clientStream.Socket?.Shutdown(SocketShutdown.Both); } catch { }
try { upstreamStream.Socket?.Shutdown(SocketShutdown.Both); } catch { }
await Task.WhenAll(t1, t2);
}
private static async Task WriteSocks5ReplyAsync(NetworkStream s, byte rep, CancellationToken ct)
{
var buf = new byte[] { 0x05, rep, 0x00, 0x01, 0, 0, 0, 0, 0, 0 };
try { await s.WriteAsync(buf, ct); } catch { }
}
private static async Task WriteHttpErrorAsync(NetworkStream s, string status, CancellationToken ct, string extraHeaders = "")
{
var msg = $"HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n{extraHeaders}\r\n";
try { await s.WriteAsync(Encoding.UTF8.GetBytes(msg), ct); } catch { }
}
private static async Task WriteHttpAsync(NetworkStream s, string text, CancellationToken ct)
{
try { await s.WriteAsync(Encoding.UTF8.GetBytes(text), ct); } catch { }
}
private static async Task<bool> ReadExactAsync(NetworkStream s, byte[] buf, int offset, int count, CancellationToken ct)
{
int read = 0;
while (read < count)
{
int n = await s.ReadAsync(buf.AsMemory(offset + read, count - read), ct);
if (n <= 0) return false;
read += n;
}
return true;
}
private static async Task PumpAsync(NetworkStream src, NetworkStream dst, CancellationToken ct)
{
var buf = new byte[16384];
try
{
while (!ct.IsCancellationRequested)
{
int n = await src.ReadAsync(buf, ct);
if (n <= 0) break;
await dst.WriteAsync(buf.AsMemory(0, n), ct);
}
}
catch { }
}
}
+766
View File
@@ -0,0 +1,766 @@
using System.Diagnostics;
using System.IO;
using System.Collections.Concurrent;
using System.Net;
using System.Net.NetworkInformation;
using System.Text;
using AppTunnel.Models;
namespace AppTunnel.Services;
/// <summary>
/// ITunnelProvider implementation for OpenVPN.
/// Launches user-installed OpenVPN Community with a split-compatible temporary
/// config and waits for its network adapter to come Up. TunnelX does not bundle
/// OpenVPN.
/// </summary>
public class OpenVpnTunnelProvider : ITunnelProvider
{
private static readonly UTF8Encoding Utf8NoBom = new(false);
private static string OpenVpnWorkDir => Path.Combine(AppTunnel.App.AppDataDir, "openvpn");
private static string TunnelXOpenVpnPidPath => Path.Combine(OpenVpnWorkDir, "tunnelx-openvpn.pid");
private Process? _process;
private int _vpnInterfaceIndex = -1;
private string _routeGatewayIp = "";
private string _connectedRemoteIp = "";
private int _connectedRemotePort;
private string _assignedLocalIp = "";
private readonly ConcurrentQueue<string> _recentOpenVpnOutput = new();
public ConnectionStatus Status { get; } = new();
public async Task<bool> ConnectAsync(ServerConfig config, CancellationToken ct)
{
_vpnInterfaceIndex = -1;
_routeGatewayIp = "";
_connectedRemoteIp = "";
_connectedRemotePort = 0;
_assignedLocalIp = "";
while (_recentOpenVpnOutput.TryDequeue(out _)) { }
Status.State = ConnectionState.Connecting;
Status.Message = LocalizationService.Instance.T("در حال اجرای OpenVPN در حالت Split...");
Logger.Info("[OpenVPN] ConnectAsync started");
try
{
var openVpnExe = ResolveOpenVpnExecutable(config);
if (string.IsNullOrWhiteSpace(openVpnExe))
{
Status.State = ConnectionState.Error;
Status.Message = IsOpenVpnConnectInstalled()
? LocalizationService.Instance.T("فقط OpenVPN Connect پیدا شد. برای Split Tunneling باید OpenVPN Community (openvpn.exe) هم نصب باشد.")
: LocalizationService.Instance.T("OpenVPN Community پیدا نشد. برای Split Tunneling باید openvpn.exe نصب باشد.");
Logger.Error("[OpenVPN] Executable not found. Searched:");
foreach (var p in GetCandidatePaths())
Logger.Error($" '{p}' → {(File.Exists(p) ? "FOUND" : "not found")}");
foreach (var p in GetOpenVpnConnectPaths())
Logger.Warning($"[OpenVPN] OpenVPN Connect check: '{p}' → {(Directory.Exists(p) || File.Exists(p) ? "FOUND (GUI only, not split-compatible)" : "not found")}");
return false;
}
if (string.IsNullOrWhiteSpace(config.OpenVpnConfig))
{
Status.State = ConnectionState.Error;
Status.Message = LocalizationService.Instance.T("کانفیگ OpenVPN (.ovpn) وارد نشده است.");
return false;
}
await KillStaleTunnelXOpenVpnProcessAsync();
var preparedConfigPath = PrepareSplitCompatibleConfig(config.OpenVpnConfig, config.OpenVpnUsername, config.OpenVpnPassword);
var remoteHost = TryExtractRemoteHost(config.OpenVpnConfig);
LogRemoteCandidates(config.OpenVpnConfig);
Logger.Info($"[OpenVPN] Launching: {openVpnExe}");
Logger.Info($"[OpenVPN] Prepared split config: {preparedConfigPath}");
_process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = openVpnExe,
Arguments = $"--config \"{preparedConfigPath}\" --verb 3",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
},
EnableRaisingEvents = true
};
_process.Start();
WriteTunnelXOpenVpnPid(_process.Id);
_ = Task.Run(() => PumpOpenVpnOutputAsync(_process.StandardOutput, ct));
_ = Task.Run(() => PumpOpenVpnOutputAsync(_process.StandardError, ct));
Logger.Info($"[OpenVPN] Process started PID={_process.Id}");
Status.Message = LocalizationService.Instance.T("OpenVPN در حال اتصال است؛ مسیرهای پیش‌فرض آن برای Split Tunnel نادیده گرفته می‌شوند...");
Logger.Info("[OpenVPN] Waiting up to 180s for VPN adapter to come Up...");
var deadline = DateTime.UtcNow.AddSeconds(180);
while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
{
var idx = !string.IsNullOrWhiteSpace(_assignedLocalIp)
? FindOpenVpnInterfaceIndex(_assignedLocalIp)
: -1;
if (idx > 0 &&
!string.IsNullOrWhiteSpace(_routeGatewayIp) &&
!string.IsNullOrWhiteSpace(_connectedRemoteIp) &&
_connectedRemotePort > 0)
{
Logger.Info($"[OpenVPN] Adapter came Up: index={idx}");
_vpnInterfaceIndex = idx;
break;
}
var remaining = (int)(deadline - DateTime.UtcNow).TotalSeconds;
if (_process.HasExited)
{
Status.State = ConnectionState.Error;
Status.Message = LocalizationService.Instance.Format("OpenVPN زودتر از اتصال بسته شد (exit={0})", _process.ExitCode);
return false;
}
Status.Message = LocalizationService.Instance.Format("منتظر بالا آمدن آداپتر OpenVPN... ({0}s)", remaining);
await Task.Delay(500, ct);
}
if (_vpnInterfaceIndex <= 0)
{
LogRecentOpenVpnOutput();
Logger.Error("[OpenVPN] Adapter not found after timeout. Current NICs:");
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
Logger.Error($" name='{nic.Name}' desc='{nic.Description}' status={nic.OperationalStatus}");
Status.State = ConnectionState.Error;
Status.Message = LocalizationService.Instance.T("آداپتور OpenVPN بالا نیامد. لاگ OpenVPN را بررسی کنید؛ ممکن است ریموت اول پاسخ ندهد یا احراز هویت/شبکه مشکل داشته باشد.");
await KillProcessAsync();
return false;
}
Status.State = ConnectionState.Connected;
Status.ConnectedSince = DateTime.Now;
Status.VpnInterfaceIndex = _vpnInterfaceIndex;
Status.VpnLocalIp = GetInterfaceIpv4(_vpnInterfaceIndex);
Status.VpnServerIp = !string.IsNullOrWhiteSpace(_connectedRemoteIp)
? _connectedRemoteIp
: ResolveRemoteForRouting(remoteHost);
Status.VpnServerPort = _connectedRemotePort;
Status.VpnGatewayIp = _routeGatewayIp;
Status.Message = LocalizationService.Instance.T("OpenVPN متصل شد (Split Tunnel)");
Logger.Info($"[OpenVPN] Connected. LocalIP={Status.VpnLocalIp} Gateway={Status.VpnGatewayIp} Remote={Status.VpnServerIp}:{Status.VpnServerPort}");
return true;
}
catch (OperationCanceledException)
{
Status.State = ConnectionState.Disconnected;
Status.Message = LocalizationService.Instance.T("اتصال لغو شد");
await KillProcessAsync();
return false;
}
catch (Exception ex)
{
Status.State = ConnectionState.Error;
Status.Message = LocalizationService.Instance.Format("خطا: {0}", ex.Message);
Logger.Error("OpenVpnTunnelProvider.ConnectAsync failed", ex);
await KillProcessAsync();
return false;
}
}
public async Task DisconnectAsync()
{
Status.State = ConnectionState.Disconnecting;
Status.Message = LocalizationService.Instance.T("در حال قطع اتصال OpenVPN...");
await KillProcessAsync();
_vpnInterfaceIndex = -1;
Status.State = ConnectionState.Disconnected;
Status.ConnectedSince = null;
Status.VpnLocalIp = string.Empty;
Status.VpnServerIp = string.Empty;
Status.VpnServerPort = 0;
Status.VpnGatewayIp = string.Empty;
Status.VpnInterfaceIndex = -1;
Status.Message = LocalizationService.Instance.T("قطع شد");
}
public bool IsInterfaceUp()
{
TryUpdateConnectedStatusFromCapturedState();
if (_vpnInterfaceIndex < 0) return false;
try
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
var ipv4 = nic.GetIPProperties().GetIPv4Properties();
if (ipv4 != null && ipv4.Index == _vpnInterfaceIndex)
return nic.OperationalStatus == OperationalStatus.Up;
}
}
catch { }
return false;
}
private bool TryUpdateConnectedStatusFromCapturedState()
{
if (Status.State != ConnectionState.Connected)
return false;
if (string.IsNullOrWhiteSpace(_assignedLocalIp) ||
string.IsNullOrWhiteSpace(_routeGatewayIp) ||
string.IsNullOrWhiteSpace(_connectedRemoteIp) ||
_connectedRemotePort <= 0)
return false;
var interfaceIndex = FindOpenVpnInterfaceIndex(_assignedLocalIp);
if (interfaceIndex <= 0)
return false;
var changed =
Status.VpnInterfaceIndex != interfaceIndex ||
!string.Equals(Status.VpnLocalIp, _assignedLocalIp, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(Status.VpnGatewayIp, _routeGatewayIp, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(Status.VpnServerIp, _connectedRemoteIp, StringComparison.OrdinalIgnoreCase) ||
Status.VpnServerPort != _connectedRemotePort;
if (!changed)
return true;
_vpnInterfaceIndex = interfaceIndex;
Status.VpnInterfaceIndex = interfaceIndex;
Status.VpnLocalIp = _assignedLocalIp;
Status.VpnGatewayIp = _routeGatewayIp;
Status.VpnServerIp = _connectedRemoteIp;
Status.VpnServerPort = _connectedRemotePort;
Status.Message = LocalizationService.Instance.T("OpenVPN متصل شد (Split Tunnel)");
Logger.Warning($"[OpenVPN] Runtime endpoint changed. LocalIP={Status.VpnLocalIp} Gateway={Status.VpnGatewayIp} Remote={Status.VpnServerIp}:{Status.VpnServerPort} IF={Status.VpnInterfaceIndex}");
return true;
}
private async Task KillProcessAsync()
{
var processId = _process?.Id;
try
{
if (_process is { HasExited: false })
{
_process.Kill(entireProcessTree: true);
await _process.WaitForExitAsync();
}
}
catch { }
finally
{
try { _process?.Dispose(); } catch { }
_process = null;
DeleteTunnelXOpenVpnPid(processId);
}
}
private static async Task KillStaleTunnelXOpenVpnProcessAsync()
{
try
{
if (!File.Exists(TunnelXOpenVpnPidPath))
return;
var raw = await File.ReadAllTextAsync(TunnelXOpenVpnPidPath);
if (!int.TryParse(raw.Trim(), out var pid))
{
DeleteTunnelXOpenVpnPid(null);
return;
}
using var process = Process.GetProcessById(pid);
if (process.HasExited)
{
DeleteTunnelXOpenVpnPid(pid);
return;
}
if (!process.ProcessName.Equals("openvpn", StringComparison.OrdinalIgnoreCase))
{
Logger.Warning($"[OpenVPN] Stale pid file ignored; PID {pid} is '{process.ProcessName}', not openvpn.");
DeleteTunnelXOpenVpnPid(pid);
return;
}
Logger.Warning($"[OpenVPN] Cleaning up stale TunnelX OpenVPN process PID={pid}");
process.Kill(entireProcessTree: true);
await process.WaitForExitAsync();
DeleteTunnelXOpenVpnPid(pid);
}
catch (ArgumentException)
{
DeleteTunnelXOpenVpnPid(null);
}
catch (Exception ex)
{
Logger.Warning($"[OpenVPN] Stale process cleanup failed: {ex.Message}");
}
}
private static void WriteTunnelXOpenVpnPid(int pid)
{
try
{
Directory.CreateDirectory(OpenVpnWorkDir);
File.WriteAllText(TunnelXOpenVpnPidPath, pid.ToString(), Utf8NoBom);
}
catch (Exception ex)
{
Logger.Warning($"[OpenVPN] Could not write pid file: {ex.Message}");
}
}
private static void DeleteTunnelXOpenVpnPid(int? processId)
{
try
{
if (!File.Exists(TunnelXOpenVpnPidPath))
return;
if (processId.HasValue)
{
var raw = File.ReadAllText(TunnelXOpenVpnPidPath).Trim();
if (int.TryParse(raw, out var pid) && pid != processId.Value)
return;
}
File.Delete(TunnelXOpenVpnPidPath);
}
catch { }
}
private static string? ResolveOpenVpnExecutable(ServerConfig config)
{
foreach (var c in GetCandidatePaths())
{
Logger.Debug($"[OpenVPN] Checking: '{c}' -> {(File.Exists(c) ? "FOUND" : "not found")}");
if (File.Exists(c)) return c;
}
return null;
}
public static string? FindOpenVpnExecutable()
{
foreach (var c in GetCandidatePaths())
{
if (File.Exists(c)) return c;
}
return null;
}
private static IEnumerable<string> GetCandidatePaths()
{
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var pfx86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
yield return Path.Combine(pf, "OpenVPN", "bin", "openvpn.exe");
yield return Path.Combine(pfx86, "OpenVPN", "bin", "openvpn.exe");
yield return Path.Combine(local, "Programs", "OpenVPN", "bin", "openvpn.exe");
}
private static IEnumerable<string> GetOpenVpnConnectPaths()
{
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var pfx86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
yield return Path.Combine(pf, "OpenVPN Connect");
yield return Path.Combine(pfx86, "OpenVPN Connect");
yield return Path.Combine(local, "Programs", "OpenVPN Connect");
}
private static bool IsOpenVpnConnectInstalled() =>
GetOpenVpnConnectPaths().Any(p => Directory.Exists(p) || File.Exists(Path.Combine(p, "OpenVPNConnect.exe")));
private static string PrepareSplitCompatibleConfig(string originalConfig, string username, string password)
{
var dir = OpenVpnWorkDir;
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "tunnelx-split.ovpn");
var authPath = Path.Combine(dir, "tunnelx-auth.txt");
var builder = new StringBuilder();
var splitOptionsInserted = false;
var lines = originalConfig.Split('\n');
void AppendTunnelXOptions()
{
if (splitOptionsInserted) return;
splitOptionsInserted = true;
builder.AppendLine();
builder.AppendLine("# Added by TunnelX for split tunneling:");
builder.AppendLine("route-nopull");
builder.AppendLine("pull-filter ignore redirect-gateway");
builder.AppendLine("pull-filter ignore block-outside-dns");
builder.AppendLine("pull-filter ignore dhcp-option");
builder.AppendLine("connect-timeout 10");
builder.AppendLine("server-poll-timeout 10");
builder.AppendLine("connect-retry 2 5");
builder.AppendLine("auth-nocache");
if (!string.IsNullOrWhiteSpace(username))
{
File.WriteAllText(authPath, $"{username.Trim()}{Environment.NewLine}{password}", Utf8NoBom);
builder.AppendLine($"auth-user-pass {QuoteOpenVpnPath(authPath)}");
}
builder.AppendLine();
}
for (var i = 0; i < lines.Length; i++)
{
var raw = lines[i].TrimEnd('\r');
var trimmed = raw.TrimStart();
if (trimmed.StartsWith("auth-user-pass", StringComparison.OrdinalIgnoreCase))
continue;
if (trimmed.StartsWith("<connection>", StringComparison.OrdinalIgnoreCase))
{
var block = new List<string> { raw };
while (++i < lines.Length)
{
var blockLine = lines[i].TrimEnd('\r');
block.Add(blockLine);
if (blockLine.Trim().Equals("</connection>", StringComparison.OrdinalIgnoreCase))
break;
}
var remote = ExtractRemoteFromLines(block);
if (remote.HasValue && ShouldSkipRemote(remote.Value.host))
{
Logger.Warning($"[OpenVPN] Skipping unreachable/private remote block {remote.Value.host}:{remote.Value.port}");
continue;
}
AppendTunnelXOptions();
foreach (var blockLine in block)
builder.AppendLine(blockLine);
continue;
}
if (trimmed.StartsWith("remote ", StringComparison.OrdinalIgnoreCase))
{
var remote = ExtractRemoteFromLines(new[] { raw });
if (remote.HasValue && ShouldSkipRemote(remote.Value.host))
{
Logger.Warning($"[OpenVPN] Skipping unreachable/private remote {remote.Value.host}:{remote.Value.port}");
continue;
}
AppendTunnelXOptions();
}
builder.AppendLine(raw);
}
AppendTunnelXOptions();
File.WriteAllText(path, builder.ToString(), Utf8NoBom);
return path;
}
private static bool IsOpenVpnExecutable(string path)
{
if (string.IsNullOrWhiteSpace(path)) return false;
if (!File.Exists(path)) return false;
return string.Equals(Path.GetFileName(path), "openvpn.exe", StringComparison.OrdinalIgnoreCase);
}
private static string QuoteOpenVpnPath(string path) => $"\"{path.Replace('\\', '/')}\"";
private static (string host, string port, string proto)? ExtractRemoteFromLines(IEnumerable<string> lines)
{
foreach (var line in lines)
{
var raw = line.Trim();
if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#") || raw.StartsWith(";"))
continue;
if (!raw.StartsWith("remote ", StringComparison.OrdinalIgnoreCase))
continue;
var parts = raw.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
return (parts[1], parts.Length >= 3 ? parts[2] : "1194", parts.Length >= 4 ? parts[3] : "");
}
return null;
}
private static bool ShouldSkipRemote(string host)
{
if (IPAddress.TryParse(host, out var ip))
return IsPrivateIpv4(ip);
try
{
var addresses = Dns.GetHostAddresses(host)
.Where(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
.ToList();
return addresses.Count > 0 && addresses.All(IsPrivateIpv4);
}
catch
{
return false;
}
}
private async Task PumpOpenVpnOutputAsync(StreamReader reader, CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct);
if (line == null) break;
if (!string.IsNullOrWhiteSpace(line))
{
_recentOpenVpnOutput.Enqueue(line);
while (_recentOpenVpnOutput.Count > 40 && _recentOpenVpnOutput.TryDequeue(out _)) { }
TryCaptureRouteGateway(line);
TryCaptureConnectedRemote(line);
TryCaptureAssignedLocalIp(line);
Logger.Debug($"[OpenVPN] {line}");
}
}
}
catch { }
}
private void TryCaptureRouteGateway(string line)
{
const string token = "route-gateway ";
var idx = line.IndexOf(token, StringComparison.OrdinalIgnoreCase);
if (idx < 0) return;
var start = idx + token.Length;
var end = start;
while (end < line.Length && !char.IsWhiteSpace(line[end]) && line[end] != ',')
end++;
var gateway = line[start..end].Trim();
if (IPAddress.TryParse(gateway, out var ip) &&
ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
{
_routeGatewayIp = gateway;
Logger.Info($"[OpenVPN] Captured route-gateway {gateway}");
TryUpdateConnectedStatusFromCapturedState();
}
}
private void TryCaptureConnectedRemote(string line)
{
if (!line.Contains("[AF_INET]", StringComparison.OrdinalIgnoreCase))
return;
var isConnectedLine =
line.Contains("TCP connection established with", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Peer Connection Initiated with", StringComparison.OrdinalIgnoreCase) ||
line.Contains("link remote:", StringComparison.OrdinalIgnoreCase);
if (!isConnectedLine)
return;
var marker = "[AF_INET]";
var idx = line.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (idx < 0) return;
var start = idx + marker.Length;
var end = start;
while (end < line.Length && !char.IsWhiteSpace(line[end]) && line[end] != ',' && line[end] != ']')
end++;
var endpoint = line[start..end].Trim();
var colon = endpoint.LastIndexOf(':');
if (colon <= 0 || colon == endpoint.Length - 1)
return;
var host = endpoint[..colon];
if (!int.TryParse(endpoint[(colon + 1)..], out var port))
return;
if (!IPAddress.TryParse(host, out var ip) ||
ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
return;
_connectedRemoteIp = host;
_connectedRemotePort = port;
Logger.Info($"[OpenVPN] Captured connected remote {host}:{port}");
TryUpdateConnectedStatusFromCapturedState();
}
private void TryCaptureAssignedLocalIp(string line)
{
const string token = "ifconfig ";
var idx = line.IndexOf(token, StringComparison.OrdinalIgnoreCase);
if (idx < 0)
return;
var start = idx + token.Length;
var end = start;
while (end < line.Length && !char.IsWhiteSpace(line[end]) && line[end] != ',')
end++;
var localIp = line[start..end].Trim();
if (IPAddress.TryParse(localIp, out var ip) &&
ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
{
_assignedLocalIp = localIp;
Logger.Info($"[OpenVPN] Captured assigned local IP {localIp}");
TryUpdateConnectedStatusFromCapturedState();
}
}
private void LogRecentOpenVpnOutput()
{
var lines = _recentOpenVpnOutput.ToArray();
if (lines.Length == 0)
{
Logger.Warning("[OpenVPN] No recent OpenVPN output captured before timeout.");
return;
}
Logger.Warning("[OpenVPN] Recent OpenVPN output before timeout:");
foreach (var line in lines.TakeLast(20))
Logger.Warning($"[OpenVPN][recent] {line}");
}
private static void LogRemoteCandidates(string config)
{
var remotes = ExtractRemoteCandidates(config).ToList();
Logger.Info($"[OpenVPN] Remote candidates found: {remotes.Count}");
foreach (var remote in remotes.Take(20))
{
Logger.Info($"[OpenVPN] remote {remote.host}:{remote.port} {remote.proto}");
if (IPAddress.TryParse(remote.host, out var ip))
{
if (IsPrivateIpv4(ip))
Logger.Warning($"[OpenVPN] remote {remote.host} is private/local; it may not be reachable from this network.");
continue;
}
try
{
var resolved = Dns.GetHostAddresses(remote.host)
.Where(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
.Select(a => a.ToString())
.ToList();
Logger.Info($"[OpenVPN] remote {remote.host} resolves to: {(resolved.Count == 0 ? "no IPv4" : string.Join(", ", resolved))}");
foreach (var resolvedIp in resolved)
{
if (IPAddress.TryParse(resolvedIp, out var resolvedAddress) && IsPrivateIpv4(resolvedAddress))
Logger.Warning($"[OpenVPN] remote {remote.host} resolved to private/local IP {resolvedIp}; OpenVPN may hang until trying the next remote.");
}
}
catch (Exception ex)
{
Logger.Warning($"[OpenVPN] DNS resolve failed for remote {remote.host}: {ex.Message}");
}
}
}
private static IEnumerable<(string host, string port, string proto)> ExtractRemoteCandidates(string config)
{
foreach (var line in config.Split('\n'))
{
var raw = line.Trim();
if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#") || raw.StartsWith(";"))
continue;
if (!raw.StartsWith("remote ", StringComparison.OrdinalIgnoreCase))
continue;
var parts = raw.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
yield return (parts[1], parts.Length >= 3 ? parts[2] : "1194", parts.Length >= 4 ? parts[3] : "");
}
}
private static bool IsPrivateIpv4(IPAddress ip)
{
var b = ip.GetAddressBytes();
return b.Length == 4 &&
(b[0] == 10 ||
(b[0] == 172 && b[1] >= 16 && b[1] <= 31) ||
(b[0] == 192 && b[1] == 168) ||
b[0] == 127 ||
(b[0] == 169 && b[1] == 254));
}
private static string TryExtractRemoteHost(string config)
{
foreach (var line in config.Split('\n'))
{
var raw = line.Trim();
if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#") || raw.StartsWith(";"))
continue;
if (!raw.StartsWith("remote ", StringComparison.OrdinalIgnoreCase))
continue;
var parts = raw.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
return parts[1];
}
return "";
}
private static string ResolveRemoteForRouting(string remoteHost)
{
if (string.IsNullOrWhiteSpace(remoteHost))
return "0.0.0.0";
if (IPAddress.TryParse(remoteHost, out var ip))
return ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? remoteHost : "0.0.0.0";
try
{
var ipv4 = Dns.GetHostAddresses(remoteHost)
.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
return ipv4?.ToString() ?? remoteHost;
}
catch
{
return remoteHost;
}
}
private static int FindOpenVpnInterfaceIndex(string expectedLocalIp)
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.OperationalStatus != OperationalStatus.Up) continue;
var match =
nic.Name.Contains("OpenVPN", StringComparison.OrdinalIgnoreCase) ||
nic.Name.Contains("TAP", StringComparison.OrdinalIgnoreCase) ||
nic.Description.Contains("OpenVPN", StringComparison.OrdinalIgnoreCase) ||
nic.Description.Contains("TAP-Windows", StringComparison.OrdinalIgnoreCase) ||
nic.Description.Contains("Wintun", StringComparison.OrdinalIgnoreCase) ||
nic.Description.Contains("Data Channel Offload", StringComparison.OrdinalIgnoreCase);
if (!match) continue;
var hasExpectedIp = nic.GetIPProperties().UnicastAddresses.Any(a =>
a.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork &&
string.Equals(a.Address.ToString(), expectedLocalIp, StringComparison.OrdinalIgnoreCase));
if (!hasExpectedIp) continue;
var ipv4 = nic.GetIPProperties().GetIPv4Properties();
if (ipv4 != null) return ipv4.Index;
}
return -1;
}
private static string GetInterfaceIpv4(int interfaceIndex)
{
try
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
var props = nic.GetIPProperties();
var ipv4 = props.GetIPv4Properties();
if (ipv4 == null || ipv4.Index != interfaceIndex) continue;
return props.UnicastAddresses
.FirstOrDefault(a => a.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
?.Address.ToString() ?? "N/A";
}
}
catch { }
return "N/A";
}
}
+79 -3
View File
@@ -2,6 +2,7 @@ using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using AppTunnel.Models;
namespace AppTunnel.Services;
@@ -27,6 +28,7 @@ public class ProfileService
private static readonly string ExcludesFile = Path.Combine(ProfileDir, "excludes.json");
private static readonly string IncludesFile = Path.Combine(ProfileDir, "includes.json");
private static readonly string TunnelAppsFile = Path.Combine(ProfileDir, "tunnelapps.json");
private static readonly string AppSettingsFile = Path.Combine(ProfileDir, "appsettings.json");
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -51,6 +53,47 @@ public class ProfileService
}
}
/// <summary>
/// Load global application settings from disk.
/// </summary>
public AppSettings LoadAppSettings()
{
if (!File.Exists(AppSettingsFile))
return new AppSettings();
try
{
var json = File.ReadAllText(AppSettingsFile, Encoding.UTF8);
return JsonSerializer.Deserialize<AppSettings>(json, JsonOptions) ?? new AppSettings();
}
catch
{
return new AppSettings();
}
}
/// <summary>
/// Save global application settings to disk.
/// </summary>
public void SaveAppSettings(AppSettings settings)
{
Directory.CreateDirectory(ProfileDir);
var json = JsonSerializer.Serialize(settings, JsonOptions);
File.WriteAllText(AppSettingsFile, json, Encoding.UTF8);
}
/// <summary>
/// Global application settings (startup + auto-connect preferences).
/// </summary>
public class AppSettings
{
public bool StartWithWindows { get; set; } = false;
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>
/// Load all saved profiles from disk.
/// </summary>
@@ -77,14 +120,26 @@ public class ProfileService
PreSharedKey = DecryptString(s.EncryptedPsk),
TunnelType = s.TunnelType,
V2RayConfig = s.V2RayConfig,
Socks5Port = s.Socks5Port > 0 ? s.Socks5Port : 1080,
OpenVpnConfig = s.OpenVpnConfig,
OpenVpnConfigPath = !string.IsNullOrWhiteSpace(s.OpenVpnConfigPath)
? s.OpenVpnConfigPath
: (s.OpenVpnExePath.EndsWith(".ovpn", StringComparison.OrdinalIgnoreCase) ? s.OpenVpnExePath : ""),
OpenVpnUsername = s.OpenVpnUsername,
OpenVpnPassword = DecryptString(s.EncryptedOpenVpnPassword),
ProxyProtocol = s.ProxyProtocol,
ProxyServerAddress = s.ProxyServerAddress,
ProxyPort = s.ProxyPort > 0 ? s.ProxyPort : 1080,
ProxyUsername = s.ProxyUsername,
ProxyPassword = DecryptString(s.EncryptedProxyPassword),
MixedProxyPort = s.Socks5Port > 0 ? s.Socks5Port : 1080,
AutoTuneMtu = s.AutoTuneMtu,
EnableDnsOptimization = s.EnableDnsOptimization,
EnableGameMode = s.EnableGameMode
}).ToList();
}
catch
catch (Exception ex)
{
Logger.Warning($"[PROFILE] Failed to load profiles: {ex.Message}");
return new List<ConnectionProfile>();
}
}
@@ -108,7 +163,16 @@ public class ProfileService
EncryptedPsk = EncryptString(p.PreSharedKey),
TunnelType = p.TunnelType,
V2RayConfig = p.V2RayConfig,
Socks5Port = p.Socks5Port,
OpenVpnConfig = p.OpenVpnConfig,
OpenVpnConfigPath = p.OpenVpnConfigPath,
OpenVpnUsername = p.OpenVpnUsername,
EncryptedOpenVpnPassword = EncryptString(p.OpenVpnPassword),
ProxyProtocol = p.ProxyProtocol,
ProxyServerAddress = p.ProxyServerAddress,
ProxyPort = p.ProxyPort,
ProxyUsername = p.ProxyUsername,
EncryptedProxyPassword = EncryptString(p.ProxyPassword),
Socks5Port = p.MixedProxyPort,
AutoTuneMtu = p.AutoTuneMtu,
EnableDnsOptimization = p.EnableDnsOptimization,
EnableGameMode = p.EnableGameMode
@@ -165,6 +229,18 @@ public class ProfileService
public string EncryptedPsk { get; set; } = "";
public TunnelType TunnelType { get; set; } = TunnelType.L2tpIpsec;
public string V2RayConfig { get; set; } = "";
public string OpenVpnConfig { get; set; } = "";
public string OpenVpnConfigPath { get; set; } = "";
// Legacy field: early OpenVPN test builds accidentally stored .ovpn path here.
public string OpenVpnExePath { get; set; } = "";
public string OpenVpnUsername { get; set; } = "";
public string EncryptedOpenVpnPassword { get; set; } = "";
public ProxyProtocol ProxyProtocol { get; set; } = ProxyProtocol.Socks5;
public string ProxyServerAddress { get; set; } = "";
public int ProxyPort { get; set; } = 1080;
public string ProxyUsername { get; set; } = "";
public string EncryptedProxyPassword { get; set; } = "";
[JsonPropertyName("socks5Port")]
public int Socks5Port { get; set; } = 1080;
public bool AutoTuneMtu { get; set; } = true;
public bool EnableDnsOptimization { get; set; } = true;
+71
View File
@@ -0,0 +1,71 @@
using System;
using System.Net;
namespace AppTunnel.Services;
/// <summary>
/// Parses proxy URLs like socks5://user:pass@host:port or http://host:port.
/// </summary>
public record ParsedProxyUrl(
string Scheme,
string Host,
int Port,
string? Username,
string? Password);
public static class ProxyUrlParser
{
public static ParsedProxyUrl? Parse(string url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
try
{
var uri = new Uri(url);
var scheme = uri.Scheme.ToLowerInvariant();
if (scheme != "socks5" && scheme != "http") return null;
var host = uri.Host;
var port = uri.Port > 0 ? uri.Port : (scheme == "http" ? 8080 : 1080);
string? user = null, pass = null;
if (!string.IsNullOrEmpty(uri.UserInfo))
{
var parts = uri.UserInfo.Split(':', 2);
user = Uri.UnescapeDataString(parts[0]);
if (parts.Length == 2)
pass = Uri.UnescapeDataString(parts[1]);
}
return new ParsedProxyUrl(scheme, host, port, user, pass);
}
catch
{
return null;
}
}
/// <summary>
/// Strips the [::ffff:] prefix and port from an IPEndPoint string, returning the IPv4 address.
/// </summary>
public static string? ExtractIPv4(string? endPoint)
{
if (string.IsNullOrWhiteSpace(endPoint)) return null;
var s = endPoint.Trim();
// Strip [::ffff:] prefix
const string v6Prefix = "[::ffff:";
if (s.StartsWith(v6Prefix, StringComparison.OrdinalIgnoreCase))
{
// [::ffff:1.2.3.4]:port → 1.2.3.4
var inner = s[v6Prefix.Length..];
var close = inner.IndexOf(']');
if (close < 0) return null;
inner = inner[..close];
return IPAddress.TryParse(inner, out _) ? inner : null;
}
// Plain "1.2.3.4:port" or just "1.2.3.4"
var colon = s.IndexOf(':');
if (colon > 0) s = s[..colon];
return IPAddress.TryParse(s, out _) ? s : null;
}
}
+102
View File
@@ -0,0 +1,102 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using WpfControl = System.Windows.Controls.Control;
using WpfTextBox = System.Windows.Controls.TextBox;
namespace AppTunnel.Services;
public static class TextInputBehavior
{
private static bool _registered;
public static void Register()
{
if (_registered)
return;
_registered = true;
EventManager.RegisterClassHandler(
typeof(WpfTextBox),
UIElement.PreviewMouseLeftButtonDownEvent,
new MouseButtonEventHandler(OnTextBoxPreviewMouseLeftButtonDown),
handledEventsToo: true);
EventManager.RegisterClassHandler(
typeof(WpfTextBox),
UIElement.GotKeyboardFocusEvent,
new KeyboardFocusChangedEventHandler(OnTextBoxGotKeyboardFocus));
EventManager.RegisterClassHandler(
typeof(WpfTextBox),
WpfControl.MouseDoubleClickEvent,
new MouseButtonEventHandler(OnTextBoxDoubleClick));
EventManager.RegisterClassHandler(
typeof(PasswordBox),
UIElement.PreviewMouseLeftButtonDownEvent,
new MouseButtonEventHandler(OnPasswordBoxPreviewMouseLeftButtonDown),
handledEventsToo: true);
EventManager.RegisterClassHandler(
typeof(PasswordBox),
UIElement.GotKeyboardFocusEvent,
new KeyboardFocusChangedEventHandler(OnPasswordBoxGotKeyboardFocus));
EventManager.RegisterClassHandler(
typeof(PasswordBox),
WpfControl.MouseDoubleClickEvent,
new MouseButtonEventHandler(OnPasswordBoxDoubleClick));
}
private static void OnTextBoxPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is not WpfTextBox textBox || textBox.IsReadOnly || e.ClickCount > 1)
return;
textBox.Focus();
textBox.CaretIndex = textBox.Text.Length;
e.Handled = true;
}
private static void OnTextBoxGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
{
if (sender is not WpfTextBox textBox || textBox.IsReadOnly)
return;
textBox.CaretIndex = textBox.Text.Length;
}
private static void OnTextBoxDoubleClick(object sender, MouseButtonEventArgs e)
{
if (sender is not WpfTextBox textBox || textBox.IsReadOnly)
return;
textBox.SelectAll();
e.Handled = true;
}
private static void OnPasswordBoxGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
{
if (sender is PasswordBox passwordBox)
passwordBox.GetType().GetMethod("Select", [typeof(int), typeof(int)])
?.Invoke(passwordBox, [passwordBox.Password.Length, 0]);
}
private static void OnPasswordBoxPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is not PasswordBox passwordBox || e.ClickCount > 1)
return;
passwordBox.Focus();
passwordBox.GetType().GetMethod("Select", [typeof(int), typeof(int)])
?.Invoke(passwordBox, [passwordBox.Password.Length, 0]);
e.Handled = true;
}
private static void OnPasswordBoxDoubleClick(object sender, MouseButtonEventArgs e)
{
if (sender is not PasswordBox passwordBox)
return;
passwordBox.SelectAll();
e.Handled = true;
}
}
@@ -19,7 +19,7 @@ public partial class TrafficRouterService
using var ping = new System.Net.NetworkInformation.Ping();
// 1. TCP-connect to VPN server port 443 directly (via physical NIC).
// 1. TCP-connect to the tunnel/proxy server directly (via physical NIC).
// ICMP is useless here: CDN servers (e.g. Cloudflare, Arvancloud) never
// respond to pings, so Ping.Send() always times out even when the server
// is perfectly healthy. We use the original hostname (_vpnServerHost) so
@@ -30,12 +30,12 @@ public partial class TrafficRouterService
var sw = System.Diagnostics.Stopwatch.StartNew();
using var tcpServer = new TcpClient();
using var serverCts = new System.Threading.CancellationTokenSource(3000);
await tcpServer.ConnectAsync(_vpnServerHost, 443, serverCts.Token);
await tcpServer.ConnectAsync(_vpnServerHost, _vpnServerPort, serverCts.Token);
sw.Stop();
Logger.Info($"[CONN-CHECK] TCP VPN server {_vpnServerHost}:443 (direct): {sw.ElapsedMilliseconds}ms — reachable");
Logger.Info($"[CONN-CHECK] TCP tunnel server {_vpnServerHost}:{_vpnServerPort} (direct): {sw.ElapsedMilliseconds}ms — reachable");
}
catch (OperationCanceledException) { Logger.Warning($"[CONN-CHECK] TCP VPN server {_vpnServerHost}:443: timeout (3000ms) — server unreachable or port blocked"); }
catch (Exception ex) { Logger.Warning($"[CONN-CHECK] TCP VPN server {_vpnServerHost}:443 failed: {ex.Message}"); }
catch (OperationCanceledException) { Logger.Warning($"[CONN-CHECK] TCP tunnel server {_vpnServerHost}:{_vpnServerPort}: timeout (3000ms) — server unreachable or port blocked"); }
catch (Exception ex) { Logger.Warning($"[CONN-CHECK] TCP tunnel server {_vpnServerHost}:{_vpnServerPort} failed: {ex.Message}"); }
// 2. Resolve an Iranian intranet hostname and ping it via the
// default route (physical NIC). Confirms the local intranet
+34 -30
View File
@@ -7,14 +7,6 @@ namespace AppTunnel.Services;
/// <summary>
/// Routes traffic from selected applications through the VPN tunnel using WinDivert.
///
/// How it works:
/// 1. WinDivert captures all outbound TCP/UDP packets
/// 2. For each packet, we look up the owning process via GetExtendedTcpTable/GetExtendedUdpTable
/// 3. If the process is in our target list, we set the outbound interface to the VPN adapter
/// 4. The packet is re-injected with the modified interface
///
/// This approach is used by many commercial VPN apps for split tunneling.
/// </summary>
public partial class TrafficRouterService : IDisposable
{
@@ -30,12 +22,18 @@ public partial class TrafficRouterService : IDisposable
private readonly ConcurrentDictionary<uint, bool> _excludedIps = new();
// Raw exclude entries → resolved NBO IPs, so we can remove cleanly.
private readonly ConcurrentDictionary<string, HashSet<uint>> _excludedEntries = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, bool> _excludedDomainRules = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<uint, bool> _excludedDirectRoutes = new();
// Included destination IPs (network byte order). Populated from user-entered
// domains/IPs; forced through tunnel regardless of target app selection.
private readonly ConcurrentDictionary<uint, bool> _includedIps = new();
// Raw include entries → resolved NBO IPs, so we can remove cleanly.
private readonly ConcurrentDictionary<string, HashSet<uint>> _includedEntries = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, bool> _includedDomainRules = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, DnsRuleQuery> _dnsRuleQueries = new();
private readonly object _destinationListLock = new();
private System.Threading.Timer? _destinationRefreshTimer;
// NAT table: key=(protocol, srcPort); value=entry with original IP/ifIdx/process
// Used to reverse-translate inbound packets so replies reach the correct socket.
@@ -57,6 +55,8 @@ public partial class TrafficRouterService : IDisposable
private string _vpnLocalIp = "";
private string _vpnServerIp = ""; // resolved IPv4 — used in WinDivert filter strings
private string _vpnServerHost = ""; // original hostname/IP from config — used for TCP health checks
private int _vpnServerPort = 443; // original server port from config — used for TCP health checks
private string _vpnGatewayIp = ""; // optional next hop for TAP/OpenVPN host routes
private byte[]? _vpnLocalIpBytes;
private volatile bool _isRunning;
private bool _fullRouteEnabled;
@@ -155,7 +155,7 @@ public partial class TrafficRouterService : IDisposable
/// </summary>
public bool EnableSocks5 { get; set; } = true;
/// <summary>Listener port for the built-in SOCKS5 proxy.</summary>
/// <summary>Listener port for the built-in mixed proxy (SOCKS5 + HTTP).</summary>
public int Socks5Port { get; set; } = 1080;
/// <summary>
@@ -184,7 +184,7 @@ public partial class TrafficRouterService : IDisposable
private uint _dnsRedirectIpNbo = BitConverter.ToUInt32(new byte[] { 8, 8, 8, 8 }, 0);
private byte[] _dnsRedirectIpBytes = new byte[] { 8, 8, 8, 8 };
private Socks5Server? _socks5;
private MixedProxyServer? _mixedProxy;
#pragma warning disable CS0067
public event Action<string, long, long>? TrafficUpdated;
@@ -303,12 +303,19 @@ public partial class TrafficRouterService : IDisposable
public long ActiveRouteCount => _addedRoutes.Count;
public long RouteFailureCount => Interlocked.Read(ref _statRoutesFailed);
public void Start(int vpnInterfaceIndex, string vpnLocalIp, string vpnServerIp)
public void Start(
int vpnInterfaceIndex,
string vpnLocalIp,
string vpnServerIp,
string vpnGatewayIp = "",
int vpnServerPort = 443,
bool resetCounters = true)
{
if (_isRunning) return;
_vpnInterfaceIndex = vpnInterfaceIndex;
_vpnLocalIp = vpnLocalIp;
_vpnGatewayIp = vpnGatewayIp;
_vpnLocalIpBytes = IPAddress.TryParse(vpnLocalIp, out var vpnAddr)
? vpnAddr.GetAddressBytes()
: null;
@@ -318,6 +325,7 @@ public partial class TrafficRouterService : IDisposable
// Keep the original hostname for TCP-based health checks (domain may be behind
// a CDN that returns different IPs; we should connect by name, not cached IP).
_vpnServerHost = vpnServerIp;
_vpnServerPort = vpnServerPort > 0 && vpnServerPort <= 65535 ? vpnServerPort : 443;
// Resolve VPN server address to an IPv4 string.
// WinDivert filters require a literal IP address — hostnames are invalid
@@ -397,22 +405,14 @@ public partial class TrafficRouterService : IDisposable
ConfigureDnsRedirectTarget();
// Reset total-throughput counters for this session.
Interlocked.Exchange(ref _totalVpnBytesSent, 0);
Interlocked.Exchange(ref _totalVpnBytesReceived, 0);
Interlocked.Exchange(ref _directBytesSent, 0);
Interlocked.Exchange(ref _directBytesReceived, 0);
foreach (var counter in _trafficCounters.Values)
{
Interlocked.Exchange(ref counter.BytesSent, 0);
Interlocked.Exchange(ref counter.BytesReceived, 0);
}
if (resetCounters)
ResetLiveTrafficCounters();
// Reset flow-log counters so session 2 gets fresh log output.
_flowLogCount = 0;
_flowMatchLogCount = 0;
Logger.Info($"TrafficRouter starting: VPN Interface={vpnInterfaceIndex}, LocalIP={vpnLocalIp}, ServerIP={vpnServerIp}");
Logger.Info($"TrafficRouter starting: VPN Interface={vpnInterfaceIndex}, LocalIP={vpnLocalIp}, Gateway={_vpnGatewayIp}, ServerIP={vpnServerIp}");
Logger.Info($"Target apps: {string.Join(", ", _targetExecutables.Keys)}");
if (PassthroughMode)
Logger.Warning("DIAGNOSTIC PASSTHROUGH MODE ENABLED — packets will NOT be redirected. For testing only.");
@@ -477,6 +477,9 @@ public partial class TrafficRouterService : IDisposable
RemoveDefaultRouteOnVpn();
_fullRouteEnabled = false;
RefreshDestinationLists(installIncludedRoutes: true);
_destinationRefreshTimer = new System.Threading.Timer(_ => RefreshDestinationLists(installIncludedRoutes: true), null,
TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
_ = Task.Run(RunConnectivityChecks);
// NEW ARCHITECTURE (flow-based, zero-copy):
@@ -515,13 +518,11 @@ public partial class TrafficRouterService : IDisposable
_networkOutTask = Task.Run(() => NetworkOutboundLoop(_cts.Token));
_networkInTask = Task.Run(() => NetworkInboundLoop(_cts.Token));
// Optional SOCKS5 proxy: apps with native SOCKS5 support (Telegram,
// Firefox, ...) can point at 127.0.0.1:1080 and get guaranteed VPN
// egress without relying on host routes.
// Optional mixed SOCKS5/HTTP proxy
if (EnableSocks5)
{
_socks5 = new Socks5Server(Socks5Port);
_socks5.Start(vpnLocalIp, EnsureHostRouteForSocks5);
_mixedProxy = new MixedProxyServer(Socks5Port);
_mixedProxy.Start(vpnLocalIp, EnsureHostRouteForSocks5);
}
}
@@ -750,7 +751,7 @@ public partial class TrafficRouterService : IDisposable
Logger.Info($"[APP-RECONCILE] '{executableName}' disabled: removedRoutes={removedRouteIps.Count}, removedNat={natRemoved}");
}
public async Task StopAsync()
public async Task StopAsync(bool resetCounters = true)
{
if (!_isRunning) return;
@@ -759,9 +760,11 @@ public partial class TrafficRouterService : IDisposable
_cts?.Cancel();
_statsTimer?.Dispose();
_statsTimer = null;
_destinationRefreshTimer?.Dispose();
_destinationRefreshTimer = null;
try { _socks5?.Stop(); } catch { }
_socks5 = null;
try { _mixedProxy?.Stop(); } catch { }
_mixedProxy = null;
// Cancel all pending delayed route removals.
foreach (var kvp in _pendingRouteRemoval)
@@ -862,6 +865,7 @@ public partial class TrafficRouterService : IDisposable
Interlocked.Exchange(ref _statLeakBlockedRecovered, 0);
Interlocked.Exchange(ref _statLeakBlockedSuppressed, 0);
Interlocked.Exchange(ref _policyTransitionGraceUntilTick, 0);
if (resetCounters)
ResetLiveTrafficCounters();
Interlocked.Exchange(ref _statRoutesAdded, 0);
Interlocked.Exchange(ref _statRoutesFailed, 0);
@@ -0,0 +1,244 @@
using System.Net;
namespace AppTunnel.Services;
public partial class TrafficRouterService
{
private int _dnsRuleQueryLogCount;
private int _dnsRuleApplyLogCount;
private sealed class DnsRuleQuery
{
public required string Host { get; init; }
public required bool Excluded { get; init; }
public required bool Included { get; init; }
public DateTime LastSeenUtc { get; init; } = DateTime.UtcNow;
}
private void LearnDnsRuleFromOutboundPacket(byte[] buffer, uint readLen)
{
if (!TryGetUdpPayload(buffer, readLen, out var payloadOffset, out var payloadLength, out var srcPort, out var dstPort))
return;
if (dstPort != 53 || payloadLength < 12)
return;
ushort txId = ReadUInt16(buffer, payloadOffset);
if (!TryReadDnsQuestionHost(buffer, payloadOffset, payloadLength, out var host))
return;
bool excluded = IsExcludedDomain(host);
bool included = !excluded && IsIncludedDomain(host);
if (!excluded && !included)
return;
if (Interlocked.Increment(ref _dnsRuleQueryLogCount) <= 20)
{
var policy = excluded ? "EXCLUDE" : "INCLUDE";
Logger.Info($"[DNS-RULE] Query '{host}' matched {policy} domain rule");
}
uint dnsServerNbo = BitConverter.ToUInt32(buffer, 16);
_dnsRuleQueries[BuildDnsRuleKey(txId, srcPort, dnsServerNbo)] = new DnsRuleQuery
{
Host = host,
Excluded = excluded,
Included = included
};
}
private void ApplyDnsRuleFromInboundPacket(byte[] buffer, uint readLen)
{
if (!TryGetUdpPayload(buffer, readLen, out var payloadOffset, out var payloadLength, out var srcPort, out var dstPort))
return;
if (srcPort != 53 || payloadLength < 12)
return;
ushort txId = ReadUInt16(buffer, payloadOffset);
uint dnsServerNbo = BitConverter.ToUInt32(buffer, 12);
if (!_dnsRuleQueries.TryRemove(BuildDnsRuleKey(txId, dstPort, dnsServerNbo), out var query))
{
// DNS redirect may change the resolver IP between request and response.
// The client port + transaction ID remain stable, so use them as a
// fallback match.
var prefix = $"{txId}:{dstPort}:";
var fallback = _dnsRuleQueries.FirstOrDefault(kv => kv.Key.StartsWith(prefix, StringComparison.Ordinal));
if (string.IsNullOrEmpty(fallback.Key) ||
!_dnsRuleQueries.TryRemove(fallback.Key, out query))
{
return;
}
}
var learnedIps = ReadDnsAAnswers(buffer, payloadOffset, payloadLength).ToList();
foreach (var ip in learnedIps)
{
var nbo = BitConverter.ToUInt32(ip.GetAddressBytes(), 0);
if (query.Excluded)
{
_excludedIps[nbo] = true;
PurgeRouteForExcludedIp(nbo, ip);
continue;
}
if (query.Included && !IsExcludedDestination(nbo))
{
_includedIps[nbo] = true;
_ipToProcess[nbo] = "[INCLUDE]";
EnsureHostRouteViaVpn(nbo, ip);
}
}
if (learnedIps.Count > 0 && Interlocked.Increment(ref _dnsRuleApplyLogCount) <= 20)
{
var policy = query.Excluded ? "EXCLUDE" : "INCLUDE";
Logger.Info($"[DNS-RULE] Applied {policy} for '{query.Host}' → {string.Join(", ", learnedIps.Select(ip => ip.ToString()))}");
}
CleanupOldDnsRuleQueries();
}
private void CleanupOldDnsRuleQueries()
{
var cutoff = DateTime.UtcNow.AddMinutes(-2);
foreach (var item in _dnsRuleQueries)
{
if (item.Value.LastSeenUtc < cutoff)
_dnsRuleQueries.TryRemove(item.Key, out _);
}
}
private static string BuildDnsRuleKey(ushort txId, ushort clientPort, uint dnsServerNbo)
=> $"{txId}:{clientPort}:{dnsServerNbo}";
private static bool TryGetUdpPayload(
byte[] buffer,
uint readLen,
out int payloadOffset,
out int payloadLength,
out ushort srcPort,
out ushort dstPort)
{
payloadOffset = 0;
payloadLength = 0;
srcPort = 0;
dstPort = 0;
if (readLen < 28 || (buffer[0] >> 4) != 4 || buffer[9] != 17)
return false;
int ipHeaderLen = (buffer[0] & 0x0F) * 4;
if (ipHeaderLen < 20 || readLen < ipHeaderLen + 8)
return false;
srcPort = ReadUInt16(buffer, ipHeaderLen);
dstPort = ReadUInt16(buffer, ipHeaderLen + 2);
int udpLen = ReadUInt16(buffer, ipHeaderLen + 4);
payloadOffset = ipHeaderLen + 8;
payloadLength = Math.Min(udpLen - 8, (int)readLen - payloadOffset);
return payloadLength >= 0;
}
private static bool TryReadDnsQuestionHost(byte[] buffer, int dnsOffset, int dnsLength, out string host)
{
host = "";
if (dnsLength < 12)
return false;
bool isResponse = (buffer[dnsOffset + 2] & 0x80) != 0;
int qdCount = ReadUInt16(buffer, dnsOffset + 4);
if (isResponse || qdCount <= 0)
return false;
int pos = dnsOffset + 12;
if (!TryReadDnsName(buffer, dnsOffset, dnsLength, ref pos, out host))
return false;
return pos + 4 <= dnsOffset + dnsLength;
}
private static IEnumerable<IPAddress> ReadDnsAAnswers(byte[] buffer, int dnsOffset, int dnsLength)
{
bool isResponse = (buffer[dnsOffset + 2] & 0x80) != 0;
if (!isResponse)
yield break;
int qdCount = ReadUInt16(buffer, dnsOffset + 4);
int anCount = ReadUInt16(buffer, dnsOffset + 6);
int pos = dnsOffset + 12;
for (int i = 0; i < qdCount; i++)
{
if (!TryReadDnsName(buffer, dnsOffset, dnsLength, ref pos, out _))
yield break;
pos += 4;
if (pos > dnsOffset + dnsLength)
yield break;
}
for (int i = 0; i < anCount; i++)
{
if (!TryReadDnsName(buffer, dnsOffset, dnsLength, ref pos, out _))
yield break;
if (pos + 10 > dnsOffset + dnsLength)
yield break;
ushort type = ReadUInt16(buffer, pos);
ushort klass = ReadUInt16(buffer, pos + 2);
ushort rdLen = ReadUInt16(buffer, pos + 8);
pos += 10;
if (pos + rdLen > dnsOffset + dnsLength)
yield break;
if (type == 1 && klass == 1 && rdLen == 4)
yield return new IPAddress(new[] { buffer[pos], buffer[pos + 1], buffer[pos + 2], buffer[pos + 3] });
pos += rdLen;
}
}
private static bool TryReadDnsName(byte[] buffer, int dnsOffset, int dnsLength, ref int pos, out string name)
{
name = "";
var labels = new List<string>();
int limit = dnsOffset + dnsLength;
int cursor = pos;
int jumps = 0;
bool jumped = false;
while (cursor < limit)
{
byte len = buffer[cursor++];
if (len == 0)
{
if (!jumped)
pos = cursor;
name = string.Join(".", labels);
return !string.IsNullOrWhiteSpace(name);
}
if ((len & 0xC0) == 0xC0)
{
if (cursor >= limit || ++jumps > 8)
return false;
int pointer = ((len & 0x3F) << 8) | buffer[cursor++];
if (!jumped)
pos = cursor;
cursor = dnsOffset + pointer;
jumped = true;
continue;
}
if ((len & 0xC0) != 0 || cursor + len > limit)
return false;
labels.Add(System.Text.Encoding.ASCII.GetString(buffer, cursor, len));
cursor += len;
}
return false;
}
private static ushort ReadUInt16(byte[] buffer, int offset)
=> (ushort)((buffer[offset] << 8) | buffer[offset + 1]);
}
@@ -10,11 +10,15 @@ public partial class TrafficRouterService
/// Replace the entire exclude list. Resolves domains to IPs.
/// </summary>
public void SetExcludedDestinations(IEnumerable<string> entries)
{
lock (_destinationListLock)
{
_excludedIps.Clear();
_excludedEntries.Clear();
_excludedDomainRules.Clear();
foreach (var entry in entries)
AddExcludedDestination(entry);
AddExcludedDestinationCore(entry);
}
}
/// <summary>
@@ -28,46 +32,42 @@ public partial class TrafficRouterService
/// tracked in _addedRoutes but can still silently redirect traffic to the VPN.
/// </summary>
public void AddExcludedDestination(string entry)
{
lock (_destinationListLock)
AddExcludedDestinationCore(entry);
}
private void AddExcludedDestinationCore(string entry)
{
var originalEntry = entry.Trim();
entry = NormalizeDestinationEntry(originalEntry);
if (string.IsNullOrEmpty(entry)) return;
if (_excludedEntries.ContainsKey(entry)) return;
var ips = new HashSet<uint>();
var ips = ResolveDestinationEntry(entry, "[EXCLUDE]", out var unsupportedIp);
if (unsupportedIp)
{
_excludedEntries[entry] = ips;
return;
}
if (IPAddress.TryParse(entry, out var ip))
{
var nbo = BitConverter.ToUInt32(ip.GetAddressBytes(), 0);
ips.Add(nbo);
_excludedIps[nbo] = true;
PurgeRouteForExcludedIp(nbo, ip);
Logger.Info($"[EXCLUDE] Added IP {entry}");
}
else
{
// Domain → resolve
try
{
var addresses = DnsResolverCache.ResolveIpv4(entry);
foreach (var addr in addresses)
{
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
{
var nbo = BitConverter.ToUInt32(addr.GetAddressBytes(), 0);
ips.Add(nbo);
_excludedIps[nbo] = true;
PurgeRouteForExcludedIp(nbo, addr);
}
}
_excludedDomainRules[entry] = true;
var normalizedSuffix = originalEntry.Equals(entry, StringComparison.OrdinalIgnoreCase)
? ""
: $" (from '{originalEntry}')";
Logger.Info($"[EXCLUDE] Added domain '{entry}'{normalizedSuffix} → {ips.Count} IPs");
Logger.Info($"[EXCLUDE] Added domain rule '*.{entry}'{normalizedSuffix} → {ips.Count} current IPs");
}
catch (Exception ex)
foreach (var nbo in ips)
{
Logger.Warning($"[EXCLUDE] Could not resolve '{entry}': {ex.Message}");
}
_excludedIps[nbo] = true;
PurgeRouteForExcludedIp(nbo, new IPAddress(BitConverter.GetBytes(nbo)));
}
_excludedEntries[entry] = ips;
}
@@ -100,6 +100,7 @@ public partial class TrafficRouterService
// We do NOT gate this on _isRunning — stale routes can be present even
// before the tunnel starts, and we must clean them up proactively.
ForceDeleteRouteFromWindows(ipForLog);
AddExcludedDirectRoute(nbo, ipForLog);
}
/// <summary>
@@ -131,16 +132,108 @@ public partial class TrafficRouterService
public void RemoveExcludedDestination(string entry)
{
entry = NormalizeDestinationEntry(entry);
lock (_destinationListLock)
{
if (_excludedEntries.TryRemove(entry, out var ips))
{
foreach (var nbo in ips)
{
if (!IsIpPresentInEntries(_excludedEntries, nbo))
{
_excludedIps.TryRemove(nbo, out _);
RemoveExcludedDirectRoute(nbo);
}
}
_excludedDomainRules.TryRemove(entry, out _);
Logger.Info($"[EXCLUDE] Removed '{entry}'");
}
}
}
private bool IsExcludedDestination(uint dstIpNbo)
=> _excludedIps.ContainsKey(dstIpNbo);
private bool IsExcludedDomain(string host)
=> IsDomainRuleMatch(_excludedDomainRules, host);
private void RefreshExcludedDestinations()
{
lock (_destinationListLock)
{
foreach (var entry in _excludedEntries.Keys.ToList())
{
var oldIps = _excludedEntries.TryGetValue(entry, out var existing)
? existing
: new HashSet<uint>();
var newIps = ResolveDestinationEntry(entry, "[EXCLUDE]", out _);
_excludedEntries[entry] = newIps;
foreach (var nbo in oldIps.Except(newIps).ToList())
{
if (!IsIpPresentInEntries(_excludedEntries, nbo))
{
_excludedIps.TryRemove(nbo, out _);
RemoveExcludedDirectRoute(nbo);
}
}
foreach (var nbo in newIps)
{
_excludedIps[nbo] = true;
PurgeRouteForExcludedIp(nbo, new IPAddress(BitConverter.GetBytes(nbo)));
}
}
}
}
private void RefreshDestinationLists(bool installIncludedRoutes)
{
RefreshExcludedDestinations();
RefreshIncludedDestinations(installIncludedRoutes);
}
private void RefreshExcludedDirectRoutes()
{
if (!_fullRouteEnabled)
return;
foreach (var nbo in _excludedIps.Keys)
AddExcludedDirectRoute(nbo, new IPAddress(BitConverter.GetBytes(nbo)));
}
private void AddExcludedDirectRoute(uint nbo, IPAddress ip)
{
if (!_fullRouteEnabled)
return;
if (string.IsNullOrWhiteSpace(_physicalGatewayIp) || _physicalInterfaceIndex <= 0)
return;
TryRunRouteCommand($"delete {ip}", out _);
if (TryRunRouteCommand(
$"add {ip} mask 255.255.255.255 {_physicalGatewayIp} IF {_physicalInterfaceIndex} METRIC 1",
out var stderr))
{
_excludedDirectRoutes[nbo] = true;
return;
}
Logger.Warning($"[EXCLUDE] Failed to add direct full-route bypass for {ip}: {stderr.Trim()}");
}
private void RemoveExcludedDirectRoute(uint nbo)
{
if (!_excludedDirectRoutes.TryRemove(nbo, out _))
return;
TryRunRouteCommand($"delete {new IPAddress(BitConverter.GetBytes(nbo))}", out _);
}
private void RemoveExcludedDirectRoutes()
{
foreach (var nbo in _excludedDirectRoutes.Keys.ToList())
RemoveExcludedDirectRoute(nbo);
_excludedDirectRoutes.Clear();
}
#endregion
}
@@ -1,4 +1,6 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
namespace AppTunnel.Services;
@@ -10,11 +12,15 @@ public partial class TrafficRouterService
/// Replace the entire include list. Resolves domains to IPs.
/// </summary>
public void SetIncludedDestinations(IEnumerable<string> entries)
{
lock (_destinationListLock)
{
_includedIps.Clear();
_includedEntries.Clear();
_includedDomainRules.Clear();
foreach (var entry in entries)
AddIncludedDestination(entry);
AddIncludedDestinationCore(entry, installRoutes: _isRunning);
}
}
/// <summary>
@@ -23,46 +29,44 @@ public partial class TrafficRouterService
/// whether the source application is in the target tunnel apps list.
/// </summary>
public void AddIncludedDestination(string entry)
{
lock (_destinationListLock)
AddIncludedDestinationCore(entry, installRoutes: _isRunning);
}
private void AddIncludedDestinationCore(string entry, bool installRoutes)
{
var originalEntry = entry.Trim();
entry = NormalizeDestinationEntry(originalEntry);
if (string.IsNullOrEmpty(entry)) return;
if (_includedEntries.ContainsKey(entry)) return;
var ips = new HashSet<uint>();
var ips = ResolveDestinationEntry(entry, "[INCLUDE]", out var unsupportedIp);
if (unsupportedIp)
{
_includedEntries[entry] = ips;
return;
}
if (IPAddress.TryParse(entry, out var ip))
{
var nbo = BitConverter.ToUInt32(ip.GetAddressBytes(), 0);
ips.Add(nbo);
_includedIps[nbo] = true;
Logger.Info($"[INCLUDE] Added IP {entry}");
}
else
{
// Domain → resolve
try
{
var addresses = DnsResolverCache.ResolveIpv4(entry);
foreach (var addr in addresses)
{
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
{
var nbo = BitConverter.ToUInt32(addr.GetAddressBytes(), 0);
ips.Add(nbo);
_includedIps[nbo] = true;
}
}
_includedDomainRules[entry] = true;
var normalizedSuffix = originalEntry.Equals(entry, StringComparison.OrdinalIgnoreCase)
? ""
: $" (from '{originalEntry}')";
Logger.Info($"[INCLUDE] Added domain '{entry}'{normalizedSuffix} → {ips.Count} IPs");
}
catch (Exception ex)
{
Logger.Warning($"[INCLUDE] Could not resolve '{entry}': {ex.Message}");
}
Logger.Info($"[INCLUDE] Added domain rule '*.{entry}'{normalizedSuffix} → {ips.Count} current IPs");
}
foreach (var nbo in ips)
_includedIps[nbo] = true;
_includedEntries[entry] = ips;
if (installRoutes)
InstallIncludedRoutes(ips);
}
/// <summary>
@@ -71,16 +75,148 @@ public partial class TrafficRouterService
public void RemoveIncludedDestination(string entry)
{
entry = NormalizeDestinationEntry(entry);
lock (_destinationListLock)
{
if (_includedEntries.TryRemove(entry, out var ips))
{
foreach (var nbo in ips)
{
if (IsIpPresentInEntries(_includedEntries, nbo))
continue;
_includedIps.TryRemove(nbo, out _);
if (_ipToProcess.TryGetValue(nbo, out var owner) &&
string.Equals(owner, "[INCLUDE]", StringComparison.OrdinalIgnoreCase))
{
_ipToProcess.TryRemove(nbo, out _);
_ipRefCount.TryRemove(nbo, out _);
TryRemoveHostRoute(nbo);
}
}
_includedDomainRules.TryRemove(entry, out _);
Logger.Info($"[INCLUDE] Removed '{entry}'");
}
}
}
private bool IsIncludedDestination(uint dstIpNbo)
=> _includedIps.ContainsKey(dstIpNbo);
private bool IsIncludedDomain(string host)
=> IsDomainRuleMatch(_includedDomainRules, host);
private void RefreshIncludedDestinations(bool installRoutes)
{
lock (_destinationListLock)
{
foreach (var entry in _includedEntries.Keys.ToList())
{
var oldIps = _includedEntries.TryGetValue(entry, out var existing)
? existing
: new HashSet<uint>();
var newIps = ResolveDestinationEntry(entry, "[INCLUDE]", out _);
foreach (var nbo in oldIps.Except(newIps).ToList())
{
_includedEntries[entry] = newIps;
if (IsIpPresentInEntries(_includedEntries, nbo))
continue;
_includedIps.TryRemove(nbo, out _);
if (_ipToProcess.TryGetValue(nbo, out var owner) &&
string.Equals(owner, "[INCLUDE]", StringComparison.OrdinalIgnoreCase))
{
_ipToProcess.TryRemove(nbo, out _);
_ipRefCount.TryRemove(nbo, out _);
TryRemoveHostRoute(nbo);
}
}
_includedEntries[entry] = newIps;
foreach (var nbo in newIps)
_includedIps[nbo] = true;
if (installRoutes)
InstallIncludedRoutes(newIps);
}
}
}
private void InstallIncludedRoutes(IEnumerable<uint> ips)
{
if (!_isRunning)
return;
foreach (var nbo in ips)
{
if (IsExcludedDestination(nbo))
continue;
var ip = new IPAddress(BitConverter.GetBytes(nbo));
_ipToProcess[nbo] = "[INCLUDE]";
EnsureHostRouteViaVpn(nbo, ip);
}
}
private static HashSet<uint> ResolveDestinationEntry(string entry, string logPrefix, out bool unsupportedIp)
{
unsupportedIp = false;
var ips = new HashSet<uint>();
if (IPAddress.TryParse(entry, out var ip))
{
if (ip.AddressFamily != AddressFamily.InterNetwork)
{
unsupportedIp = true;
Logger.Warning($"{logPrefix} IPv6 destination '{entry}' is not supported by IPv4 route rules yet");
return ips;
}
ips.Add(BitConverter.ToUInt32(ip.GetAddressBytes(), 0));
return ips;
}
try
{
foreach (var addr in DnsResolverCache.ResolveIpv4(entry))
ips.Add(BitConverter.ToUInt32(addr.GetAddressBytes(), 0));
}
catch (Exception ex)
{
Logger.Warning($"{logPrefix} Could not resolve '{entry}': {ex.Message}");
}
return ips;
}
private static bool IsIpPresentInEntries(
ConcurrentDictionary<string, HashSet<uint>> entries,
uint ip)
=> entries.Values.Any(set => set.Contains(ip));
private static bool IsDomainRuleMatch(
ConcurrentDictionary<string, bool> domainRules,
string host)
{
host = NormalizeDomainForRule(host);
if (string.IsNullOrEmpty(host))
return false;
foreach (var rule in domainRules.Keys)
{
var normalizedRule = NormalizeDomainForRule(rule);
if (host.Equals(normalizedRule, StringComparison.OrdinalIgnoreCase) ||
host.EndsWith("." + normalizedRule, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string NormalizeDomainForRule(string value)
=> NormalizeDestinationEntry(value).TrimStart('.').ToLowerInvariant();
#endregion
}
@@ -73,6 +73,7 @@ public partial class TrafficRouterService
}
uint dstNbo = BitConverter.ToUInt32(buffer, 16);
LearnDnsRuleFromOutboundPacket(buffer, readLen);
// ── Fast path: destination already has a VPN host route ──
if (_addedRoutes.ContainsKey(dstNbo))
@@ -361,7 +362,7 @@ public partial class TrafficRouterService
{
try
{
string filter = $"inbound and ip and (tcp or udp) and ip.DstAddr == {_vpnLocalIp}";
string filter = $"inbound and ip and ((tcp or udp) and ip.DstAddr == {_vpnLocalIp} or udp.SrcPort == 53)";
IntPtr h;
lock (_handleLock)
@@ -395,6 +396,9 @@ public partial class TrafficRouterService
continue;
}
if (readLen >= 20)
ApplyDnsRuleFromInboundPacket(buffer, readLen);
if (readLen >= 20 && TryParseConnectionTuple(buffer, readLen, out var tuple))
{
// For inbound: tuple.RemotePort = destination port = our client port.
@@ -5,6 +5,8 @@ namespace AppTunnel.Services;
public partial class TrafficRouterService
{
private bool _vpnServerPhysicalRouteAdded;
public bool IsFullRouteEnabled => _fullRouteEnabled;
public bool SetFullRouteEnabled(bool enabled)
@@ -17,19 +19,32 @@ public partial class TrafficRouterService
if (!AddVpnServerPhysicalRoute())
Logger.Warning("[FULL-ROUTE] Could not pin VPN server to the physical gateway; enabling full-route may fail.");
if (!TryRunRouteCommand($"add 0.0.0.0 mask 0.0.0.0 0.0.0.0 IF {_vpnInterfaceIndex} METRIC 1", out var stderr))
RemoveFullRouteDefault();
var gateway = GetVpnRouteGateway();
var added = TryRunRouteCommand($"add 0.0.0.0 mask 0.0.0.0 {gateway} IF {_vpnInterfaceIndex} METRIC 1", out var stderr);
if (!added && gateway != "0.0.0.0")
{
Logger.Warning($"[FULL-ROUTE] Failed to add VPN default route via {gateway}; retrying on-link gateway. stderr={stderr.Trim()}");
added = TryRunRouteCommand($"add 0.0.0.0 mask 0.0.0.0 0.0.0.0 IF {_vpnInterfaceIndex} METRIC 1", out stderr);
}
if (!added)
{
Logger.Warning($"[FULL-ROUTE] Failed to add VPN default route: {stderr}");
RemoveVpnServerPhysicalRoute();
return false;
}
_fullRouteEnabled = true;
InvalidateProcessCaches();
RefreshExcludedDirectRoutes();
Logger.Info($"[FULL-ROUTE] Enabled via VPN IF {_vpnInterfaceIndex}");
return true;
}
RemoveExcludedDirectRoutes();
RemoveFullRouteDefault();
RemoveVpnServerPhysicalRoute();
_fullRouteEnabled = false;
MarkPolicyTransitionGrace(TimeSpan.FromSeconds(25));
CleanupRoutesForCurrentMode(dropStaleNat: true);
@@ -40,15 +55,28 @@ public partial class TrafficRouterService
private bool AddVpnServerPhysicalRoute()
{
_vpnServerPhysicalRouteAdded = false;
if (string.IsNullOrWhiteSpace(_vpnServerIp) || _vpnServerIp == "0.0.0.0")
return false;
if (string.IsNullOrWhiteSpace(_physicalGatewayIp) || _physicalInterfaceIndex <= 0)
return false;
TryRunRouteCommand($"delete {_vpnServerIp}", out _);
return TryRunRouteCommand(
var added = TryRunRouteCommand(
$"add {_vpnServerIp} mask 255.255.255.255 {_physicalGatewayIp} IF {_physicalInterfaceIndex} METRIC 1",
out _);
_vpnServerPhysicalRouteAdded = added;
return added;
}
private void RemoveVpnServerPhysicalRoute()
{
if (!_vpnServerPhysicalRouteAdded)
return;
if (!string.IsNullOrWhiteSpace(_vpnServerIp) && _vpnServerIp != "0.0.0.0")
TryRunRouteCommand($"delete {_vpnServerIp}", out _);
_vpnServerPhysicalRouteAdded = false;
}
private void RemoveFullRouteDefault()
@@ -57,6 +85,9 @@ public partial class TrafficRouterService
RemoveDefaultRouteOnVpn();
}
private string GetVpnRouteGateway()
=> string.IsNullOrWhiteSpace(_vpnGatewayIp) ? "0.0.0.0" : _vpnGatewayIp;
/// <summary>
/// Remove default routes (0.0.0.0/0) on the VPN interface so only
/// explicitly added /32 host routes use the tunnel. Without this,
@@ -355,9 +386,10 @@ public partial class TrafficRouterService
/// </summary>
private bool TryAddRouteViaCommandLine(IPAddress dstIp)
{
var ok = TryRunRouteCommand($"add {dstIp} mask 255.255.255.255 0.0.0.0 IF {_vpnInterfaceIndex} METRIC 1", out var stderr);
var gateway = GetVpnRouteGateway();
var ok = TryRunRouteCommand($"add {dstIp} mask 255.255.255.255 {gateway} IF {_vpnInterfaceIndex} METRIC 1", out var stderr);
if (!ok && Interlocked.Read(ref _statRoutesFailed) <= 10)
Logger.Warning($"[ROUTE!] route.exe add {dstIp} stderr='{stderr.Trim()}'");
Logger.Warning($"[ROUTE!] route.exe add {dstIp} via {gateway} stderr='{stderr.Trim()}'");
return ok;
}
+129 -18
View File
@@ -20,7 +20,7 @@ public class V2RayTunnelProvider : ITunnelProvider
private const string TunAddress = "172.18.0.1/30";
private const string VpnLocalIp = "172.18.0.1"; // actual TUN interface address
private const int DefaultTunMtu = 1500;
private const int MixedProxyPort = 2080; // sing-box SOCKS5/HTTP inbound for accurate ping
private const int DefaultMixedProxyPort = 2080; // preferred sing-box SOCKS5/HTTP inbound for accurate ping
private readonly string _singBoxExe;
private readonly string _workDir;
@@ -124,7 +124,7 @@ public class V2RayTunnelProvider : ITunnelProvider
_tunnelFailedFired = 0;
Status.State = ConnectionState.Connecting;
Status.Message = "در حال آماده‌سازی V2Ray...";
Status.Message = LocalizationService.Instance.T("در حال آماده‌سازی V2Ray...");
Logger.Info("V2RayTunnelProvider: starting");
try
@@ -138,7 +138,7 @@ public class V2RayTunnelProvider : ITunnelProvider
if (!File.Exists(_singBoxExe))
{
Status.State = ConnectionState.Error;
Status.Message = $"فایل sing-box.exe پیدا نشد: {_singBoxExe}";
Status.Message = LocalizationService.Instance.Format("فایل sing-box.exe پیدا نشد: {0}", _singBoxExe);
Logger.Error(Status.Message);
return false;
}
@@ -146,6 +146,9 @@ public class V2RayTunnelProvider : ITunnelProvider
// Build and write config
int tunMtu = DefaultTunMtu;
string singBoxJson;
using var mixedProxyPortReservation = LocalPortReservation.ReservePreferredOrRandom(DefaultMixedProxyPort);
var mixedProxyPort = mixedProxyPortReservation.Port;
Logger.Info($"[PORT] sing-box mixed proxy=127.0.0.1:{mixedProxyPort}");
try
{
if (config.AutoTuneMtu)
@@ -163,18 +166,20 @@ public class V2RayTunnelProvider : ITunnelProvider
Logger.Info($"[MTU] Auto-tune disabled; using MTU={tunMtu}");
}
singBoxJson = BuildSingBoxConfig(config.V2RayConfig, tunMtu, config.EnableDnsOptimization);
singBoxJson = BuildSingBoxConfig(config.V2RayConfig, tunMtu, config.EnableDnsOptimization, mixedProxyPort);
}
catch (Exception ex)
{
mixedProxyPortReservation.Dispose();
Status.State = ConnectionState.Error;
Status.Message = $"خطا در پارس کانفیگ: {ex.Message}";
Status.Message = LocalizationService.Instance.Format("خطا در پارس کانفیگ: {0}", ex.Message);
Logger.Error("V2Ray config parse error", ex);
return false;
}
await File.WriteAllTextAsync(_configPath, singBoxJson, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
Logger.Info($"V2Ray config written to {_configPath}");
mixedProxyPortReservation.Dispose();
// Start sing-box process
_process = new Process
@@ -257,7 +262,7 @@ public class V2RayTunnelProvider : ITunnelProvider
_process.BeginOutputReadLine();
Logger.Info($"sing-box started (PID {_process.Id})");
Status.Message = "در حال انتظار برای interface TunnelX-V2Ray...";
Status.Message = LocalizationService.Instance.T("در حال انتظار برای interface TunnelX-V2Ray...");
// Wait up to 10 seconds for the TUN interface to appear
int interfaceIndex = -1;
@@ -268,7 +273,7 @@ public class V2RayTunnelProvider : ITunnelProvider
if (_process.HasExited)
{
Status.State = ConnectionState.Error;
Status.Message = $"sing-box زودتر خارج شد (exit code {_process.ExitCode}) — کانفیگ را بررسی کنید";
Status.Message = LocalizationService.Instance.Format("sing-box زودتر خارج شد (exit code {0}) — کانفیگ را بررسی کنید", _process.ExitCode);
Logger.Error(Status.Message);
await KillProcessAsync();
return false;
@@ -282,7 +287,7 @@ public class V2RayTunnelProvider : ITunnelProvider
if (interfaceIndex <= 0)
{
Status.State = ConnectionState.Error;
Status.Message = "interface TunnelX-V2Ray ظاهر نشد (timeout 10s)";
Status.Message = LocalizationService.Instance.T("interface TunnelX-V2Ray ظاهر نشد (timeout 10s)");
Logger.Error(Status.Message);
await KillProcessAsync();
return false;
@@ -290,28 +295,32 @@ public class V2RayTunnelProvider : ITunnelProvider
_vpnInterfaceIndex = interfaceIndex;
var statusServerHost = ExtractServerHost(config.V2RayConfig);
var statusServerPort = ExtractServerPort(config.V2RayConfig);
Status.State = ConnectionState.Connected;
Status.ConnectedSince = DateTime.Now;
Status.VpnLocalIp = VpnLocalIp;
Status.VpnServerIp = ExtractServerHost(config.V2RayConfig);
Status.VpnServerHost = statusServerHost;
Status.VpnServerIp = statusServerHost;
Status.VpnServerPort = statusServerPort;
Status.VpnInterfaceIndex = interfaceIndex;
Status.SingBoxMixedPort = MixedProxyPort;
Status.Message = "V2Ray connected";
Logger.Info($"V2Ray tunnel up — interface index {interfaceIndex}, server={Status.VpnServerIp}");
Status.SingBoxMixedPort = mixedProxyPort;
Status.Message = config.TunnelType == TunnelType.SocksProxy ? "Proxy connected" : "V2Ray connected";
Logger.Info($"{(config.TunnelType == TunnelType.SocksProxy ? "Proxy" : "V2Ray")} tunnel up — interface index {interfaceIndex}, server={Status.VpnServerIp}:{Status.VpnServerPort}");
return true;
}
catch (OperationCanceledException)
{
Status.State = ConnectionState.Disconnected;
Status.Message = "اتصال لغو شد";
Status.Message = LocalizationService.Instance.T("اتصال لغو شد");
await KillProcessAsync();
return false;
}
catch (Exception ex)
{
Status.State = ConnectionState.Error;
Status.Message = $"خطا: {ex.Message}";
Status.Message = LocalizationService.Instance.Format("خطا: {0}", ex.Message);
Logger.Error("V2RayTunnelProvider.ConnectAsync failed", ex);
await KillProcessAsync();
return false;
@@ -325,7 +334,7 @@ public class V2RayTunnelProvider : ITunnelProvider
public async Task DisconnectAsync()
{
Status.State = ConnectionState.Disconnecting;
Status.Message = "در حال قطع اتصال V2Ray...";
Status.Message = LocalizationService.Instance.T("در حال قطع اتصال V2Ray...");
// Stop watchdog from firing during/after deliberate disconnect.
_tunnelFailedFired = 1;
@@ -339,9 +348,12 @@ public class V2RayTunnelProvider : ITunnelProvider
Status.State = ConnectionState.Disconnected;
Status.ConnectedSince = null;
Status.VpnLocalIp = string.Empty;
Status.VpnServerHost = string.Empty;
Status.VpnServerIp = string.Empty;
Status.VpnServerPort = 0;
Status.VpnInterfaceIndex = -1;
Status.SingBoxMixedPort = 0;
Status.Message = "قطع شد";
Status.Message = LocalizationService.Instance.T("قطع شد");
}
// =========================================================================
@@ -368,7 +380,7 @@ public class V2RayTunnelProvider : ITunnelProvider
// Config builder
// =========================================================================
private string BuildSingBoxConfig(string userConfig, int tunMtu, bool enableDnsOptimization)
private string BuildSingBoxConfig(string userConfig, int tunMtu, bool enableDnsOptimization, int mixedProxyPort)
{
userConfig = userConfig.Trim();
@@ -397,6 +409,15 @@ public class V2RayTunnelProvider : ITunnelProvider
{
(outbound, outboundTag) = ParseShadowsocks(userConfig);
}
else if (userConfig.StartsWith("socks5://") ||
userConfig.StartsWith("socks://"))
{
(outbound, outboundTag) = ParseSocks5(userConfig);
}
else if (userConfig.StartsWith("http://"))
{
(outbound, outboundTag) = ParseHttp(userConfig);
}
else
{
throw new InvalidOperationException(
@@ -460,7 +481,7 @@ public class V2RayTunnelProvider : ITunnelProvider
["type"] = "mixed",
["tag"] = "mixed-in",
["listen"] = "127.0.0.1",
["listen_port"] = MixedProxyPort
["listen_port"] = mixedProxyPort
}
},
// direct outbound is required so sing-box can reach the proxy
@@ -596,6 +617,11 @@ public class V2RayTunnelProvider : ITunnelProvider
};
if (security == "reality")
{
tlsObj["utls"] = new JsonObject
{
["enabled"] = true,
["fingerprint"] = query.GetValueOrDefault("fp", "chrome")
};
tlsObj["reality"] = new JsonObject
{
["enabled"] = true,
@@ -748,6 +774,75 @@ public class V2RayTunnelProvider : ITunnelProvider
return (outbound, tag);
}
private static (JsonObject outbound, string tag) ParseSocks5(string uri)
{
var u = new Uri(uri);
var tag = Uri.UnescapeDataString(u.Fragment.TrimStart('#').Trim());
if (string.IsNullOrEmpty(tag)) tag = "socks5-out";
var outbound = new JsonObject
{
["type"] = "socks",
["tag"] = tag,
["server"] = u.Host,
["server_port"] = u.Port > 0 ? u.Port : 1080,
["version"] = "5"
};
if (!string.IsNullOrEmpty(u.UserInfo))
{
var userInfo = Uri.UnescapeDataString(u.UserInfo);
var colonIdx = userInfo.IndexOf(':');
if (colonIdx >= 0)
{
outbound["users"] = new JsonArray
{
new JsonObject
{
["username"] = userInfo[..colonIdx],
["password"] = userInfo[(colonIdx + 1)..]
}
};
}
}
return (outbound, tag);
}
private static (JsonObject outbound, string tag) ParseHttp(string uri)
{
var u = new Uri(uri);
var tag = Uri.UnescapeDataString(u.Fragment.TrimStart('#').Trim());
if (string.IsNullOrEmpty(tag)) tag = "http-out";
var outbound = new JsonObject
{
["type"] = "http",
["tag"] = tag,
["server"] = u.Host,
["server_port"] = u.Port > 0 ? u.Port : 3128
};
if (!string.IsNullOrEmpty(u.UserInfo))
{
var userInfo = Uri.UnescapeDataString(u.UserInfo);
var colonIdx = userInfo.IndexOf(':');
if (colonIdx >= 0)
{
outbound["users"] = new JsonArray
{
new JsonObject
{
["username"] = userInfo[..colonIdx],
["password"] = userInfo[(colonIdx + 1)..]
}
};
}
}
return (outbound, tag);
}
// =========================================================================
// Helpers
// =========================================================================
@@ -768,6 +863,22 @@ public class V2RayTunnelProvider : ITunnelProvider
catch { return ""; }
}
private static int ExtractServerPort(string userConfig)
{
try
{
userConfig = userConfig.Trim();
if (userConfig.StartsWith("{")) return 0;
var uri = new Uri(userConfig.Split('#')[0]);
if (uri.Port > 0) return uri.Port;
return uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)
? 3128
: 443;
}
catch { return 0; }
}
private static int FindInterfaceIndex(string interfaceName)
{
try
+6 -9
View File
@@ -11,10 +11,6 @@ public class VpnService
private ITunnelProvider? _activeProvider;
private readonly ConnectionStatus _defaultStatus = new();
/// <summary>
/// Invoked when the active V2Ray tunnel collapses unexpectedly.
/// Set this before calling ConnectAsync; it is forwarded to V2RayTunnelProvider.
/// </summary>
public Action? OnTunnelFailed { get; set; }
/// <summary>Live status, forwarded from the active provider.</summary>
@@ -22,11 +18,16 @@ public class VpnService
public async Task<bool> ConnectAsync(ServerConfig config, CancellationToken ct = default)
{
if (config.TunnelType == TunnelType.SocksProxy)
config.V2RayConfig = config.BuildProxyUri();
_activeProvider = config.TunnelType switch
{
TunnelType.L2tpIpsec => new L2tpTunnelProvider(),
TunnelType.V2Ray => TunnelProviderFactory.Create(config.V2RayConfig),
_ => throw new NotImplementedException($"نوع تانل ناشناخته: {config.TunnelType}")
TunnelType.OpenVpn => new OpenVpnTunnelProvider(),
TunnelType.SocksProxy => new V2RayTunnelProvider(),
_ => throw new NotImplementedException(LocalizationService.Instance.Format("نوع تانل ناشناخته: {0}", config.TunnelType))
};
// Wire up the tunnel-failure watchdog for V2Ray connections.
@@ -42,9 +43,5 @@ public class VpnService
await _activeProvider.DisconnectAsync();
}
/// <summary>
/// Returns true when the active provider's network interface is still operational.
/// Mirrors the health-check used by the connection monitor.
/// </summary>
public bool IsInterfaceUp() => _activeProvider?.IsInterfaceUp() ?? false;
}
+65 -22
View File
@@ -14,8 +14,8 @@ public class XrayTunnelProvider : ITunnelProvider
private const string TunAddress = "172.18.0.1/30";
private const string VpnLocalIp = "172.18.0.1";
private const int DefaultTunMtu = 1500;
private const int MixedProxyPort = 2080;
private const int XraySocksPort = 2081;
private const int DefaultMixedProxyPort = 2080;
private const int DefaultXraySocksPort = 2081;
private readonly string _workDir;
private readonly string _xrayConfigPath;
@@ -53,7 +53,7 @@ public class XrayTunnelProvider : ITunnelProvider
public async Task<bool> ConnectAsync(ServerConfig config, CancellationToken ct)
{
Status.State = ConnectionState.Connecting;
Status.Message = "در حال آماده سازی Xray...";
Status.Message = LocalizationService.Instance.T("در حال آماده سازی Xray...");
try
{
@@ -62,9 +62,9 @@ public class XrayTunnelProvider : ITunnelProvider
await EnsureEmbeddedExeExtractedAsync("sing-box.exe", _singBoxExe, ct);
if (!File.Exists(_xrayExe))
return Fail($"فایل xray.exe پیدا نشد: {_xrayExe}");
return Fail(LocalizationService.Instance.Format("فایل xray.exe پیدا نشد: {0}", _xrayExe));
if (!File.Exists(_singBoxExe))
return Fail($"فایل sing-box.exe پیدا نشد: {_singBoxExe}");
return Fail(LocalizationService.Instance.Format("فایل sing-box.exe پیدا نشد: {0}", _singBoxExe));
var outbound = BuildXrayOutbound(config.V2RayConfig);
if (config.EnableDnsOptimization)
@@ -85,12 +85,23 @@ public class XrayTunnelProvider : ITunnelProvider
Logger.Info($"[MTU] Auto-tune disabled; using MTU={tunMtu}");
}
var xrayJson = BuildXraySocksConfig(outbound);
var singBoxJson = BuildTunBridgeConfig(tunMtu);
using var xraySocksPortReservation = LocalPortReservation.ReservePreferredOrRandom(DefaultXraySocksPort);
using var mixedProxyPortReservation = LocalPortReservation.ReservePreferredOrRandom(
DefaultMixedProxyPort,
xraySocksPortReservation.Port);
var xraySocksPort = xraySocksPortReservation.Port;
var mixedProxyPort = mixedProxyPortReservation.Port;
Logger.Info($"[PORT] Xray SOCKS=127.0.0.1:{xraySocksPort}, mixed proxy=127.0.0.1:{mixedProxyPort}");
var xrayJson = BuildXraySocksConfig(outbound, xraySocksPort);
var singBoxJson = BuildTunBridgeConfig(tunMtu, mixedProxyPort, xraySocksPort);
await File.WriteAllTextAsync(_xrayConfigPath, xrayJson, new UTF8Encoding(false), ct);
await File.WriteAllTextAsync(_singBoxConfigPath, singBoxJson, new UTF8Encoding(false), ct);
xraySocksPortReservation.Dispose();
mixedProxyPortReservation.Dispose();
_xrayProcess = StartProcess(
_xrayExe,
$"run -c \"{_xrayConfigPath}\"",
@@ -106,22 +117,24 @@ public class XrayTunnelProvider : ITunnelProvider
"[sing-box bridge stderr]");
Logger.Info($"sing-box TUN bridge started (PID {_singBoxProcess.Id})");
Status.Message = "در حال انتظار برای interface TunnelX-V2Ray...";
Status.Message = LocalizationService.Instance.T("در حال انتظار برای interface TunnelX-V2Ray...");
var interfaceIndex = await WaitForTunInterfaceAsync(ct);
if (interfaceIndex <= 0)
{
await KillProcessAsync();
return Fail("interface TunnelX-V2Ray ظاهر نشد (timeout 10s)");
return Fail(LocalizationService.Instance.T("interface TunnelX-V2Ray ظاهر نشد (timeout 10s)"));
}
_vpnInterfaceIndex = interfaceIndex;
Status.State = ConnectionState.Connected;
Status.ConnectedSince = DateTime.Now;
Status.VpnLocalIp = VpnLocalIp;
Status.VpnServerIp = ExtractServerHost(config.V2RayConfig);
Status.VpnServerHost = ExtractServerHost(config.V2RayConfig);
Status.VpnServerIp = Status.VpnServerHost;
Status.VpnServerPort = ExtractServerPort(config.V2RayConfig);
Status.VpnInterfaceIndex = interfaceIndex;
Status.SingBoxMixedPort = MixedProxyPort;
Status.SingBoxMixedPort = mixedProxyPort;
Status.Message = "Xray connected";
Logger.Info($"Xray tunnel up via sing-box TUN bridge — interface index {interfaceIndex}, server={Status.VpnServerIp}");
@@ -130,7 +143,7 @@ public class XrayTunnelProvider : ITunnelProvider
catch (OperationCanceledException)
{
Status.State = ConnectionState.Disconnected;
Status.Message = "اتصال لغو شد";
Status.Message = LocalizationService.Instance.T("اتصال لغو شد");
await KillProcessAsync();
return false;
}
@@ -138,7 +151,7 @@ public class XrayTunnelProvider : ITunnelProvider
{
Logger.Error("XrayTunnelProvider.ConnectAsync failed", ex);
Status.State = ConnectionState.Error;
Status.Message = $"خطا: {ex.Message}";
Status.Message = LocalizationService.Instance.Format("خطا: {0}", ex.Message);
await KillProcessAsync();
return false;
}
@@ -147,7 +160,7 @@ public class XrayTunnelProvider : ITunnelProvider
public async Task DisconnectAsync()
{
Status.State = ConnectionState.Disconnecting;
Status.Message = "در حال قطع اتصال Xray...";
Status.Message = LocalizationService.Instance.T("در حال قطع اتصال Xray...");
await KillProcessAsync();
@@ -158,9 +171,12 @@ public class XrayTunnelProvider : ITunnelProvider
Status.State = ConnectionState.Disconnected;
Status.ConnectedSince = null;
Status.VpnLocalIp = string.Empty;
Status.VpnServerHost = string.Empty;
Status.VpnServerIp = string.Empty;
Status.VpnServerPort = 0;
Status.VpnInterfaceIndex = -1;
Status.SingBoxMixedPort = 0;
Status.Message = "قطع شد";
Status.Message = LocalizationService.Instance.T("قطع شد");
}
public bool IsInterfaceUp()
@@ -214,7 +230,7 @@ public class XrayTunnelProvider : ITunnelProvider
throw new InvalidOperationException("xhttp فعلا فقط برای vless:// یا JSON دارای outbound xhttp پشتیبانی می‌شود");
}
private static string BuildXraySocksConfig(JsonObject outbound)
private static string BuildXraySocksConfig(JsonObject outbound, int xraySocksPort)
{
var doc = new JsonObject
{
@@ -224,7 +240,7 @@ public class XrayTunnelProvider : ITunnelProvider
new JsonObject
{
["listen"] = "127.0.0.1",
["port"] = XraySocksPort,
["port"] = xraySocksPort,
["protocol"] = "socks",
["settings"] = new JsonObject
{
@@ -260,7 +276,7 @@ public class XrayTunnelProvider : ITunnelProvider
}
}
private static string BuildTunBridgeConfig(int tunMtu)
private static string BuildTunBridgeConfig(int tunMtu, int mixedProxyPort, int xraySocksPort)
{
var doc = new JsonObject
{
@@ -283,7 +299,7 @@ public class XrayTunnelProvider : ITunnelProvider
["type"] = "mixed",
["tag"] = "mixed-in",
["listen"] = "127.0.0.1",
["listen_port"] = MixedProxyPort
["listen_port"] = mixedProxyPort
}
},
["outbounds"] = new JsonArray
@@ -293,7 +309,7 @@ public class XrayTunnelProvider : ITunnelProvider
["type"] = "socks",
["tag"] = "xray-socks",
["server"] = "127.0.0.1",
["server_port"] = XraySocksPort,
["server_port"] = xraySocksPort,
["version"] = "5"
},
new JsonObject { ["type"] = "direct", ["tag"] = "direct" }
@@ -486,9 +502,9 @@ public class XrayTunnelProvider : ITunnelProvider
while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
{
if (_xrayProcess?.HasExited == true)
throw new InvalidOperationException($"xray زودتر خارج شد (exit code {_xrayProcess.ExitCode})");
throw new InvalidOperationException(LocalizationService.Instance.Format("xray زودتر خارج شد (exit code {0})", _xrayProcess.ExitCode));
if (_singBoxProcess?.HasExited == true)
throw new InvalidOperationException($"sing-box bridge زودتر خارج شد (exit code {_singBoxProcess.ExitCode})");
throw new InvalidOperationException(LocalizationService.Instance.Format("sing-box bridge زودتر خارج شد (exit code {0})", _singBoxProcess.ExitCode));
var idx = FindInterfaceIndex(TunInterfaceName);
if (idx > 0) return idx;
@@ -637,6 +653,33 @@ public class XrayTunnelProvider : ITunnelProvider
return "";
}
private static int ExtractServerPort(string userConfig)
{
try
{
userConfig = userConfig.Trim();
if (!userConfig.StartsWith("{"))
{
var uri = new Uri(userConfig.Split('#')[0]);
return uri.Port > 0 ? uri.Port : 443;
}
var root = JsonNode.Parse(userConfig)?.AsObject();
if (root?["outbounds"] is JsonArray outbounds)
{
foreach (var item in outbounds.OfType<JsonObject>())
{
var port = item["server_port"]?.GetValue<int>() ??
item["settings"]?["vnext"]?[0]?["port"]?.GetValue<int>();
if (port is > 0 and <= 65535) return port.Value;
}
}
}
catch { }
return 0;
}
private static int FindInterfaceIndex(string interfaceName)
{
try
@@ -69,7 +69,7 @@ public partial class MainViewModel
var dialog = new OpenFileDialog
{
Filter = "Applications (*.exe)|*.exe",
Title = "انتخاب برنامه"
Title = LocalizationService.Instance.T("انتخاب برنامه")
};
if (dialog.ShowDialog() == true)
+552 -28
View File
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.NetworkInformation;
@@ -23,6 +24,7 @@ public partial class MainViewModel
if (_connectionState == ConnectionState.Connecting)
{
// Cancel ongoing connection attempt
StatusText = "در حال لغو اتصال...";
_connectionCts?.Cancel();
return;
}
@@ -54,10 +56,33 @@ public partial class MainViewModel
StatusText = "کانفیگ V2Ray را وارد کنید";
return;
}
if (!ValidateSocks5Port(out var socksError))
if (tunnelType == TunnelType.OpenVpn && string.IsNullOrWhiteSpace(_selectedProfile?.OpenVpnConfig))
{
Logger.Warning("ConnectAsync: OpenVPN config is empty");
StatusText = "کانفیگ OpenVPN (.ovpn) را وارد کنید";
return;
}
if (tunnelType == TunnelType.SocksProxy && !ValidateProxySettings(out var proxyError))
{
Logger.Warning($"ConnectAsync: proxy settings invalid: {proxyError}");
StatusText = proxyError;
ConfigValidationText = proxyError;
return;
}
if (tunnelType == TunnelType.OpenVpn && !IsOpenVpnCommunityInstalled)
{
RefreshOpenVpnInstallStatus();
if (!IsOpenVpnCommunityInstalled)
{
Logger.Warning("ConnectAsync: OpenVPN Community openvpn.exe not found");
StatusText = "OpenVPN Community نصب نیست؛ ابتدا از لینک رسمی نصب کنید";
return;
}
}
if (!ValidateMixedProxyPort(out var socksError))
{
StatusText = socksError;
Socks5PortStatusText = socksError;
MixedProxyPortStatusText = socksError;
return;
}
@@ -75,7 +100,13 @@ public partial class MainViewModel
IsBusy = true;
ConnectionState = ConnectionState.Connecting;
StatusText = "در حال اتصال...";
StatusText = tunnelType == TunnelType.OpenVpn
? "در حال آماده‌سازی OpenVPN..."
: "در حال اتصال...";
// Give WPF one dispatcher turn to render the connecting view before
// provider startup does DNS/process/network work.
await Task.Yield();
var config = _selectedProfile?.ToServerConfig() ?? new ServerConfig
{
@@ -83,6 +114,16 @@ public partial class MainViewModel
Username = Username.Trim(),
Password = Password,
PreSharedKey = PreSharedKey,
TunnelType = _currentTunnelType,
V2RayConfig = _selectedV2RayConfig,
OpenVpnConfig = _selectedOpenVpnConfig,
OpenVpnUsername = OpenVpnUsername,
OpenVpnPassword = OpenVpnPassword,
ProxyProtocol = ProxyProtocol,
ProxyServerAddress = ProxyServerAddress,
ProxyPort = ProxyPort,
ProxyUsername = ProxyUsername,
ProxyPassword = ProxyPassword,
AutoTuneMtu = AutoTuneMtu,
EnableDnsOptimization = IsDnsOptimizationEnabled,
EnableGameMode = IsGameModeEnabled
@@ -100,6 +141,7 @@ public partial class MainViewModel
}
catch (OperationCanceledException)
{
await CleanupAfterFailedConnectionAsync();
ConnectionState = ConnectionState.Disconnected;
StatusText = "اتصال لغو شد";
IsBusy = false;
@@ -109,46 +151,102 @@ public partial class MainViewModel
if (success)
{
ConnectionState = ConnectionState.Connected;
StatusText = _vpnService.Status.Message;
StatusText = tunnelType == TunnelType.SocksProxy
? "پراکسی متصل شد"
: _vpnService.Status.Message;
VpnIp = _vpnService.Status.VpnLocalIp;
ConnectionIpText = "در حال دریافت...";
VpnAdapterName = ResolveInterfaceName(_vpnService.Status.VpnInterfaceIndex);
_currentVpnInterfaceIndex = _vpnService.Status.VpnInterfaceIndex;
_currentVpnGatewayIp = _vpnService.Status.VpnGatewayIp;
_connectionStartTime = DateTime.Now;
LastActiveProfileId = _selectedProfile?.Id;
OnPropertyChanged(nameof(ConnectedProfileName));
OnPropertyChanged(nameof(SelectedProfileSummaryText));
RaiseHealthStatusChanged();
// Start traffic routing for enabled apps
StartTrafficRouterForCurrentStatus(resetAppCounters: true);
_vpnHealthCheckCounter = 0;
_timer.Start();
var exitIpProxyPort = _vpnService.Status.SingBoxMixedPort > 0
? _vpnService.Status.SingBoxMixedPort
: _trafficRouter.Socks5Port;
_ = RefreshExitIpAsync(exitIpProxyPort);
_ = RefreshGitHubInstallCountAsync(exitIpProxyPort);
}
else
{
var failedState = _vpnService.Status.State;
var failedMessage = _vpnService.Status.Message;
await CleanupAfterFailedConnectionAsync();
if (failedState == ConnectionState.Disconnected)
{
ConnectionState = ConnectionState.Disconnected;
StatusText = failedMessage;
}
else
{
ConnectionState = ConnectionState.Error;
StatusText = failedMessage;
}
}
IsBusy = false;
}
private async Task CleanupAfterFailedConnectionAsync()
{
_timer.Stop();
_pingCts?.Cancel();
IsPinging = false;
try { await _trafficRouter.StopAsync(); }
catch (Exception ex) { Logger.Warning($"CleanupAfterFailedConnectionAsync router cleanup failed: {ex.Message}"); }
try { await _vpnService.DisconnectAsync(); }
catch (Exception ex) { Logger.Warning($"CleanupAfterFailedConnectionAsync VPN cleanup failed: {ex.Message}"); }
VpnIp = "";
ConnectionIpText = "-";
VpnAdapterName = "";
_currentVpnInterfaceIndex = -1;
_currentVpnGatewayIp = "";
_isFullRouteEnabled = false;
OnPropertyChanged(nameof(IsFullRouteEnabled));
OnPropertyChanged(nameof(FullRouteStatusText));
RaiseHealthStatusChanged();
}
private void StartTrafficRouterForCurrentStatus(bool resetAppCounters)
{
var enabledApps = TunnelApps.Where(a => a.IsEnabled).ToList();
_trafficRouter.ClearTargetApps();
foreach (var app in enabledApps)
{
if (resetAppCounters)
{
app.BytesSent = 0;
app.BytesReceived = 0;
}
_trafficRouter.AddTargetApp(app.ExecutableName);
}
// Load user's exclude list (domains/IPs to bypass tunnel)
_trafficRouter.SetExcludedDestinations(ExcludedDestinations);
_trafficRouter.SetIncludedDestinations(IncludedDestinations);
_trafficRouter.Socks5Port = Socks5Port;
_trafficRouter.Socks5Port = MixedProxyPort;
_trafficRouter.EnableDnsOptimization = IsDnsOptimizationEnabled;
_trafficRouter.EnableGameMode = IsGameModeEnabled;
_trafficRouter.Start(
_vpnService.Status.VpnInterfaceIndex,
_vpnService.Status.VpnLocalIp,
_vpnService.Status.VpnServerIp); // actual proxy/VPN server host, resolved by TrafficRouter
_vpnHealthCheckCounter = 0;
_timer.Start();
}
else
{
ConnectionState = ConnectionState.Error;
StatusText = _vpnService.Status.Message;
}
IsBusy = false;
_vpnService.Status.VpnServerIp,
_vpnService.Status.VpnGatewayIp,
_vpnService.Status.VpnServerPort,
resetCounters: resetAppCounters);
}
private async Task DisconnectAsync()
@@ -170,7 +268,10 @@ public partial class MainViewModel
ConnectionState = ConnectionState.Disconnected;
StatusText = "قطع شد";
VpnIp = "";
ConnectionIpText = "-";
VpnAdapterName = "";
_currentVpnInterfaceIndex = -1;
_currentVpnGatewayIp = "";
_isFullRouteEnabled = false;
OnPropertyChanged(nameof(IsFullRouteEnabled));
OnPropertyChanged(nameof(FullRouteStatusText));
@@ -220,7 +321,10 @@ public partial class MainViewModel
ConnectionState = ConnectionState.Disconnected;
StatusText = "اتصال VPN به‌طور غیرمنتظره قطع شد";
VpnIp = "";
ConnectionIpText = "-";
VpnAdapterName = "";
_currentVpnInterfaceIndex = -1;
_currentVpnGatewayIp = "";
_isFullRouteEnabled = false;
OnPropertyChanged(nameof(IsFullRouteEnabled));
OnPropertyChanged(nameof(FullRouteStatusText));
@@ -252,11 +356,20 @@ public partial class MainViewModel
}
private int _vpnHealthCheckCounter;
private bool _isRefreshingOpenVpnRouter;
private int _currentVpnInterfaceIndex = -1;
private string _currentVpnGatewayIp = "";
private void UpdateTimerTick()
{
if (!IsConnected) return;
if (CurrentTunnelType == TunnelType.OpenVpn && OpenVpnRuntimeEndpointChanged())
{
_ = RefreshOpenVpnRouterAsync();
return;
}
// Check VPN interface health every 5 seconds
if (++_vpnHealthCheckCounter >= 5)
{
@@ -296,6 +409,88 @@ public partial class MainViewModel
RaiseHealthStatusChanged();
}
private bool OpenVpnRuntimeEndpointChanged()
{
_vpnService.IsInterfaceUp(); // lets the OpenVPN provider publish post-reconnect IP/gateway changes.
var status = _vpnService.Status;
if (string.IsNullOrWhiteSpace(status.VpnLocalIp) ||
string.IsNullOrWhiteSpace(status.VpnGatewayIp) ||
status.VpnInterfaceIndex <= 0)
return false;
return !string.Equals(status.VpnLocalIp, VpnIp, StringComparison.OrdinalIgnoreCase) ||
status.VpnInterfaceIndex != _currentVpnInterfaceIndex ||
!string.Equals(status.VpnGatewayIp, _currentVpnGatewayIp, StringComparison.OrdinalIgnoreCase);
}
private async Task RefreshOpenVpnRouterAsync()
{
if (_isRefreshingOpenVpnRouter) return;
if (!IsConnected || CurrentTunnelType != TunnelType.OpenVpn) return;
try
{
_isRefreshingOpenVpnRouter = true;
var status = _vpnService.Status;
if (string.IsNullOrWhiteSpace(status.VpnLocalIp) ||
string.IsNullOrWhiteSpace(status.VpnGatewayIp) ||
status.VpnInterfaceIndex <= 0)
return;
if (string.Equals(status.VpnLocalIp, VpnIp, StringComparison.OrdinalIgnoreCase) &&
status.VpnInterfaceIndex == _currentVpnInterfaceIndex &&
string.Equals(status.VpnGatewayIp, _currentVpnGatewayIp, StringComparison.OrdinalIgnoreCase))
return;
var wasFullRoute = IsFullRouteEnabled;
Logger.Warning($"[OpenVPN] Runtime endpoint changed; restarting TrafficRouter. OldIP={VpnIp} NewIP={status.VpnLocalIp} Gateway={status.VpnGatewayIp} IF={status.VpnInterfaceIndex}");
StatusText = "OpenVPN دوباره متصل شد؛ مسیرهای TunnelX در حال بروزرسانی است...";
_timer.Stop();
_pingCts?.Cancel();
IsPinging = false;
await _trafficRouter.StopAsync(resetCounters: false);
VpnIp = status.VpnLocalIp;
ConnectionIpText = "در حال دریافت...";
VpnAdapterName = ResolveInterfaceName(status.VpnInterfaceIndex);
_currentVpnInterfaceIndex = status.VpnInterfaceIndex;
_currentVpnGatewayIp = status.VpnGatewayIp;
_isFullRouteEnabled = false;
OnPropertyChanged(nameof(IsFullRouteEnabled));
OnPropertyChanged(nameof(FullRouteStatusText));
StartTrafficRouterForCurrentStatus(resetAppCounters: false);
var exitIpProxyPort = _vpnService.Status.SingBoxMixedPort > 0
? _vpnService.Status.SingBoxMixedPort
: _trafficRouter.Socks5Port;
_ = RefreshExitIpAsync(exitIpProxyPort);
_ = RefreshGitHubInstallCountAsync(exitIpProxyPort);
if (wasFullRoute)
{
_isFullRouteEnabled = _trafficRouter.SetFullRouteEnabled(true);
OnPropertyChanged(nameof(IsFullRouteEnabled));
OnPropertyChanged(nameof(FullRouteStatusText));
}
_vpnHealthCheckCounter = 0;
StatusText = "OpenVPN دوباره متصل شد و مسیرها بروزرسانی شدند";
RaiseHealthStatusChanged();
}
catch (Exception ex)
{
Logger.Error("[OpenVPN] TrafficRouter refresh after reconnect failed", ex);
await HandleVpnDroppedAsync();
}
finally
{
_isRefreshingOpenVpnRouter = false;
if (IsConnected)
_timer.Start();
}
}
private void OnTrafficUpdated(string exeName, long sent, long received)
{
Application.Current?.Dispatcher.BeginInvoke(() =>
@@ -337,6 +532,103 @@ public partial class MainViewModel
return interfaceIndex > 0 ? $"IF {interfaceIndex}" : "-";
}
private async Task RefreshExitIpAsync(int proxyPort)
{
if (proxyPort <= 0)
{
ConnectionIpText = "-";
return;
}
var hosts = new[] { "api.ipify.org", "ipv4.icanhazip.com" };
Exception? lastError = null;
for (var attempt = 1; attempt <= 4 && _connectionState == ConnectionState.Connected; attempt++)
{
foreach (var host in hosts)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
var ip = await QueryPublicIpViaHttpConnectProxyAsync(proxyPort, host, cts.Token);
if (IPAddress.TryParse(ip, out _))
{
ConnectionIpText = ip;
return;
}
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or SocketException or AuthenticationException)
{
lastError = ex;
}
}
await Task.Delay(TimeSpan.FromMilliseconds(1200 * attempt));
}
if (lastError != null)
Logger.Warning($"[EXIT-IP] Public IP check failed after retries: {lastError.Message}");
if (_connectionState == ConnectionState.Connected)
ConnectionIpText = "-";
}
private static async Task<string> QueryPublicIpViaHttpConnectProxyAsync(int proxyPort, string host, CancellationToken ct)
{
using var tcp = new TcpClient();
tcp.NoDelay = true;
await tcp.ConnectAsync("127.0.0.1", proxyPort, ct);
await using var stream = tcp.GetStream();
var connectRequest = Encoding.ASCII.GetBytes(
$"CONNECT {host}:443 HTTP/1.1\r\nHost: {host}:443\r\n\r\n");
await stream.WriteAsync(connectRequest, ct);
var connectHeader = await ReadHttpHeaderAsync(stream, ct);
if (!connectHeader.StartsWith("HTTP/1.1 200", StringComparison.OrdinalIgnoreCase) &&
!connectHeader.StartsWith("HTTP/1.0 200", StringComparison.OrdinalIgnoreCase))
throw new IOException("proxy CONNECT failed");
using var ssl = new SslStream(stream, leaveInnerStreamOpen: false, (_, _, _, _) => true);
await ssl.AuthenticateAsClientAsync(host, null, SslProtocols.Tls12 | SslProtocols.Tls13, checkCertificateRevocation: false);
var request = Encoding.ASCII.GetBytes(
$"GET / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: TunnelX\r\n\r\n");
await ssl.WriteAsync(request, ct);
using var ms = new MemoryStream();
var buffer = new byte[2048];
int read;
while ((read = await ssl.ReadAsync(buffer, ct)) > 0)
ms.Write(buffer, 0, read);
var response = Encoding.UTF8.GetString(ms.ToArray());
var split = response.IndexOf("\r\n\r\n", StringComparison.Ordinal);
var body = split >= 0 ? response[(split + 4)..] : response;
return body.Trim();
}
private static async Task<string> ReadHttpHeaderAsync(NetworkStream stream, CancellationToken ct)
{
using var ms = new MemoryStream();
var buffer = new byte[1];
while (ms.Length < 8192)
{
var read = await stream.ReadAsync(buffer, ct);
if (read == 0) break;
ms.WriteByte(buffer[0]);
var bytes = ms.ToArray();
if (bytes.Length >= 4 &&
bytes[^4] == '\r' &&
bytes[^3] == '\n' &&
bytes[^2] == '\r' &&
bytes[^1] == '\n')
break;
}
return Encoding.ASCII.GetString(ms.ToArray());
}
#endregion
#region Pre-connect server test
@@ -369,6 +661,58 @@ public partial class MainViewModel
return;
}
if (CurrentTunnelType == TunnelType.OpenVpn)
{
var openVpnEndpoints = ExtractOpenVpnRemoteEndpoints(SelectedOpenVpnConfig).ToList();
if (openVpnEndpoints.Count == 0)
{
ServerPingResult = "remote سرور در فایل .ovpn پیدا نشد";
return;
}
var tcpEndpoints = openVpnEndpoints
.Where(e => !e.Protocol.Contains("udp", StringComparison.OrdinalIgnoreCase))
.ToList();
if (tcpEndpoints.Count == 0)
{
ServerPingResult = "کانفیگ UDP است؛ تست دقیق قبل از اتصال ممکن نیست";
return;
}
Exception? lastError = null;
foreach (var endpointToTest in tcpEndpoints)
{
try
{
using var ctsOpenVpn = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msOpenVpn = await MeasureTcpConnectLatencyAsync(endpointToTest.Host, endpointToTest.Port, ctsOpenVpn.Token);
ServerPingResult = $"TCP {msOpenVpn} ms";
return;
}
catch (Exception ex) when (ex is OperationCanceledException or SocketException or TimeoutException)
{
lastError = ex;
}
}
ServerPingResult = LocalizationService.Instance.Format("هیچ remote قابل‌دسترسی نبود ({0})", lastError?.Message ?? "timeout");
return;
}
if (CurrentTunnelType == TunnelType.SocksProxy)
{
if (!ValidateProxySettings(out var proxyError))
{
ServerPingResult = proxyError;
return;
}
using var ctsProxy = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var proxyMs = await MeasureTcpConnectLatencyAsync(ProxyServerAddress.Trim(), ProxyPort, ctsProxy.Token);
ServerPingResult = $"TCP {proxyMs} ms";
return;
}
var rawConfig = SelectedV2RayConfig.Trim();
if (!TryExtractProxyEndpointDetails(rawConfig, out var endpoint, out var error))
{
@@ -376,10 +720,10 @@ public partial class MainViewModel
return;
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var ms = await MeasureEndpointLatencyAsync(endpoint, cts.Token);
var mode = endpoint.UseTls ? "TLS handshake" : "TCP connect";
ServerPingResult = $"{mode} {endpoint.Server}:{endpoint.Port} {ms} ms";
using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var ms2 = await MeasureEndpointLatencyAsync(endpoint, cts2.Token);
var mode = endpoint.UseTls ? "TLS" : "TCP";
ServerPingResult = $"{mode} {ms2} ms";
}
catch (OperationCanceledException)
{
@@ -387,7 +731,7 @@ public partial class MainViewModel
}
catch (Exception ex)
{
ServerPingResult = $"خطا: {ex.Message}";
ServerPingResult = LocalizationService.Instance.Format("خطا: {0}", ex.Message);
}
finally
{
@@ -395,7 +739,113 @@ public partial class MainViewModel
}
}
private async Task TestConnectedServerPingAsync()
{
if (IsTestingConnectedServerPing) return;
if (IsPinging)
{
_pingCts?.Cancel();
IsPinging = false;
}
IsTestingConnectedServerPing = true;
PingResult = "در حال پینگ سرور...";
try
{
if (CurrentTunnelType == TunnelType.OpenVpn)
{
var connectedHost = _vpnService.Status.VpnServerIp;
var connectedPort = _vpnService.Status.VpnServerPort;
if (!string.IsNullOrWhiteSpace(connectedHost) && connectedPort > 0)
{
using var ctsConnectedOpenVpn = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var connectedMs = await MeasureTcpConnectLatencyAsync(connectedHost, connectedPort, ctsConnectedOpenVpn.Token);
PingResult = $"TCP {connectedMs} ms";
return;
}
if (!TryExtractOpenVpnRemoteEndpoint(SelectedOpenVpnConfig, out var openVpnEndpoint, out var openVpnError))
{
PingResult = openVpnError;
return;
}
if (openVpnEndpoint.Protocol.Contains("udp", StringComparison.OrdinalIgnoreCase))
{
using var ping = new Ping();
var reply = await ping.SendPingAsync(openVpnEndpoint.Host, 3000);
PingResult = reply.Status == IPStatus.Success
? $"ICMP {reply.RoundtripTime} ms"
: $"ICMP {reply.Status}";
return;
}
using var ctsOpenVpn = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msOpenVpn = await MeasureTcpConnectLatencyAsync(openVpnEndpoint.Host, openVpnEndpoint.Port, ctsOpenVpn.Token);
PingResult = $"TCP {msOpenVpn} ms";
return;
}
if (CurrentTunnelType == TunnelType.L2tpIpsec)
{
var host = ServerAddress.Trim();
if (string.IsNullOrWhiteSpace(host))
{
PingResult = "آدرس سرور خالی است";
return;
}
using var ping = new Ping();
var reply = await ping.SendPingAsync(host, 3000);
PingResult = reply.Status == IPStatus.Success
? $"ICMP {reply.RoundtripTime} ms"
: $"ICMP {reply.Status}";
return;
}
if (CurrentTunnelType == TunnelType.SocksProxy)
{
if (!ValidateProxySettings(out var proxyError))
{
PingResult = proxyError;
return;
}
using var ctsProxy = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var proxyMs = await MeasureTcpConnectLatencyAsync(ProxyServerAddress.Trim(), ProxyPort, ctsProxy.Token);
PingResult = $"TCP {proxyMs} ms";
return;
}
if (!TryExtractProxyEndpointDetails(SelectedV2RayConfig.Trim(), out var endpoint, out var error))
{
PingResult = error;
return;
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var ms = await MeasureEndpointLatencyAsync(endpoint, cts.Token);
var mode = endpoint.UseTls ? "TLS" : "TCP";
PingResult = $"{mode} {ms} ms";
}
catch (OperationCanceledException)
{
PingResult = "پینگ سرور timeout شد";
}
catch (Exception ex)
{
PingResult = LocalizationService.Instance.Format("خطا: {0}", ex.Message);
}
finally
{
IsTestingConnectedServerPing = false;
}
}
private readonly record struct ProxyEndpoint(string Server, int Port, bool UseTls, string? Sni);
private readonly record struct OpenVpnRemoteEndpoint(string Host, int Port, string Protocol);
private static async Task<long> MeasureEndpointLatencyAsync(ProxyEndpoint endpoint, CancellationToken ct)
{
@@ -420,6 +870,70 @@ public partial class MainViewModel
return sw.ElapsedMilliseconds;
}
private static async Task<long> MeasureTcpConnectLatencyAsync(string host, int port, CancellationToken ct)
{
using var tcp = new TcpClient();
tcp.NoDelay = true;
var sw = System.Diagnostics.Stopwatch.StartNew();
await tcp.ConnectAsync(host, port, ct);
sw.Stop();
return sw.ElapsedMilliseconds;
}
private static bool TryExtractOpenVpnRemoteEndpoint(
string config,
out OpenVpnRemoteEndpoint endpoint,
out string error)
{
endpoint = default;
error = "";
if (string.IsNullOrWhiteSpace(config))
{
error = "فایل .ovpn انتخاب نشده است";
return false;
}
foreach (var endpointToTest in ExtractOpenVpnRemoteEndpoints(config))
{
endpoint = endpointToTest;
return true;
}
error = "remote سرور در فایل .ovpn پیدا نشد";
return false;
}
private static IEnumerable<OpenVpnRemoteEndpoint> ExtractOpenVpnRemoteEndpoints(string config)
{
if (string.IsNullOrWhiteSpace(config))
yield break;
foreach (var line in config.Split('\n'))
{
var raw = line.Trim();
if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#") || raw.StartsWith(";"))
continue;
if (!raw.StartsWith("remote ", StringComparison.OrdinalIgnoreCase))
continue;
var parts = raw.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
continue;
var host = parts[1];
var port = parts.Length >= 3 && int.TryParse(parts[2], out var parsedPort)
? parsedPort
: 1194;
var protocol = parts.Length >= 4 ? parts[3] : "";
if (string.IsNullOrWhiteSpace(host) || port <= 0 || port > 65535)
continue;
yield return new OpenVpnRemoteEndpoint(host, port, protocol);
}
}
private static bool TryExtractProxyEndpoint(string config, out string server, out int port, out string error)
{
if (TryExtractProxyEndpointDetails(config, out var endpoint, out error))
@@ -480,6 +994,16 @@ public partial class MainViewModel
return true;
}
// SOCKS5 / HTTP proxy URIs
if (config.StartsWith("socks5://", StringComparison.OrdinalIgnoreCase) ||
config.StartsWith("socks://", StringComparison.OrdinalIgnoreCase) ||
config.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
var uri = new Uri(config.Split('#')[0]);
endpoint = new ProxyEndpoint(uri.Host, uri.Port > 0 ? uri.Port : 1080, false, null);
return ValidateEndpoint(endpoint.Server, endpoint.Port, out error);
}
if (config.StartsWith("{"))
{
var root = JsonNode.Parse(config)?.AsObject();
@@ -514,7 +1038,7 @@ public partial class MainViewModel
}
catch (Exception ex)
{
error = $"پارس کانفیگ ناموفق بود: {ex.Message}";
error = LocalizationService.Instance.Format("پارس کانفیگ ناموفق بود: {0}", ex.Message);
return false;
}
}
@@ -680,7 +1204,7 @@ public partial class MainViewModel
{
IsPinging = false;
if (sent > 0)
PingResult += " [پایان]";
PingResult += LocalizationService.Instance.T(" [پایان]");
}
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,8 @@
using System.Collections.ObjectModel;
using System.IO;
using AppTunnel.Models;
using AppTunnel.Services;
using AppTunnel.Views;
namespace AppTunnel.ViewModels;
@@ -17,10 +19,14 @@ public partial class MainViewModel
set
{
if (_selectedProfile == value) return;
_saveDebounceTimer.Stop();
SaveCurrentProfileState();
_selectedProfile = value;
OnPropertyChanged();
OnPropertyChanged(nameof(SelectedProfileName));
OnPropertyChanged(nameof(ConnectedProfileName));
OnPropertyChanged(nameof(SelectedProfileSummaryText));
RaiseProfileCardChanged();
if (value != null)
LoadProfileIntoUi(value);
SaveProfiles();
@@ -28,11 +34,44 @@ public partial class MainViewModel
}
public string SelectedProfileName => _selectedProfile?.Name ?? "";
public string ProfileCountText => Profiles.Count == 1
? LocalizationService.Instance.T("۱ پروفایل ذخیره‌شده")
: LocalizationService.Instance.Format("{0} پروفایل ذخیره‌شده", Profiles.Count);
public string ActiveProfileTypeText => CurrentTunnelType switch
{
TunnelType.L2tpIpsec => "L2TP/IPsec",
TunnelType.V2Ray => TunnelProviderFactory.RequiresXray(SelectedV2RayConfig) ? "V2Ray / Xray" : "V2Ray / sing-box",
TunnelType.OpenVpn => "OpenVPN",
TunnelType.SocksProxy => ProxyProtocol == ProxyProtocol.Http ? "HTTP Proxy" : "SOCKS5 Proxy",
_ => LocalizationService.Instance.T("نوع اتصال نامشخص")
};
public string ActiveProfileEndpointText => CurrentTunnelType switch
{
TunnelType.L2tpIpsec => string.IsNullOrWhiteSpace(ServerAddress) ? LocalizationService.Instance.T("آدرس سرور هنوز وارد نشده") : ServerAddress.Trim(),
TunnelType.V2Ray => TryExtractProxyEndpoint(SelectedV2RayConfig.Trim(), out var server, out var port, out _)
? $"{server}:{port}"
: LocalizationService.Instance.T("کانفیگ V2Ray/Xray آماده نمایش نیست"),
TunnelType.OpenVpn => string.IsNullOrWhiteSpace(SelectedOpenVpnConfigPath)
? LocalizationService.Instance.T("فایل OpenVPN انتخاب نشده")
: Path.GetFileName(SelectedOpenVpnConfigPath),
TunnelType.SocksProxy => string.IsNullOrWhiteSpace(ProxyServerAddress)
? LocalizationService.Instance.T("آدرس پراکسی هنوز وارد نشده")
: $"{ProxyServerAddress.Trim()}:{ProxyPort}",
_ => ""
};
public string ProfileSaveHintText => string.IsNullOrWhiteSpace(SaveStatusText)
? LocalizationService.Instance.T("تغییرات این پروفایل به‌صورت خودکار ذخیره می‌شود")
: SaveStatusText;
/// <summary>
/// Event to notify code-behind to update PasswordBox controls.
/// </summary>
public event Action<string, string>? PasswordChanged;
public event Action<string>? OpenVpnPasswordChanged;
public event Action<string>? ProxyPasswordChanged;
private void LoadProfiles()
{
@@ -40,14 +79,17 @@ public partial class MainViewModel
Profiles.Clear();
if (profiles.Count == 0)
profiles.Add(new ConnectionProfile { Name = "پیش‌فرض" });
profiles.Add(new ConnectionProfile { Name = LocalizationService.Instance.T("پیش‌فرض") });
foreach (var p in profiles.OrderByDescending(p => p.LastUsedAt))
Profiles.Add(p);
OnPropertyChanged(nameof(ProfileCountText));
_selectedProfile = Profiles[0];
OnPropertyChanged(nameof(SelectedProfile));
OnPropertyChanged(nameof(SelectedProfileName));
OnPropertyChanged(nameof(ConnectedProfileName));
OnPropertyChanged(nameof(SelectedProfileSummaryText));
LoadProfileIntoUi(Profiles[0]);
}
@@ -111,7 +153,16 @@ public partial class MainViewModel
_selectedProfile.PreSharedKey = PreSharedKey;
_selectedProfile.TunnelType = _currentTunnelType;
_selectedProfile.V2RayConfig = SelectedV2RayConfig;
_selectedProfile.Socks5Port = Socks5Port;
_selectedProfile.OpenVpnConfig = SelectedOpenVpnConfig;
_selectedProfile.OpenVpnConfigPath = SelectedOpenVpnConfigPath;
_selectedProfile.OpenVpnUsername = OpenVpnUsername;
_selectedProfile.OpenVpnPassword = OpenVpnPassword;
_selectedProfile.ProxyProtocol = ProxyProtocol;
_selectedProfile.ProxyServerAddress = ProxyServerAddress;
_selectedProfile.ProxyPort = ProxyPort;
_selectedProfile.ProxyUsername = ProxyUsername;
_selectedProfile.ProxyPassword = ProxyPassword;
_selectedProfile.MixedProxyPort = MixedProxyPort;
_selectedProfile.AutoTuneMtu = AutoTuneMtu;
_selectedProfile.EnableDnsOptimization = IsDnsOptimizationEnabled;
_selectedProfile.EnableGameMode = IsGameModeEnabled;
@@ -123,6 +174,7 @@ public partial class MainViewModel
/// </summary>
public void SaveCurrentState()
{
if (_isLoadingProfile) return;
SaveStatusText = "در حال ذخیره...";
_saveDebounceTimer.Stop();
_saveDebounceTimer.Start(); // Restart timer - will save after 1 second of no changes
@@ -140,17 +192,20 @@ public partial class MainViewModel
}
private void LoadProfileIntoUi(ConnectionProfile profile)
{
_isLoadingProfile = true;
try
{
ServerAddress = profile.ServerAddress;
Username = profile.Username;
Password = profile.Password;
PreSharedKey = profile.PreSharedKey;
_socks5Port = profile.Socks5Port > 0 ? profile.Socks5Port : 1080;
_trafficRouter.Socks5Port = _socks5Port;
OnPropertyChanged(nameof(Socks5Port));
OnPropertyChanged(nameof(Socks5PortText));
OnPropertyChanged(nameof(Socks5Info));
UpdateSocks5PortStatus();
_mixedProxyPort = profile.MixedProxyPort > 0 ? profile.MixedProxyPort : 1080;
_trafficRouter.Socks5Port = _mixedProxyPort;
OnPropertyChanged(nameof(MixedProxyPort));
OnPropertyChanged(nameof(MixedProxyPortText));
OnPropertyChanged(nameof(MixedProxyInfo));
UpdateMixedProxyPortStatus();
_autoTuneMtu = profile.AutoTuneMtu;
_isDnsOptimizationEnabled = profile.EnableDnsOptimization;
_isGameModeEnabled = profile.EnableGameMode;
@@ -164,54 +219,175 @@ public partial class MainViewModel
// while the new profile is being loaded.
_currentTunnelType = profile.TunnelType;
_selectedV2RayConfig = profile.V2RayConfig;
_selectedOpenVpnConfig = profile.OpenVpnConfig;
_selectedOpenVpnConfigPath = profile.OpenVpnConfigPath;
_openVpnUsername = profile.OpenVpnUsername;
_openVpnPassword = profile.OpenVpnPassword;
_proxyProtocol = profile.ProxyProtocol;
_proxyServerAddress = profile.ProxyServerAddress;
_proxyPort = profile.ProxyPort > 0 ? profile.ProxyPort : 1080;
_proxyUsername = profile.ProxyUsername;
_proxyPassword = profile.ProxyPassword;
OnPropertyChanged(nameof(CurrentTunnelType));
OnPropertyChanged(nameof(ConnectedBadgeText));
OnPropertyChanged(nameof(SelectedV2RayConfig));
OnPropertyChanged(nameof(SelectedOpenVpnConfig));
OnPropertyChanged(nameof(SelectedOpenVpnConfigPath));
OnPropertyChanged(nameof(OpenVpnUsername));
OnPropertyChanged(nameof(ProxyProtocol));
OnPropertyChanged(nameof(ProxyServerAddress));
OnPropertyChanged(nameof(ProxyPort));
OnPropertyChanged(nameof(ProxyPortText));
OnPropertyChanged(nameof(ProxyUsername));
UpdateConfigDiagnostics();
RaiseProfileCardChanged();
PasswordChanged?.Invoke(profile.Password, profile.PreSharedKey);
OpenVpnPasswordChanged?.Invoke(profile.OpenVpnPassword);
ProxyPasswordChanged?.Invoke(profile.ProxyPassword);
}
finally
{
_isLoadingProfile = false;
}
}
private void CreateNewProfile()
{
SaveCurrentProfileState();
var profile = new ConnectionProfile { Name = $"پروفایل {Profiles.Count + 1}", Socks5Port = Socks5Port };
profile.AutoTuneMtu = AutoTuneMtu;
profile.EnableDnsOptimization = IsDnsOptimizationEnabled;
profile.EnableGameMode = IsGameModeEnabled;
var profile = new ConnectionProfile
{
Name = "",
MixedProxyPort = MixedProxyPort,
AutoTuneMtu = AutoTuneMtu,
EnableDnsOptimization = IsDnsOptimizationEnabled,
EnableGameMode = IsGameModeEnabled
};
if (ProfileEditorDialog.Show(profile, "افزودن کانفیگ جدید", System.Windows.Application.Current.MainWindow) != true)
return;
Profiles.Add(profile);
OnPropertyChanged(nameof(ProfileCountText));
SelectedProfile = profile;
SaveProfiles();
}
private void DuplicateCurrentProfile(object? parameter = null)
{
var source = parameter as ConnectionProfile ?? _selectedProfile;
if (source == null) return;
SaveCurrentProfileState();
var clone = CloneProfile(source);
clone.Name = LocalizationService.Instance.Format("{0} (کپی)", source.Name);
if (ProfileEditorDialog.Show(clone, "کپی پروفایل", System.Windows.Application.Current.MainWindow) != true)
return;
Profiles.Add(clone);
OnPropertyChanged(nameof(ProfileCountText));
SelectedProfile = clone;
SaveProfiles();
}
private void EditProfile(object? parameter)
{
var profile = parameter as ConnectionProfile ?? _selectedProfile;
if (profile == null) return;
SaveCurrentProfileState();
var editable = CloneProfile(profile);
editable.Id = profile.Id;
editable.CreatedAt = profile.CreatedAt;
editable.LastUsedAt = profile.LastUsedAt;
if (ProfileEditorDialog.Show(editable, "ویرایش پروفایل", System.Windows.Application.Current.MainWindow) != true)
return;
ApplyProfileValues(profile, editable);
if (_selectedProfile == profile)
LoadProfileIntoUi(profile);
SaveProfiles();
RaiseProfileCardChanged();
}
private void SelectProfile(object? parameter)
{
if (parameter is ConnectionProfile profile)
SelectedProfile = profile;
}
private void DuplicateCurrentProfile()
private void DeleteCurrentProfile(object? parameter = null)
{
if (_selectedProfile == null) return;
SaveCurrentProfileState();
var toRemove = parameter as ConnectionProfile ?? _selectedProfile;
if (toRemove == null || Profiles.Count <= 1) return;
if (!Helpers.DialogService.Confirm(LocalizationService.Instance.Format("پروفایل «{0}» حذف شود؟", toRemove.Name), "حذف پروفایل"))
return;
var clone = new ConnectionProfile
{
Name = $"{_selectedProfile.Name} (کپی)",
ServerAddress = _selectedProfile.ServerAddress,
Username = _selectedProfile.Username,
Password = _selectedProfile.Password,
PreSharedKey = _selectedProfile.PreSharedKey,
TunnelType = _selectedProfile.TunnelType,
V2RayConfig = _selectedProfile.V2RayConfig,
Socks5Port = _selectedProfile.Socks5Port,
AutoTuneMtu = _selectedProfile.AutoTuneMtu,
EnableDnsOptimization = _selectedProfile.EnableDnsOptimization,
EnableGameMode = _selectedProfile.EnableGameMode,
};
Profiles.Add(clone);
SelectedProfile = clone;
}
private void DeleteCurrentProfile()
{
if (_selectedProfile == null || Profiles.Count <= 1) return;
var toRemove = _selectedProfile;
var idx = Profiles.IndexOf(toRemove);
Profiles.Remove(toRemove);
OnPropertyChanged(nameof(ProfileCountText));
SelectedProfile = Profiles[Math.Min(idx, Profiles.Count - 1)];
SaveProfiles();
}
private static ConnectionProfile CloneProfile(ConnectionProfile source) => new()
{
Name = source.Name,
ServerAddress = source.ServerAddress,
Username = source.Username,
Password = source.Password,
PreSharedKey = source.PreSharedKey,
TunnelType = source.TunnelType,
V2RayConfig = source.V2RayConfig,
OpenVpnConfig = source.OpenVpnConfig,
OpenVpnConfigPath = source.OpenVpnConfigPath,
OpenVpnUsername = source.OpenVpnUsername,
OpenVpnPassword = source.OpenVpnPassword,
ProxyProtocol = source.ProxyProtocol,
ProxyServerAddress = source.ProxyServerAddress,
ProxyPort = source.ProxyPort,
ProxyUsername = source.ProxyUsername,
ProxyPassword = source.ProxyPassword,
MixedProxyPort = source.MixedProxyPort,
AutoTuneMtu = source.AutoTuneMtu,
EnableDnsOptimization = source.EnableDnsOptimization,
EnableGameMode = source.EnableGameMode
};
private static void ApplyProfileValues(ConnectionProfile target, ConnectionProfile source)
{
target.Name = source.Name;
target.ServerAddress = source.ServerAddress;
target.Username = source.Username;
target.Password = source.Password;
target.PreSharedKey = source.PreSharedKey;
target.TunnelType = source.TunnelType;
target.V2RayConfig = source.V2RayConfig;
target.OpenVpnConfig = source.OpenVpnConfig;
target.OpenVpnConfigPath = source.OpenVpnConfigPath;
target.OpenVpnUsername = source.OpenVpnUsername;
target.OpenVpnPassword = source.OpenVpnPassword;
target.ProxyProtocol = source.ProxyProtocol;
target.ProxyServerAddress = source.ProxyServerAddress;
target.ProxyPort = source.ProxyPort;
target.ProxyUsername = source.ProxyUsername;
target.ProxyPassword = source.ProxyPassword;
target.MixedProxyPort = source.MixedProxyPort;
target.AutoTuneMtu = source.AutoTuneMtu;
target.EnableDnsOptimization = source.EnableDnsOptimization;
target.EnableGameMode = source.EnableGameMode;
}
private void RaiseProfileCardChanged()
{
OnPropertyChanged(nameof(ProfileCountText));
OnPropertyChanged(nameof(ConnectedProfileName));
OnPropertyChanged(nameof(SelectedProfileSummaryText));
OnPropertyChanged(nameof(ActiveProfileTypeText));
OnPropertyChanged(nameof(ActiveProfileEndpointText));
OnPropertyChanged(nameof(ProfileSaveHintText));
}
#endregion
@@ -245,8 +421,10 @@ public partial class MainViewModel
var entry = new ConnectionHistoryEntry
{
ProfileName = _selectedProfile?.Name ?? "پیش‌فرض",
ServerAddress = ServerAddress,
ProfileName = _selectedProfile?.Name ?? LocalizationService.Instance.T("پیش‌فرض"),
ServerAddress = CurrentTunnelType == TunnelType.SocksProxy
? $"{ProxyServerAddress}:{ProxyPort}"
: ServerAddress,
ConnectedAt = _connectionStartTime,
DisconnectedAt = DateTime.Now,
BytesSent = totalSent,
+164 -55
View File
@@ -2,7 +2,64 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid Margin="0,8,0,0">
<UserControl.Resources>
<Style x:Key="CompactToggleSwitch" TargetType="CheckBox">
<Setter Property="Width" Value="32"/>
<Setter Property="Height" Value="18"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<Grid Width="32" Height="18">
<Border x:Name="track"
Width="32"
Height="18"
CornerRadius="9"
Background="#555"
Cursor="Hand"/>
<Border x:Name="thumb"
Width="14"
Height="14"
CornerRadius="7"
Background="White"
HorizontalAlignment="Left"
Margin="2,0,0,0"
VerticalAlignment="Center"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="track" Property="Background" Value="{StaticResource SuccessBrush}"/>
<Setter TargetName="thumb" Property="HorizontalAlignment" Value="Right"/>
<Setter TargetName="thumb" Property="Margin" Value="0,0,2,0"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="CompactIconButton" TargetType="Button" BasedOn="{StaticResource SecondaryButton}">
<Setter Property="MinWidth" Value="24"/>
<Setter Property="Width" Value="24"/>
<Setter Property="Height" Value="22"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style x:Key="AppRowCard" TargetType="Border">
<Setter Property="Background" Value="#0EFFFFFF"/>
<Setter Property="BorderBrush" Value="#10FFFFFF"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Margin" Value="0,0,0,6"/>
<Setter Property="MinHeight" Value="50"/>
<Setter Property="FlowDirection" Value="LeftToRight"/>
</Style>
</UserControl.Resources>
<Grid Margin="12,8,12,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
@@ -10,25 +67,29 @@
</Grid.ColumnDefinitions>
<!-- LEFT: Selected Apps (Tunnel List) -->
<Border Grid.Column="0" Style="{StaticResource CardPanel}" Margin="0">
<Border Grid.Column="0" Style="{StaticResource CardPanel}" Margin="0" BorderBrush="#2233C481">
<DockPanel>
<Grid DockPanel.Dock="Top" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="✅ برنامه‌های تونل" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="ترافیک این برنامه‌ها از VPN عبور می‌کند"
<TextBlock Text="✅ برنامه‌های داخل تونل" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="برنامه‌های فعال‌شده از مسیر تونل عبور می‌کنند"
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="10" Margin="0,2,0,0"/>
FontSize="10" Margin="0,2,0,0"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<Button Grid.Column="1" Style="{StaticResource SecondaryButton}"
Content="📁 افزودن"
<Button Grid.Column="2" Style="{StaticResource SecondaryButton}"
Content="افزودن دستی"
ToolTip="افزودن دستی فایل exe"
Command="{Binding AddAppCommand}"
FontSize="11" Padding="10,6"/>
FontSize="10" Padding="10,6"
VerticalAlignment="Center"/>
</Grid>
<!-- Tunnel Search -->
@@ -37,8 +98,8 @@
Text="{Binding TunnelSearchText, UpdateSourceTrigger=PropertyChanged}"
Tag="فیلتر برنامه‌های تونل..."
DockPanel.Dock="Top"
Margin="0,0,0,8"
FontSize="12" Padding="10,7"
Margin="0,0,0,7"
FontSize="12" Padding="9,6"
PreviewMouseDoubleClick="OnSearchBoxPreviewMouseDoubleClick"/>
<!-- Tunnel Apps List -->
@@ -46,56 +107,86 @@
SelectedItem="{Binding SelectedApp}"
Background="Transparent"
BorderThickness="0"
FlowDirection="LeftToRight"
FlowDirection="{Binding AppFlowDirection}"
Cursor="Arrow"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListView.ItemTemplate>
<DataTemplate>
<Border Background="#11FFFFFF" CornerRadius="8"
Padding="10,8" Margin="0,2" Cursor="Arrow">
<Border Style="{StaticResource AppRowCard}" Cursor="Arrow">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- App Icon -->
<Image Grid.Column="0" Source="{Binding Icon}"
Width="28" Height="28" Margin="0,0,10,0"/>
Width="28" Height="28"
VerticalAlignment="Center"/>
<!-- App Name & Exe -->
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<StackPanel Grid.Column="2" VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayName}"
Foreground="{StaticResource TextPrimaryBrush}"
FontSize="13" TextTrimming="CharacterEllipsis"/>
FontSize="10.8"
FontWeight="Normal"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType"
TextOptions.TextHintingMode="Fixed"
TextWrapping="Wrap"
FlowDirection="LeftToRight"
TextAlignment="Left"/>
<TextBlock Text="{Binding ExecutableName}"
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="10" FlowDirection="LeftToRight"/>
FontSize="8.5"
Margin="0,2,0,0"
FlowDirection="LeftToRight"
TextAlignment="Left"/>
</StackPanel>
<!-- Traffic -->
<TextBlock Grid.Column="2" Text="{Binding TrafficDisplay}"
Foreground="{StaticResource AccentBrush}"
FontSize="11" VerticalAlignment="Center"
Margin="8,0" FlowDirection="LeftToRight"/>
<!-- Toggle -->
<CheckBox Grid.Column="3" Style="{StaticResource ToggleSwitch}"
<!-- Compact actions -->
<Grid Grid.Column="4"
Width="76"
VerticalAlignment="Center"
FlowDirection="LeftToRight">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0"
Orientation="Horizontal"
HorizontalAlignment="Right">
<CheckBox Style="{StaticResource CompactToggleSwitch}"
IsChecked="{Binding IsEnabled, Mode=OneWay}"
Command="{Binding DataContext.ToggleAppCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
VerticalAlignment="Center" Margin="4,0"/>
VerticalAlignment="Center"
Margin="0,0,4,0"/>
<!-- Remove -->
<Button Grid.Column="4" Style="{StaticResource DangerButton}"
Content="✕" FontSize="14"
<Button Style="{StaticResource CompactIconButton}"
Content="×"
ToolTip="حذف"
Foreground="#FF8A80"
Command="{Binding DataContext.RemoveAppCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"/>
</StackPanel>
<Border Grid.Row="1"
Background="#15121212"
CornerRadius="6"
Padding="4,1"
Margin="0,4,0,0"
HorizontalAlignment="Right">
<TextBlock Text="{Binding TrafficDisplay}"
Foreground="{StaticResource AccentBrush}"
FontSize="8.5"
FlowDirection="LeftToRight"/>
</Border>
</Grid>
</Grid>
</Border>
</DataTemplate>
@@ -119,78 +210,96 @@
</Border>
<!-- RIGHT: Available Apps (Discovery) -->
<Border Grid.Column="2" Style="{StaticResource CardPanel}" Margin="0">
<Border Grid.Column="2" Style="{StaticResource CardPanel}" Margin="0" BorderBrush="#22E8803A">
<DockPanel>
<Grid DockPanel.Dock="Top" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="🔍 برنامه‌های نصب شده" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="کلیک کنید تا به تونل اضافه شود"
<TextBlock Text="🔍 برنامه‌های نصبشده" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="روی برنامه کلیک کنید تا به لیست تونل اضافه شود"
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="10" Margin="0,2,0,0"/>
FontSize="10" Margin="0,2,0,0"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<Button Grid.Column="1" Style="{StaticResource SecondaryButton}"
Content="🔄 بارگذاری"
<Button Grid.Column="2" Style="{StaticResource SecondaryButton}"
Content="بروزرسانی"
ToolTip="بارگذاری مجدد لیست برنامه‌ها"
Command="{Binding RefreshAppsCommand}"
FontSize="11" Padding="10,6"/>
FontSize="10" Padding="10,6"
VerticalAlignment="Center"/>
</Grid>
<TextBox x:Name="AvailableSearchBox"
Style="{StaticResource ModernTextBox}"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
Tag="جستجوی برنامه..."
DockPanel.Dock="Top"
Margin="0,0,0,8"
Margin="0,0,0,7"
FontSize="12"
Padding="9,6"
PreviewMouseDoubleClick="OnSearchBoxPreviewMouseDoubleClick"/>
<!-- Available Apps List -->
<ListView ItemsSource="{Binding FilteredAvailableApps}"
Background="Transparent"
BorderThickness="0"
FlowDirection="LeftToRight"
FlowDirection="{Binding AppFlowDirection}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListView.ItemTemplate>
<DataTemplate>
<Border x:Name="itemBorder"
Background="#08FFFFFF" CornerRadius="6"
Padding="8,6" Margin="0,1" Cursor="Hand"
Style="{StaticResource AppRowCard}"
Cursor="Hand"
MouseLeftButtonUp="OnAvailableAppClick">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Image Grid.Column="0" Source="{Binding Icon}"
Width="24" Height="24" Margin="0,0,8,0"/>
Width="26" Height="26"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<StackPanel Grid.Column="2" VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayName}"
Foreground="{StaticResource TextPrimaryBrush}"
FontSize="12" TextTrimming="CharacterEllipsis"/>
FontSize="10.8"
FontWeight="Normal"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType"
TextOptions.TextHintingMode="Fixed"
TextWrapping="Wrap"
FlowDirection="LeftToRight"
TextAlignment="Left"/>
<TextBlock Text="{Binding ExecutableName}"
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="9" FlowDirection="LeftToRight"/>
FontSize="8.5"
Margin="0,2,0,0"
FlowDirection="LeftToRight"
TextAlignment="Left"/>
</StackPanel>
<Border Grid.Column="2" x:Name="addBadge"
Background="#25E07820" CornerRadius="4"
Padding="6,2" Margin="4,0,0,0">
<TextBlock Text="" FontSize="11"
<Button Grid.Column="4"
x:Name="addBadge"
Style="{StaticResource CompactIconButton}"
Content="+"
ToolTip="افزودن"
Foreground="{StaticResource PrimaryBrush}"
VerticalAlignment="Center"/>
</Border>
Click="OnAvailableAppAddClick"/>
</Grid>
</Border>
<DataTemplate.Triggers>
<Trigger SourceName="itemBorder" Property="IsMouseOver" Value="True">
<Setter TargetName="itemBorder" Property="Background" Value="#18FFFFFF"/>
<Setter TargetName="addBadge" Property="Background" Value="#40E07820"/>
</Trigger>
</DataTemplate.Triggers>
</DataTemplate>
+10
View File
@@ -28,4 +28,14 @@ public partial class AppsTabView : System.Windows.Controls.UserControl
e.Handled = true;
}
}
private void OnAvailableAppAddClick(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement element && element.DataContext is AppItemViewModel app
&& DataContext is MainViewModel vm)
{
vm.AddAppToTunnel(app);
e.Handled = true;
}
}
}
+663 -156
View File
@@ -11,76 +11,306 @@
<conv:EnumToVisibilityConverter x:Key="EnumToVis"/>
</UserControl.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<ScrollViewer FlowDirection="{Binding AppFlowDirection}"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel HorizontalAlignment="Stretch" Margin="16,8,16,4">
<!-- ══ DISCONNECTED STATE: Show connection form ══ -->
<StackPanel Visibility="{Binding IsConnected, Converter={StaticResource InverseBoolToVis}}">
<!-- ── Profile Card ── -->
<Border Style="{StaticResource CardPanel}">
<!-- OpenVPN connecting view -->
<Border Background="#33221812"
BorderBrush="{StaticResource WarningBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="18,16"
Margin="0,0,0,10"
Visibility="{Binding IsOpenVpnConnectionPending, Converter={StaticResource BoolToVis}}">
<StackPanel>
<!-- Header -->
<Grid Margin="0,0,0,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="📋 پروفایل اتصال" FontSize="13" FontWeight="SemiBold"
<Border Grid.Column="0"
Width="42"
Height="42"
CornerRadius="21"
Background="#22FFC107"
VerticalAlignment="Top">
<TextBlock Text="⏳"
FontSize="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="2">
<TextBlock Text="در حال اتصال OpenVPN"
FontSize="15"
FontWeight="SemiBold"
Foreground="{StaticResource WarningBrush}"/>
<TextBlock Text="{Binding StatusText}"
TextWrapping="Wrap"
FontSize="11"
LineHeight="18"
Margin="0,5,0,0"
Foreground="{StaticResource TextPrimaryBrush}"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<TextBlock Text="{Binding SaveStatusText}"
<TextBlock Text="تا قبل از بالا آمدن آداپتر، مسیرهای سیستم تغییر داده نمی‌شود. اگر اتصال طولانی شد، فایل .ovpn، نام کاربری/رمز یا نصب OpenVPN Community را بررسی کنید."
TextWrapping="Wrap"
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<Button Style="{StaticResource SecondaryButton}"
Content="📋" FontSize="12" Padding="7,4" Width="30"
ToolTip="کپی از پروفایل فعلی"
Command="{Binding DuplicateProfileCommand}"
Margin="0,0,4,0"/>
<Button Style="{StaticResource DangerButton}"
Content="🗑" FontSize="12" Padding="7,4" Width="30"
ToolTip="حذف پروفایل"
Command="{Binding DeleteProfileCommand}"/>
LineHeight="17"
Margin="0,6,0,0"
Foreground="{StaticResource TextSecondaryBrush}"/>
</StackPanel>
<Button Grid.Column="4"
Style="{StaticResource DangerButton}"
Content="لغو اتصال"
Command="{Binding ConnectCommand}"
VerticalAlignment="Top"
Padding="14,7"/>
</Grid>
</StackPanel>
</Border>
<!-- Profile selector + New button -->
<Grid Margin="0,0,0,4">
<!-- ── Profile Card ── -->
<Border Style="{StaticResource CardPanel}"
Visibility="{Binding IsOpenVpnConnectionPending, Converter={StaticResource InverseBoolToVis}}">
<StackPanel>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ComboBox Grid.Column="0"
Style="{StaticResource DarkComboBox}"
ItemsSource="{Binding Profiles}"
SelectedItem="{Binding SelectedProfile}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" Padding="2,0"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<StackPanel Grid.Column="0">
<TextBlock Text="کانفیگ‌ها و پروفایل‌ها"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="یک کانفیگ را انتخاب کنید، ویرایش کنید یا کانفیگ جدید بسازید."
FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,4,0,0"/>
</StackPanel>
<Button Grid.Column="2"
Style="{StaticResource PrimaryButton}"
Content=" جدید" FontSize="11" Padding="12,7"
ToolTip="ساخت پروفایل جدید"
Content="افزودن کانفیگ جدید"
FontSize="12"
Padding="16,9"
Command="{Binding NewProfileCommand}"/>
</Grid>
<!-- Profile Name -->
<TextBlock Style="{StaticResource FieldLabel}" Text="نام پروفایل"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding SelectedProfile.Name, UpdateSourceTrigger=PropertyChanged}"
HorizontalContentAlignment="Right"
LostFocus="OnProfileNameChanged"/>
<ListBox ItemsSource="{Binding Profiles}"
SelectedItem="{Binding SelectedProfile, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
Padding="0"
PreviewMouseWheel="OnProfileListPreviewMouseWheel"
ScrollViewer.VerticalScrollBarVisibility="Disabled"
SelectionMode="Single">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0,0,0,6"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Background" Value="#121212"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="ItemBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="10"
Padding="10,8">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ItemBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter TargetName="ItemBorder" Property="Background" Value="#181818"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ItemBorder" Property="BorderBrush" Value="{StaticResource PrimaryBrush}"/>
<Setter TargetName="ItemBorder" Property="Background" Value="#24180F"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" FlowDirection="{Binding DataContext.AppFlowDirection, RelativeSource={RelativeSource AncestorType=UserControl}}">
<TextBlock Text="{Binding Name}"
FontSize="13"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
TextTrimming="CharacterEllipsis"/>
<Border Background="#18E8803A"
CornerRadius="8"
Padding="7,2"
Margin="8,0,0,0">
<TextBlock Text="{Binding TunnelTypeDisplay}"
FontSize="9"
Foreground="{StaticResource AccentBrush}"/>
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal"
FlowDirection="LeftToRight"
Margin="0,5,0,0">
<TextBlock Text="{Binding EndpointDisplay}"
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text=" • "
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBlock Text="{Binding ReadinessText}"
Foreground="{Binding ReadinessColor, Converter={StaticResource StringToColor}}"
FontSize="10"
FontWeight="SemiBold"/>
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
FlowDirection="{Binding DataContext.AppFlowDirection, RelativeSource={RelativeSource AncestorType=UserControl}}"
VerticalAlignment="Center">
<Button Style="{StaticResource PrimaryButton}"
Content="فعال"
FontSize="10"
Padding="10,5"
Command="{Binding DataContext.SelectProfileCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
ToolTip="استفاده از این کانفیگ برای اتصال"/>
<Button Style="{StaticResource SecondaryButton}"
Content="ویرایش"
FontSize="10"
Padding="9,5"
Margin="5,0,0,0"
Command="{Binding DataContext.EditProfileCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"/>
<Button Style="{StaticResource SecondaryButton}"
Content="کپی"
FontSize="10"
Padding="9,5"
Margin="5,0,0,0"
Command="{Binding DataContext.DuplicateProfileCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"/>
<Button Style="{StaticResource DangerButton}"
Content="حذف"
FontSize="10"
Padding="9,5"
Margin="5,0,0,0"
Command="{Binding DataContext.DeleteProfileCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"/>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Background="#151515"
BorderBrush="#22FFFFFF"
BorderThickness="1"
CornerRadius="12"
Padding="12,10"
Margin="0,10,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" FlowDirection="{Binding AppFlowDirection}">
<Ellipse Width="9" Height="9"
Fill="{Binding StatusColor, Converter={StaticResource StringToColor}}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding StatusText}"
FontSize="12"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
Margin="7,0,0,0"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock FontSize="10"
Text="{Binding SelectedProfileSummaryText}"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,4,0,0"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<Button Grid.Column="2"
Command="{Binding ConnectCommand}"
Content="{Binding ConnectButtonText}"
MinWidth="160"
Height="44"
Padding="18,8"
ToolTip="{Binding ConnectButtonToolTip}">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#10B981"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="12"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#059669"/>
</Trigger>
<DataTrigger Binding="{Binding ConnectionState}" Value="{x:Static models:ConnectionState.Connecting}">
<Setter Property="Background" Value="#F97316"/>
</DataTrigger>
<DataTrigger Binding="{Binding ConnectionState}" Value="{x:Static models:ConnectionState.Error}">
<Setter Property="Background" Value="#DC2626"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</Grid>
</Border>
</StackPanel>
</Border>
<!-- ── Server Settings Card ── -->
<Border Style="{StaticResource CardPanel}">
<Border Style="{StaticResource CardPanel}"
Visibility="Collapsed">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="🌐 تنظیمات سرور"
Margin="0,0,0,4"/>
@@ -95,6 +325,10 @@
Tag="{x:Static models:TunnelType.L2tpIpsec}"/>
<ComboBoxItem Content="V2Ray / Xray"
Tag="{x:Static models:TunnelType.V2Ray}"/>
<ComboBoxItem Content="OpenVPN"
Tag="{x:Static models:TunnelType.OpenVpn}"/>
<ComboBoxItem Content="SOCKS / Proxy"
Tag="{x:Static models:TunnelType.SocksProxy}"/>
</ComboBox>
<!-- L2TP fields (shown only when TunnelType = L2tpIpsec) -->
@@ -202,6 +436,178 @@
</StackPanel>
<!-- End V2Ray fields -->
<!-- OpenVPN fields (shown only when TunnelType = OpenVpn) -->
<StackPanel Visibility="{Binding CurrentTunnelType,
Converter={StaticResource EnumToVis},
ConverterParameter=OpenVpn}">
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,0,0,8">
<StackPanel>
<TextBlock Text="TunnelX فایل .ovpn را با OpenVPN Community اجرا می‌کند و مسیر/DNS پیش‌فرض OpenVPN را کنترل می‌کند تا فقط برنامه‌های انتخابی از تونل عبور کنند."
TextWrapping="Wrap"
FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBlock Text="OpenVPN Connect به‌تنهایی کافی نیست؛ اگر Community نصب نباشد، از دکمه دانلود پایین استفاده کنید."
TextWrapping="Wrap"
FontSize="10"
Margin="0,5,0,0"
Foreground="{StaticResource WarningBrush}"/>
</StackPanel>
</Border>
<TextBlock Style="{StaticResource FieldLabel}" Text="فایل OpenVPN (.ovpn)"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Style="{StaticResource ModernTextBox}"
Text="{Binding SelectedOpenVpnConfigPath}"
IsReadOnly="True"
FlowDirection="LeftToRight"/>
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="انتخاب فایل"
Command="{Binding BrowseOpenVpnConfigCommand}"
FontSize="10"
Padding="12,6"/>
<Button Grid.Column="4"
Style="{StaticResource SecondaryButton}"
Content="حذف فایل"
Command="{Binding ClearConfigCommand}"
FontSize="10"
Padding="12,6"/>
</Grid>
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,6,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding OpenVpnPrerequisiteText}"
Foreground="{Binding OpenVpnPrerequisiteColor, Converter={StaticResource StringToColor}}"
FontSize="10"
TextWrapping="Wrap"
FlowDirection="{Binding AppFlowDirection}"/>
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="دانلود OpenVPN"
Command="{Binding OpenOpenVpnCommunityDownloadCommand}"
FontSize="10"
Padding="10,5"/>
</Grid>
</Border>
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="1">
<TextBlock Style="{StaticResource FieldLabel}" Text="نام کاربری"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding OpenVpnUsername, UpdateSourceTrigger=PropertyChanged}"
FlowDirection="LeftToRight"/>
</StackPanel>
<StackPanel Grid.Column="3">
<TextBlock Style="{StaticResource FieldLabel}" Text="رمز عبور"/>
<PasswordBox x:Name="OpenVpnPasswordField"
Style="{StaticResource ModernPasswordBox}"
FlowDirection="LeftToRight"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding ConfigValidationText}"
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
Margin="0,6,0,0"/>
</StackPanel>
<!-- End OpenVPN fields -->
<!-- SOCKS/Proxy fields (shown only when TunnelType = SocksProxy) -->
<StackPanel Visibility="{Binding CurrentTunnelType,
Converter={StaticResource EnumToVis},
ConverterParameter=SocksProxy}">
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,0,0,8">
<StackPanel>
<TextBlock Text="برای سرورهای SOCKS5 یا HTTP Proxy، اطلاعات را جداگانه وارد کنید. TunnelX از همین پراکسی یک TUN داخلی می‌سازد تا اسپلیت‌تانلینگ برنامه‌های انتخابی مثل سایر نوع‌های اتصال کار کند."
TextWrapping="Wrap"
FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBlock Text="اگر پراکسی شما نام کاربری یا رمز ندارد، فیلدهای احراز هویت را خالی بگذارید."
TextWrapping="Wrap"
FontSize="10"
Margin="0,5,0,0"
Foreground="{StaticResource AccentBrush}"/>
</StackPanel>
</Border>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Style="{StaticResource FieldLabel}" Text="نوع پراکسی"/>
<ComboBox Style="{StaticResource DarkComboBox}"
SelectedValue="{Binding ProxyProtocol, Mode=TwoWay}"
SelectedValuePath="Tag">
<ComboBoxItem Content="SOCKS5"
Tag="{x:Static models:ProxyProtocol.Socks5}"/>
<ComboBoxItem Content="HTTP Proxy"
Tag="{x:Static models:ProxyProtocol.Http}"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Style="{StaticResource FieldLabel}" Text="پورت"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding ProxyPortText, UpdateSourceTrigger=PropertyChanged}"
FlowDirection="LeftToRight"/>
</StackPanel>
</Grid>
<TextBlock Style="{StaticResource FieldLabel}" Text="آدرس IP یا دامنه سرور" Margin="0,6,0,0"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding ProxyServerAddress, UpdateSourceTrigger=PropertyChanged}"
FlowDirection="LeftToRight"/>
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Style="{StaticResource FieldLabel}" Text="نام کاربری"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding ProxyUsername, UpdateSourceTrigger=PropertyChanged}"
FlowDirection="LeftToRight"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Style="{StaticResource FieldLabel}" Text="رمز عبور"/>
<PasswordBox x:Name="ProxyPasswordField"
Style="{StaticResource ModernPasswordBox}"
FlowDirection="LeftToRight"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding ConfigValidationText}"
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
Margin="0,6,0,0"/>
</StackPanel>
<!-- End SOCKS/Proxy fields -->
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,8,0,0">
<Grid>
<Grid.ColumnDefinitions>
@@ -236,45 +642,52 @@
</StackPanel>
<!-- End Disconnected State -->
<!-- ══ CONNECTED STATE: Show disconnect button + info ══ -->
<!-- ══ CONNECTED STATE: compact status dashboard ══ -->
<StackPanel Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
<!-- Connected Badge -->
<Border Background="#1A6CCB5F" CornerRadius="12" Padding="16,12"
HorizontalAlignment="Center" Margin="0,8,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock Text="✓" FontSize="28" FontWeight="Bold"
<!-- Connection summary -->
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0.95*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="0.9*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="1.45*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="0.95*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="0.95*"/>
</Grid.ColumnDefinitions>
<!-- Connected profile -->
<Border Grid.Column="0" Background="#1A6CCB5F" CornerRadius="10" Padding="10,8"
ToolTip="وضعیت و پروفایل فعال">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="✓" FontSize="15" FontWeight="Bold"
Foreground="{StaticResource SuccessBrush}"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="متصل به VPN" FontSize="15" FontWeight="SemiBold"
Foreground="{StaticResource SuccessBrush}"/>
<TextBlock Text="{Binding SelectedProfile.Name}" FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"/>
</StackPanel>
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding ConnectedBadgeText}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource SuccessBrush}"
HorizontalAlignment="Center"
Margin="0,2,0,0"/>
<TextBlock Text="{Binding ConnectedProfileName}" FontSize="10"
FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"
HorizontalAlignment="Center"
TextAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxWidth="120"/>
</StackPanel>
</Border>
<!-- Connection Info - 4 cards in one row -->
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Duration -->
<Border Grid.Column="0" Background="#11FFFFFF" CornerRadius="10" Padding="10,10"
<Border Grid.Column="2" Background="#11FFFFFF" CornerRadius="10" Padding="10,8"
ToolTip="مدت زمان اتصال فعلی از لحظه برقراری اتصال">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="⏱" FontSize="14" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding ConnectionDuration}" FontSize="16"
<TextBlock Text="{Binding ConnectionDuration}" FontSize="14"
FontWeight="Bold" Foreground="{StaticResource AccentBrush}"
HorizontalAlignment="Center" Margin="0,4,0,0"
HorizontalAlignment="Center" Margin="0,2,0,0"
FlowDirection="LeftToRight"/>
<TextBlock Text="مدت" FontSize="9"
Foreground="{StaticResource TextSecondaryBrush}"
@@ -282,29 +695,29 @@
</StackPanel>
</Border>
<!-- VPN IP -->
<Border Grid.Column="2" Background="#11FFFFFF" CornerRadius="10" Padding="10,10"
ToolTip="آدرس IP اختصاص‌یافته توسط سرور VPN به این دستگاه">
<!-- Connection / Exit IP -->
<Border Grid.Column="4" Background="#11FFFFFF" CornerRadius="10" Padding="10,8"
ToolTip="IP عمومی‌ای که مقصدهای اینترنتی شما را با آن می‌بینند">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="🌐" FontSize="14" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding VpnIp}" FontSize="13"
<TextBlock Text="{Binding ConnectionIpText}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource SuccessBrush}"
HorizontalAlignment="Center" Margin="0,4,0,0"
HorizontalAlignment="Center" Margin="0,2,0,0"
FlowDirection="LeftToRight"/>
<TextBlock Text="IP" FontSize="9"
<TextBlock Text="{Binding ConnectionIpLabel}" FontSize="9"
Foreground="{StaticResource TextSecondaryBrush}"
HorizontalAlignment="Center"/>
</StackPanel>
</Border>
<!-- Tunnel Traffic -->
<Border Grid.Column="4" Background="#11FFFFFF" CornerRadius="10" Padding="10,10"
<Border Grid.Column="6" Background="#11FFFFFF" CornerRadius="10" Padding="10,8"
ToolTip="مجموع ترافیک ارسال و دریافت عبوری از تونل VPN (کل تونل)">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="📊" FontSize="14" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding TotalTraffic}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource WarningBrush}"
HorizontalAlignment="Center" Margin="0,4,0,0"
HorizontalAlignment="Center" Margin="0,2,0,0"
FlowDirection="LeftToRight"/>
<TextBlock Text="تونل" FontSize="9"
Foreground="{StaticResource TextSecondaryBrush}"
@@ -313,13 +726,13 @@
</Border>
<!-- Direct diagnostic -->
<Border Grid.Column="6" Background="#11FFFFFF" CornerRadius="10" Padding="10,10"
<Border Grid.Column="8" Background="#11FFFFFF" CornerRadius="10" Padding="10,8"
ToolTip="نمایش تشخیصی ترافیک خارج از تونل. این عدد در مصرف تونل و تاریخچه ثبت نمی‌شود.">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="📡" FontSize="14" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding DirectTraffic}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}"
HorizontalAlignment="Center" Margin="0,4,0,0"
HorizontalAlignment="Center" Margin="0,2,0,0"
FlowDirection="LeftToRight"/>
<TextBlock Text="خارج تونل" FontSize="9"
Foreground="{StaticResource TextSecondaryBrush}"
@@ -328,66 +741,17 @@
</Border>
</Grid>
<!-- Apps + SOCKS5 in one row -->
<!-- Route, manual proxy, ping -->
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Apps Routed -->
<Border Grid.Column="0" Background="#11FFFFFF" CornerRadius="10" Padding="12,10"
ToolTip="تعداد برنامه‌هایی که ترافیکشان از طریق تونل VPN عبور می‌کند">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="📱" FontSize="16" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="برنامه‌ها در تونل" FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
VerticalAlignment="Center" Margin="8,0,0,0"/>
<Border Grid.Column="2" Background="{StaticResource PrimaryBrush}"
CornerRadius="8" Padding="10,3">
<TextBlock Text="{Binding EnabledAppsCount}" FontSize="13" FontWeight="Bold"
Foreground="White"/>
</Border>
</Grid>
</Border>
<!-- SOCKS5 Proxy -->
<Border Grid.Column="2" Background="#11FFFFFF" CornerRadius="10" Padding="12,10"
ToolTip="پروکسی SOCKS5 داخلی روی 127.0.0.1:1080 — برای برنامه‌هایی که از VPN تشخیص داده نمی‌شوند می‌توانید پروکسی را دستی تنظیم کنید">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="🧦" FontSize="16" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="SOCKS5" FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
VerticalAlignment="Center" Margin="8,0,0,0"/>
<Border Grid.Column="2" Background="#1AE8803A" CornerRadius="8" Padding="8,3">
<TextBlock Text="{Binding Socks5Info}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}"
FlowDirection="LeftToRight"/>
</Border>
</Grid>
</Border>
</Grid>
<!-- Route Mode + Ping -->
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="#11FFFFFF" CornerRadius="10" Padding="12,10">
<Border Grid.Column="0" Background="#11FFFFFF" CornerRadius="10" Padding="12,12" MinHeight="136">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -396,14 +760,11 @@
<StackPanel Grid.Column="0">
<TextBlock Text="🌐 عبور کل سیستم" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="{Binding RouteModeTitle}" FontSize="10"
FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}"
Margin="0,3,0,0"/>
<TextBlock Text="{Binding RouteModeDescription}" FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
Margin="0,3,0,0"/>
LineHeight="17"
Margin="0,4,10,0"/>
</StackPanel>
<CheckBox Grid.Column="1"
Style="{StaticResource ToggleSwitch}"
@@ -413,26 +774,61 @@
</Grid>
</Border>
<Border Grid.Column="2" Background="#11FFFFFF" CornerRadius="10" Padding="12,10">
<Border Grid.Column="2" Background="#11FFFFFF" CornerRadius="10" Padding="12,12" MinHeight="136"
ToolTip="اگر برنامه‌ای خودکار وارد تونل نشد، این آدرس را در تنظیمات Proxy همان برنامه وارد کنید.">
<StackPanel>
<TextBlock Text="🏓 تست مسیر از تونل" FontSize="12" FontWeight="SemiBold"
<TextBlock Text="🧦 پروکسی دستی" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="{Binding MixedProxyInfo}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}"
FlowDirection="LeftToRight"
Margin="0,5,0,0"/>
<TextBlock Text="این آدرس داخلی را در برنامه‌هایی وارد کنید که تنظیم Proxy جداگانه دارند یا خودکار وارد تونل نمی‌شوند."
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="17"
Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Border Grid.Column="4" Background="#11FFFFFF" CornerRadius="10" Padding="12,12" MinHeight="136">
<StackPanel>
<TextBlock Text="🏓 تست مسیر" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
Margin="0,0,0,3"/>
<TextBlock Text="یک دامنه یا IP را از داخل تونل تست کنید."
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
Margin="0,0,0,6"/>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="6"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding PingTarget, UpdateSourceTrigger=PropertyChanged}"
<TextBox Grid.Row="0" Grid.Column="0" Text="{Binding PingTarget, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBox}"
FlowDirection="LeftToRight" FontSize="11"
Padding="6,4" VerticalAlignment="Center"
Padding="8,6" VerticalAlignment="Center"
ToolTip="IP یا دامنه مقصد برای تست از داخل تونل"/>
<Button Grid.Column="2" Content="{Binding PingButtonText}"
<Button Grid.Row="0" Grid.Column="2" Content="{Binding PingButtonText}"
Command="{Binding TogglePingCommand}"
Style="{StaticResource SecondaryButton}"
FontSize="11" Padding="10,5"/>
FontSize="10" Padding="10,6"
ToolTip="تست همین مقصد از داخل مسیر تونل"/>
<Button Grid.Row="2" Grid.ColumnSpan="3" Content="{Binding ConnectedServerPingButtonText}"
Command="{Binding TestConnectedServerPingCommand}"
Style="{StaticResource SecondaryButton}"
FontSize="10" Padding="9,5"
HorizontalAlignment="Stretch"
ToolTip="دسترسی به سرور همین اتصال را تست می‌کند"/>
</Grid>
<TextBlock Text="{Binding PingResult}" FontSize="11"
FontWeight="SemiBold" FlowDirection="LeftToRight"
@@ -443,31 +839,142 @@
</Border>
</Grid>
<Button Command="{Binding ConnectCommand}"
MinWidth="220"
Height="50"
Padding="18,8"
HorizontalAlignment="Center"
Margin="0,10,0,0"
ToolTip="قطع اتصال فعلی">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#DC2626"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="DisconnectButtonBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="16"
Padding="{TemplateBinding Padding}">
<Border.Effect>
<DropShadowEffect Color="#B91C1C"
BlurRadius="18"
Opacity="0.28"
ShadowDepth="0"/>
</Border.Effect>
<Grid FlowDirection="{Binding AppFlowDirection}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0"
Width="34"
Height="34"
CornerRadius="17"
Background="#26FFFFFF"
VerticalAlignment="Center">
<TextBlock Text="⏻"
FontSize="18"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="2" VerticalAlignment="Center">
<TextBlock Text="قطع اتصال"
FontSize="16"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="DisconnectButtonBorder" Property="Background" Value="#B91C1C"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="DisconnectButtonBorder" Property="Background" Value="#991B1B"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</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 -->
<!-- Split Tunnel Tip (always visible) -->
<Border Background="#1AFFC107" BorderBrush="#33FFC107" BorderThickness="1"
CornerRadius="10" Padding="14,12" Margin="0,10,0,0">
CornerRadius="10" Padding="10,8" Margin="0,8,0,0">
<StackPanel>
<TextBlock TextWrapping="Wrap" FontSize="11" LineHeight="20"
<TextBlock TextWrapping="Wrap" FontSize="10" LineHeight="17"
FlowDirection="{Binding AppFlowDirection}"
Foreground="{StaticResource TextPrimaryBrush}">
<Run Text="💡 فقط ترافیک برنامه‌هایی از تونل VPN عبور می‌کند که از تب "/>
<Run Text="💡 در حالت انتخابی، فقط برنامه‌های فعال در تب "/>
<Run Text="«برنامه‌ها»" FontWeight="SemiBold"/>
<Run Text=" فعال‌سازی شده باشند. سایر برنامه‌ها مستقیماً به اینترنت متصل می‌شوند."/>
<Run Text=" از تونل عبور می‌کنند؛ بقیه مستقیم می‌مانند."/>
</TextBlock>
<Border Background="#22FFFFFF" Height="1" Margin="0,8,0,8"/>
<TextBlock TextWrapping="Wrap" FontSize="11" LineHeight="20"
<TextBlock TextWrapping="Wrap" FontSize="10" LineHeight="17"
FlowDirection="{Binding AppFlowDirection}"
Foreground="{StaticResource TextSecondaryBrush}">
<Run Text="📌 "/>
<Run Text="تلگرام، واتس‌اپ و سایر برنامه‌های مایکروسافت استور"
<Run Text="📌 برای تلگرام، واتس‌اپ و برنامه‌های Store، "/>
<Run Text="Microsoft Edge WebView2"
FlowDirection="LeftToRight"
FontWeight="SemiBold"/>
<Run Text=" معمولاً از موتور مرورگر داخلی ویندوز استفاده می‌کنند. برای تونل‌کردن صحیح ترافیک آن‌ها، علاوه‌بر خود برنامه، حتماً "/>
<Run Text="Microsoft Edge WebView2" FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}"/>
<Run Text=" را هم از ستون "/>
<Run Text="«برنامه‌های نصب‌شده»" FontWeight="SemiBold"/>
<Run Text=" به لیست تونل اضافه کنید."/>
<Run Text=" را هم به لیست تونل اضافه کنید."/>
</TextBlock>
</StackPanel>
</Border>
+46
View File
@@ -1,4 +1,5 @@
using System.Windows;
using System.Windows.Input;
using AppTunnel.ViewModels;
namespace AppTunnel.Views;
@@ -19,22 +20,34 @@ public partial class ConnectionTabView : System.Windows.Controls.UserControl
// Wire up PasswordBox (can't bind directly in WPF)
PasswordField.PasswordChanged += OnPasswordFieldChanged;
PskField.PasswordChanged += OnPskFieldChanged;
OpenVpnPasswordField.PasswordChanged += OnOpenVpnPasswordFieldChanged;
ProxyPasswordField.PasswordChanged += OnProxyPasswordFieldChanged;
// When profile changes, update PasswordBox fields
vm.PasswordChanged += OnViewModelPasswordChanged;
vm.OpenVpnPasswordChanged += OnViewModelOpenVpnPasswordChanged;
vm.ProxyPasswordChanged += OnViewModelProxyPasswordChanged;
// Load initial values
PasswordField.Password = vm.Password;
PskField.Password = vm.PreSharedKey;
OpenVpnPasswordField.Password = vm.OpenVpnPassword;
ProxyPasswordField.Password = vm.ProxyPassword;
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
PasswordField.PasswordChanged -= OnPasswordFieldChanged;
PskField.PasswordChanged -= OnPskFieldChanged;
OpenVpnPasswordField.PasswordChanged -= OnOpenVpnPasswordFieldChanged;
ProxyPasswordField.PasswordChanged -= OnProxyPasswordFieldChanged;
if (DataContext is MainViewModel vm)
{
vm.PasswordChanged -= OnViewModelPasswordChanged;
vm.OpenVpnPasswordChanged -= OnViewModelOpenVpnPasswordChanged;
vm.ProxyPasswordChanged -= OnViewModelProxyPasswordChanged;
}
}
private void OnPasswordFieldChanged(object sender, RoutedEventArgs e)
@@ -55,6 +68,18 @@ public partial class ConnectionTabView : System.Windows.Controls.UserControl
}
}
private void OnOpenVpnPasswordFieldChanged(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm && vm.OpenVpnPassword != OpenVpnPasswordField.Password)
vm.OpenVpnPassword = OpenVpnPasswordField.Password;
}
private void OnProxyPasswordFieldChanged(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm && vm.ProxyPassword != ProxyPasswordField.Password)
vm.ProxyPassword = ProxyPasswordField.Password;
}
private void OnViewModelPasswordChanged(string password, string psk)
{
Dispatcher.Invoke(() =>
@@ -64,9 +89,30 @@ public partial class ConnectionTabView : System.Windows.Controls.UserControl
});
}
private void OnViewModelOpenVpnPasswordChanged(string password)
{
Dispatcher.Invoke(() => OpenVpnPasswordField.Password = password);
}
private void OnViewModelProxyPasswordChanged(string password)
{
Dispatcher.Invoke(() => ProxyPasswordField.Password = password);
}
private void OnProfileNameChanged(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
vm.SaveCurrentState();
}
private void OnProfileListPreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
e.Handled = true;
var parentEvent = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta)
{
RoutedEvent = MouseWheelEvent,
Source = sender
};
RaiseEvent(parentEvent);
}
}
+143
View File
@@ -0,0 +1,143 @@
<Window x:Class="AppTunnel.Views.DonationDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:services="clr-namespace:AppTunnel.Services"
Title="حمایت از پروژه"
Width="430"
Height="520"
MinWidth="390"
MinHeight="440"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ResizeMode="NoResize"
ShowInTaskbar="False"
FlowDirection="{Binding Source={x:Static services:LocalizationService.Instance}, Path=FlowDirection}">
<Border Background="{StaticResource BackgroundBrush}"
CornerRadius="16"
BorderBrush="#34E8803A"
BorderThickness="1">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="28" Opacity="0.65" ShadowDepth="0"/>
</Border.Effect>
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="حمایت از پروژه"
FontSize="17"
FontWeight="Bold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="اگر TunnelX برایتان مفید بوده، می‌توانید با PayPal یا کریپتو از توسعه آن حمایت کنید."
FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="18"
Margin="0,4,12,0"/>
</StackPanel>
<Button Grid.Column="1"
Content="✕"
Style="{StaticResource WindowControlButton}"
Width="32"
Height="28"
FontSize="13"
FlowDirection="LeftToRight"
Click="OnCloseClick"/>
</Grid>
<Button Grid.Row="1"
Content="پرداخت با PayPal"
Style="{StaticResource PrimaryButton}"
HorizontalAlignment="Stretch"
Padding="14,9"
Margin="0,0,0,12"
Click="OnPayPalClick"/>
<Border Grid.Row="2"
Background="#0EFFFFFF"
BorderBrush="#16FFFFFF"
BorderThickness="1"
CornerRadius="12"
Padding="10">
<DockPanel LastChildFill="True">
<TextBlock DockPanel.Dock="Top"
Text="پرداخت با کریپتو"
FontSize="12"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
Margin="0,0,0,8"/>
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ItemsControl x:Name="CryptoItemsControl">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#10121212"
BorderBrush="#12FFFFFF"
BorderThickness="1"
CornerRadius="10"
Padding="9,8"
Margin="0,0,0,7">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Label}"
FontSize="11"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="{Binding Address}"
FontSize="10"
FontFamily="Consolas"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
FlowDirection="LeftToRight"
TextAlignment="Left"
Margin="0,3,0,0"/>
</StackPanel>
<Button Grid.Column="2"
Content="کپی"
Tag="{Binding}"
Style="{StaticResource SecondaryButton}"
Padding="9,4"
FontSize="10"
MinWidth="44"
VerticalAlignment="Center"
Click="OnCopyCryptoClick"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<TextBlock x:Name="CopyStatusText"
Grid.Row="3"
Text=""
FontSize="11"
Foreground="{StaticResource AccentBrush}"
HorizontalAlignment="Center"
Margin="0,10,0,0"/>
</Grid>
</Border>
</Window>
+68
View File
@@ -0,0 +1,68 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using AppTunnel.Services;
namespace AppTunnel.Views;
public partial class DonationDialog : Window
{
public DonationDialog()
{
InitializeComponent();
CryptoItemsControl.ItemsSource = CreateDonationAddresses();
Loaded += (_, _) => LocalizationService.Instance.ApplyTo(this);
}
private static ObservableCollection<CryptoDonationAddress> CreateDonationAddresses()
{
var t = LocalizationService.Instance.T;
return
[
new(t("ترون / USDT روی TRC20"), "TNWV867fQDT6zpLunHgbeMjrN6ic63LQSu"),
new(t("بیت‌کوین"), "bc1qgx3g47c458fu6smnpqpu0l05hha82rq2xjet4y"),
new(t("اتریوم / USDT روی ERC20"), "0x72d94Bb250E8802441a0ED05686Ee925BC99Fef5"),
new("TON", "UQD65oL2Vu2OJDSrwQ0wLLSw3g668SREMJ3VPW9k8b6Sy-Yf"),
new("BNB Smart Chain", "0xE2a5b01cE2b3713D435Bc16d92eAdd88A82159f0"),
new("Dogecoin", "DSZRNY65yF679uvjAh6sUAt6YiEEQHwKGb")
];
}
private void OnPayPalClick(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo(AppInfo.PayPalDonateUrl)
{
UseShellExecute = true
});
}
catch (Exception ex)
{
CopyStatusText.Text = LocalizationService.Instance.Format("باز کردن لینک ناموفق بود: {0}", ex.Message);
}
}
private void OnCopyCryptoClick(object sender, RoutedEventArgs e)
{
if (sender is not System.Windows.Controls.Button { Tag: CryptoDonationAddress item })
return;
try
{
System.Windows.Clipboard.SetText(item.Address);
CopyStatusText.Text = LocalizationService.Instance.Format("{0} کپی شد", item.Label);
}
catch (Exception ex)
{
CopyStatusText.Text = LocalizationService.Instance.Format("کپی ناموفق بود: {0}", ex.Message);
}
}
private void OnCloseClick(object sender, RoutedEventArgs e)
{
Close();
}
}
public sealed record CryptoDonationAddress(string Label, string Address);
+251 -234
View File
@@ -3,13 +3,53 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:services="clr-namespace:AppTunnel.Services">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Padding="0,14,0,12">
<StackPanel Margin="16,0">
<UserControl.Resources>
<Style x:Key="HelpCard" TargetType="Border" BasedOn="{StaticResource CardPanel}">
<Setter Property="Padding" Value="14,12"/>
<Setter Property="Margin" Value="0,0,0,10"/>
</Style>
<!-- Overview -->
<Border Style="{StaticResource CardPanel}">
<Style x:Key="HelpTitle" TargetType="TextBlock">
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Margin" Value="0,0,0,6"/>
</Style>
<Style x:Key="HelpBody" TargetType="TextBlock">
<Setter Property="FontSize" Value="11"/>
<Setter Property="LineHeight" Value="18"/>
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
</Style>
<Style x:Key="HelpChip" TargetType="Border">
<Setter Property="Background" Value="#0EFFFFFF"/>
<Setter Property="BorderBrush" Value="#14FFFFFF"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="9"/>
<Setter Property="Padding" Value="10,8"/>
</Style>
<Style x:Key="HelpExpander" TargetType="Expander">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="#14FFFFFF"/>
<Setter Property="BorderThickness" Value="0,1,0,0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0,4,0,0"/>
<Setter Property="FontSize" Value="12"/>
</Style>
</UserControl.Resources>
<ScrollViewer FlowDirection="{Binding AppFlowDirection}"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Padding="0,12,0,10">
<StackPanel Margin="14,0">
<!-- Header -->
<Border Style="{StaticResource HelpCard}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -17,23 +57,22 @@
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="TunnelX"
FontSize="22"
<TextBlock Text="راهنمای TunnelX"
FontSize="20"
FontWeight="Bold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="Split tunneling برای برنامه‌ها، مقصدها و حالت کل سیستم"
FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
<TextBlock Text="شروع سریع، پروفایل‌ها، بخش‌های اپ و عیب‌یابی در یک صفحه ساده."
Style="{StaticResource HelpBody}"
Margin="0,4,0,0"/>
</StackPanel>
<Border Grid.Column="1"
Background="#1AE8803A"
Background="#14E8803A"
CornerRadius="8"
Padding="10,5"
VerticalAlignment="Center">
Padding="9,4"
VerticalAlignment="Top">
<TextBlock Text="{x:Static services:AppInfo.VersionText}"
FontSize="12"
FontSize="11"
FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}"
FlowDirection="LeftToRight"/>
@@ -41,106 +80,162 @@
</Grid>
</Border>
<!-- Project + Support -->
<Border Style="{StaticResource CardPanel}">
<!-- Primary Actions -->
<Border Style="{StaticResource HelpCard}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Style="{StaticResource SectionHeader}" Text="پروژه آزاد و حمایت"/>
<TextBlock Text="{Binding AppCreatorText}"
FontSize="11"
Foreground="{StaticResource TextPrimaryBrush}"
Margin="0,0,0,4"/>
<TextBlock Text="{Binding AppGitHubUrl}"
FontSize="10"
Foreground="{StaticResource AccentBrush}"
FlowDirection="LeftToRight"
TextWrapping="Wrap"/>
<TextBlock Text="{Binding AppLicenseText, StringFormat=لایسنس: {0}}"
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
FlowDirection="LeftToRight"
Margin="0,5,0,0"/>
<TextBlock Text="{Binding DonatePayPalText}"
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
FlowDirection="LeftToRight"
Margin="0,5,0,0"/>
<TextBlock Text="{Binding CryptoDonationText}"
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
FontFamily="Consolas"
FlowDirection="LeftToRight"
TextWrapping="Wrap"
Margin="0,5,0,0"/>
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Vertical"
VerticalAlignment="Center"
MinWidth="150">
<Button Style="{StaticResource SecondaryButton}"
Content="باز کردن گیت‌هاب"
Command="{Binding OpenGitHubCommand}"
Padding="14,8"/>
<Button Style="{StaticResource PrimaryButton}"
Content="حمایت با پی‌پل"
Command="{Binding OpenDonateCommand}"
Padding="16,8"
Margin="0,8,0,0"/>
<Button Style="{StaticResource SecondaryButton}"
Content="کپی اطلاعات حمایت"
Command="{Binding CopyDonationInfoCommand}"
Padding="14,8"
Margin="0,8,0,0"/>
</StackPanel>
</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="پروژه و بروزرسانی" Style="{StaticResource HelpTitle}"/>
<TextBlock Text="{Binding UpdateStatusText}"
FontSize="11"
Style="{StaticResource HelpBody}"/>
<TextBlock Text="{Binding AppLicenseDisplayText}"
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="18"/>
FlowDirection="LeftToRight"
Margin="0,5,0,0"/>
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Vertical"
VerticalAlignment="Center"
MinWidth="150">
<Button Style="{StaticResource SecondaryButton}"
MinWidth="145"
VerticalAlignment="Center">
<Button Style="{StaticResource PrimaryButton}"
Content="{Binding UpdateButtonText}"
Command="{Binding CheckForUpdatesCommand}"
Padding="14,8"/>
<Button Style="{StaticResource PrimaryButton}"
Content="باز کردن صفحه انتشار"
FontSize="11"
Padding="12,7"/>
<Button Style="{StaticResource SecondaryButton}"
Content="صفحه انتشار"
Command="{Binding OpenLatestReleaseCommand}"
Padding="14,8"
Margin="0,8,0,0"/>
FontSize="11"
Padding="12,7"
Margin="0,6,0,0"/>
<Button Style="{StaticResource SecondaryButton}"
Content="GitHub"
Command="{Binding OpenGitHubCommand}"
FontSize="11"
Padding="12,7"
Margin="0,6,0,0"/>
</StackPanel>
</Grid>
</Border>
<!-- Quick Path -->
<Border Style="{StaticResource CardPanel}">
<!-- Quick Start -->
<Border Style="{StaticResource HelpCard}">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="شروع سریع"/>
<TextBlock Text="شروع سریع" Style="{StaticResource HelpTitle}"/>
<UniformGrid Columns="2" Rows="2">
<Border Style="{StaticResource HelpChip}" Margin="0,0,5,5">
<StackPanel>
<TextBlock Text="۱. پروفایل" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="کانفیگ را بسازید و نوع اتصال را انتخاب کنید."
Style="{StaticResource HelpBody}" FontSize="10" Margin="0,3,0,0"/>
</StackPanel>
</Border>
<Border Style="{StaticResource HelpChip}" Margin="5,0,0,5">
<StackPanel>
<TextBlock Text="۲. برنامه‌ها" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="برنامه‌های داخل تونل را انتخاب یا دستی اضافه کنید."
Style="{StaticResource HelpBody}" FontSize="10" Margin="0,3,0,0"/>
</StackPanel>
</Border>
<Border Style="{StaticResource HelpChip}" Margin="0,5,5,0">
<StackPanel>
<TextBlock Text="۳. قوانین" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="مقصدهای مستقیم یا اجباری را مشخص کنید."
Style="{StaticResource HelpBody}" FontSize="10" Margin="0,3,0,0"/>
</StackPanel>
</Border>
<Border Style="{StaticResource HelpChip}" Margin="5,5,0,0">
<StackPanel>
<TextBlock Text="۴. اتصال" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="وصل شوید و سلامت، IP و مصرف را بررسی کنید."
Style="{StaticResource HelpBody}" FontSize="10" Margin="0,3,0,0"/>
</StackPanel>
</Border>
</UniformGrid>
</StackPanel>
</Border>
<!-- Profile Types -->
<Border Style="{StaticResource HelpCard}">
<StackPanel>
<TextBlock Text="نوع پروفایل" Style="{StaticResource HelpTitle}"/>
<TextBlock Text="فقط فیلدهای مربوط به نوع انتخاب‌شده را پر کنید. هر پروفایل برنامه‌ها و قوانین مسیر خودش را نگه می‌دارد."
Style="{StaticResource HelpBody}"/>
<Expander Header="L2TP/IPsec" Style="{StaticResource HelpExpander}">
<TextBlock Text="برای اتصال VPN ویندوز. آدرس سرور، نام کاربری، رمز عبور و Pre-Shared Key لازم است. اگر وصل نشد، PSK، فایروال و تنظیمات VPN ویندوز را بررسی کنید."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
<Expander Header="V2Ray / Xray" Style="{StaticResource HelpExpander}">
<TextBlock Text="لینک یا JSON کانفیگ را وارد کنید یا از کلیپ‌بورد پیست کنید. TunnelX معمولاً sing-box را اجرا می‌کند و برای قابلیت‌هایی مثل xhttp از Xray-core استفاده می‌کند."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
<Expander Header="SOCKS5 / HTTP Proxy" Style="{StaticResource HelpExpander}">
<TextBlock Text="برای پراکسی خارجی آماده. نوع پراکسی، آدرس، پورت و در صورت نیاز نام کاربری/رمز را وارد کنید. این با پراکسی داخلی 127.0.0.1 فرق دارد."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
<Expander Header="OpenVPN" Style="{StaticResource HelpExpander}">
<TextBlock Text="فایل ovpn را انتخاب کنید. OpenVPN Community باید جداگانه نصب باشد؛ OpenVPN Connect برای Split Tunneling مناسب نیست. اگر سرور رمز می‌خواهد، نام کاربری و رمز را در TunnelX وارد کنید."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
</StackPanel>
</Border>
<!-- App Areas -->
<Border Style="{StaticResource HelpCard}">
<StackPanel>
<TextBlock Text="بخش‌های اپ" Style="{StaticResource HelpTitle}"/>
<Expander Header="اتصال VPN" Style="{StaticResource HelpExpander}" IsExpanded="True">
<TextBlock Text="پروفایل فعال، تست سرور، اتصال/قطع اتصال، IP خروجی، پینگ، مصرف و راهنمای پراکسی دستی اینجاست."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
<Expander Header="برنامه‌ها" Style="{StaticResource HelpExpander}">
<TextBlock Text="از لیست برنامه‌های پیدا شده انتخاب کنید یا فایل exe را دستی اضافه کنید. برای Store/MSIX/WebView2 برنامه را باز نگه دارید و بروزرسانی لیست را بزنید."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
<Expander Header="قوانین مسیر" Style="{StaticResource HelpExpander}">
<TextBlock Text="«مستقیم بماند» یعنی مقصد از تونل عبور نکند. «از تونل عبور کند» یعنی مقصد حتی بدون انتخاب برنامه وارد تونل شود. دامنه‌ها زیردامنه‌ها را هم پوشش می‌دهند."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
<Expander Header="تنظیمات" Style="{StaticResource HelpExpander}">
<TextBlock Text="پورت پراکسی محلی، MTU خودکار، DNS Optimization، Game Mode، اجرای خودکار ویندوز و اتصال خودکار اینجاست."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
<Expander Header="ترافیک و تاریخچه" Style="{StaticResource HelpExpander}">
<TextBlock Text="مدت اتصال، IP، مصرف تونل، مصرف خارج تونل، سلامت Split Tunnel، مصرف برنامه‌ها و تاریخچه اتصال‌ها را نشان می‌دهد."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
<Expander Header="جزئیات و لاگ‌ها" Style="{StaticResource HelpExpander}">
<TextBlock Text="از دکمه جزئیات، لاگ‌ها را با فیلتر خطا، هشدار، DNS یا Route ببینید. قبل از ارسال عمومی لاگ، رمزها، کلیدها، UUID و endpoint خصوصی را حذف کنید."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
</StackPanel>
</Border>
<!-- Essentials -->
<Border Style="{StaticResource HelpCard}">
<StackPanel>
<TextBlock Text="نکات مهم" Style="{StaticResource HelpTitle}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -153,167 +248,89 @@
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Grid.Column="0" Background="#11FFFFFF" CornerRadius="8" Padding="12,10">
<StackPanel>
<TextBlock Text="۱. پروفایل" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="نوع اتصال را انتخاب کنید و کانفیگ L2TP یا V2Ray/Xray را وارد کنید."
FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,5,0,0"/>
</StackPanel>
<Border Grid.Row="0" Grid.Column="0" Style="{StaticResource HelpChip}">
<TextBlock Text="حالت عادی: فقط برنامه‌های انتخاب‌شده و مقصدهای لزومی وارد تونل می‌شوند."
Style="{StaticResource HelpBody}" FontSize="10"/>
</Border>
<Border Grid.Row="0" Grid.Column="2" Background="#11FFFFFF" CornerRadius="8" Padding="12,10">
<StackPanel>
<TextBlock Text="۲. تست سرور" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="قبل از اتصال، تست سرور را بزنید. برای TLS، handshake واقعی اندازه‌گیری می‌شود."
FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,5,0,0"/>
</StackPanel>
<Border Grid.Row="0" Grid.Column="2" Style="{StaticResource HelpChip}">
<TextBlock Text="Full Route: کل سیستم وارد تونل می‌شود؛ استثناها می‌توانند مستقیم بمانند."
Style="{StaticResource HelpBody}" FontSize="10"/>
</Border>
<Border Grid.Row="2" Grid.Column="0" Background="#11FFFFFF" CornerRadius="8" Padding="12,10">
<Border Grid.Row="2" Grid.Column="0" Style="{StaticResource HelpChip}">
<StackPanel>
<TextBlock Text="۳. برنامه‌ها" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="برنامه‌هایی که باید از تونل عبور کنند را در تب برنامه‌ها فعال کنید."
FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<Border Grid.Row="2" Grid.Column="2" Background="#11FFFFFF" CornerRadius="8" Padding="12,10">
<StackPanel>
<TextBlock Text="۴. اتصال" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="بعد از اتصال، کارت سلامت ترافیک را برای Leak، DNS، IPv6 و Route بررسی کنید."
FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,5,0,0"/>
</StackPanel>
</Border>
</Grid>
</StackPanel>
</Border>
<!-- Modes -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{StaticResource CardPanel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="حالت انتخابی"/>
<TextBlock Text="فقط برنامه‌های انتخاب‌شده و مقصدهای لزومی از تونل عبور می‌کنند. این حالت برای مصرف کمتر و کنترل دقیق‌تر پیشنهاد می‌شود."
<TextBlock Text="پراکسی داخلی"
FontSize="11"
LineHeight="19"
TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}"/>
</StackPanel>
</Border>
<Border Grid.Column="2" Style="{StaticResource CardPanel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="عبور کل سیستم"/>
<TextBlock Text="وقتی Full Route روشن باشد، کل ترافیک ویندوز از تونل عبور می‌کند. برای تست یا زمانی که همه برنامه‌ها باید VPN داشته باشند مناسب است."
FontSize="11"
LineHeight="19"
TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}"/>
</StackPanel>
</Border>
</Grid>
<!-- Rules + Apps -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{StaticResource CardPanel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="برنامه‌ها"/>
<TextBlock TextWrapping="Wrap" FontSize="11" LineHeight="19"
Foreground="{StaticResource TextSecondaryBrush}">
برنامه‌های Store/MSIX و WebView2 ممکن است چند پردازش داشته باشند. اگر اپی دیده نشد، آن را باز کنید و دوباره بارگذاری برنامه‌ها را بزنید.
</TextBlock>
</StackPanel>
</Border>
<Border Grid.Column="2" Style="{StaticResource CardPanel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="قوانین مسیر"/>
<TextBlock TextWrapping="Wrap" FontSize="11" LineHeight="19"
Foreground="{StaticResource TextSecondaryBrush}">
استثناها مقصد را مستقیم نگه می‌دارند. لزومی‌ها مقصد را حتی بدون انتخاب برنامه از تونل عبور می‌دهند. دامنه، IP و URL قابل استفاده است.
</TextBlock>
</StackPanel>
</Border>
</Grid>
<!-- Local Proxy + Diagnostics -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{StaticResource CardPanel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="SOCKS5 داخلی"/>
<TextBlock TextWrapping="Wrap" FontSize="11" LineHeight="19"
Foreground="{StaticResource TextSecondaryBrush}">
پروکسی داخلی روی 127.0.0.1 اجرا می‌شود. پورت را قبل از اتصال انتخاب کنید؛ پورت‌های سیستم، رزرو و پورت‌های رایج توسعه محدود شده‌اند.
</TextBlock>
<Border Background="#11FFFFFF" CornerRadius="6" Padding="8" Margin="0,8,0,0">
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="برای ابزارهایی که آدرس محلی می‌خواهند:"
Style="{StaticResource HelpBody}" FontSize="10" Margin="0,3,0,0"/>
<TextBlock Text="127.0.0.1:1080"
FontSize="11"
FontFamily="Consolas"
Foreground="{StaticResource AccentBrush}"
FlowDirection="LeftToRight"/>
</Border>
FlowDirection="LeftToRight"
Margin="0,3,0,0"/>
</StackPanel>
</Border>
<Border Grid.Column="2" Style="{StaticResource CardPanel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="کارت سلامت"/>
<TextBlock TextWrapping="Wrap" FontSize="11" LineHeight="19"
Foreground="{StaticResource TextSecondaryBrush}">
Leak باید صفر باشد. DNS نشان می‌دهد درخواست‌های DNS هدایت شده‌اند. IPv6 blocked یعنی مسیرهای IPv6 ناخواسته بسته شده‌اند. Route تعداد مسیرهای فعال و خطاها را نشان می‌دهد.
</TextBlock>
</StackPanel>
<Border Grid.Row="2" Grid.Column="2" Style="{StaticResource HelpChip}">
<TextBlock Text="سلامت: Leak باید صفر باشد. DNS، IPv6 و Route را بعد از اتصال بررسی کنید."
Style="{StaticResource HelpBody}" FontSize="10"/>
</Border>
</Grid>
</StackPanel>
</Border>
<!-- Troubleshooting -->
<Border Style="{StaticResource CardPanel}">
<Border Style="{StaticResource HelpCard}">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="عیب‌یابی سریع"/>
<TextBlock Text="عیب‌یابی سریع" Style="{StaticResource HelpTitle}"/>
<Expander Header="اتصال برقرار نمی‌شود" Foreground="{StaticResource TextPrimaryBrush}" Margin="0,4,0,0">
<TextBlock Text="دسترسی Administrator، فایروال، پورت SOCKS5 و صحت کانفیگ را بررسی کنید. در کانفیگ‌های xhttp، هسته باید Xray باشد."
TextWrapping="Wrap" FontSize="11" LineHeight="19"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,8,0,0"/>
<Expander Header="اتصال برقرار نمی‌شود" Style="{StaticResource HelpExpander}">
<TextBlock Text="برنامه را با Administrator اجرا کنید. فایروال، آنتی‌ویروس، آدرس سرور، پورت، رمزها، PSK، نصب OpenVPN Community و اعتبار کانفیگ را بررسی کنید."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
<Expander Header="ترافیک برنامه از تونل عبور نمی‌کند" Foreground="{StaticResource TextPrimaryBrush}" Margin="0,8,0,0">
<TextBlock Text="برنامه را در تب برنامه‌ها فعال کنید. برای اپ‌های Store، برنامه را باز نگه دارید و لیست برنامه‌ها را دوباره بارگذاری کنید."
TextWrapping="Wrap" FontSize="11" LineHeight="19"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,8,0,0"/>
<Expander Header="ترافیک برنامه از تونل عبور نمی‌کند" Style="{StaticResource HelpExpander}">
<TextBlock Text="برنامه را در تب برنامه‌ها فعال کنید. اگر چندپردازشی است، برنامه را باز نگه دارید و لیست برنامه‌ها را دوباره بارگذاری کنید."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
<Expander Header="سایت داخلی باید مستقیم باز شود" Foreground="{StaticResource TextPrimaryBrush}" Margin="0,8,0,0">
<TextBlock Text="دامنه یا IP را به استثناها اضافه کنید. برای عبور اجباری یک مقصد از VPN، آن را به لزومی‌ها اضافه کنید."
TextWrapping="Wrap" FontSize="11" LineHeight="19"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,8,0,0"/>
<Expander Header="پراکسی کار نمی‌کند" Style="{StaticResource HelpExpander}">
<TextBlock Text="برای پروفایل پراکسی، آدرس، پورت، نوع و اطلاعات ورود را بررسی کنید. برای ابزارهای محلی، آدرس 127.0.0.1 و پورت تنظیمات را وارد کنید."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
<Expander Header="DNS، IPv6 یا Leak غیرعادی است" Style="{StaticResource HelpExpander}">
<TextBlock Text="یک بار قطع و وصل کنید تا مسیرها و قوانین DNS دوباره ساخته شوند. اگر مشکل ماند، لاگ‌های DNS و Route را بررسی کنید."
Style="{StaticResource HelpBody}" Margin="0,8,0,0"/>
</Expander>
</StackPanel>
</Border>
<!-- Support -->
<Border Style="{StaticResource HelpCard}">
<StackPanel>
<TextBlock Text="حمایت و تماس" Style="{StaticResource HelpTitle}"/>
<TextBlock Text="{Binding AppCreatorText}" Style="{StaticResource HelpBody}"/>
<TextBlock Text="{Binding AppGitHubUrl}"
FontSize="10"
Foreground="{StaticResource AccentBrush}"
FlowDirection="LeftToRight"
TextWrapping="Wrap"
Margin="0,5,0,0"/>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="حمایت با پی‌پل"
Command="{Binding OpenDonateCommand}"
FontSize="11"
Padding="12,7"/>
<Button Style="{StaticResource SecondaryButton}"
Content="کپی اطلاعات حمایت"
Command="{Binding CopyDonationInfoCommand}"
FontSize="11"
Padding="12,7"
Margin="8,0,0,0"/>
</StackPanel>
</StackPanel>
</Border>
+1 -1
View File
@@ -10,7 +10,7 @@
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
FlowDirection="RightToLeft"
FlowDirection="{Binding Source={x:Static services:LocalizationService.Instance}, Path=FlowDirection}"
MouseLeftButtonDown="OnTitleBarMouseDown">
<Border Background="{StaticResource BackgroundBrush}"
+2
View File
@@ -1,4 +1,5 @@
using System.Windows;
using AppTunnel.Services;
namespace AppTunnel.Views;
@@ -7,6 +8,7 @@ public partial class HelpWindow : Window
public HelpWindow()
{
InitializeComponent();
Loaded += (_, _) => LocalizationService.Instance.ApplyTo(this);
}
private void OnCloseClick(object sender, RoutedEventArgs e) => Close();
+27 -26
View File
@@ -2,11 +2,13 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,12,0,0">
<StackPanel HorizontalAlignment="Stretch" Margin="16,0">
<ScrollViewer FlowDirection="{Binding AppFlowDirection}"
VerticalScrollBarVisibility="Auto"
Padding="0,6,0,0">
<StackPanel HorizontalAlignment="Stretch" Margin="10,0">
<!-- History Header -->
<Border Style="{StaticResource CardPanel}">
<Border Style="{StaticResource CardPanel}" BorderBrush="#22E8803A">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
@@ -18,28 +20,27 @@
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="0" Grid.Column="0">
<TextBlock Text="📜 تاریخچه اتصالات" FontSize="16" FontWeight="SemiBold"
<TextBlock Text="📜 تاریخچه اتصالات" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="سوابق مصرف تونل در اتصال‌های قبلی نمایش داده می‌شود"
<TextBlock Text="سوابق اتصال و مصرف تونل"
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="11" Margin="0,4,0,0"/>
FontSize="10" Margin="0,2,0,0"/>
</StackPanel>
<Button Grid.Row="0" Grid.Column="1" Style="{StaticResource DangerButton}"
Content="🗑 پاک کردن"
Content="پاک کردن"
Command="{Binding ClearHistoryCommand}"
FontSize="11" Padding="12,8"/>
FontSize="10" Padding="10,5"/>
<!-- Total History Data Summary -->
<Border Grid.Row="1" Grid.ColumnSpan="2"
Background="#15E07820" CornerRadius="8"
Padding="14,10" Margin="0,12,0,0">
Background="#15E07820" BorderBrush="#22E8803A" BorderThickness="1" CornerRadius="9"
Padding="12,8" Margin="0,10,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"
FlowDirection="LeftToRight">
<TextBlock Text="📊 مجموع مصرف تونل: " FontSize="13"
Foreground="{StaticResource TextSecondaryBrush}"
FlowDirection="RightToLeft"/>
<TextBlock Text="{Binding TotalHistoryData}" FontSize="15"
FlowDirection="{Binding AppFlowDirection}">
<TextBlock Text="مجموع مصرف تونل: " FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBlock Text="{Binding TotalHistoryData}" FontSize="13"
FontWeight="Bold"
Foreground="{StaticResource WarningBrush}"/>
</StackPanel>
@@ -48,13 +49,13 @@
</Border>
<!-- History List -->
<Border Style="{StaticResource CardPanel}">
<Border Style="{StaticResource CardPanel}" BorderBrush="#18FFFFFF">
<StackPanel>
<ItemsControl ItemsSource="{Binding ConnectionHistory}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#11FFFFFF" CornerRadius="8"
Padding="14,12" Margin="0,4">
<Border Background="#12FFFFFF" BorderBrush="#10FFFFFF" BorderThickness="1" CornerRadius="9"
Padding="12,9" Margin="0,3">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -65,19 +66,19 @@
<!-- Profile name and server -->
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding ProfileName}"
FontSize="14" FontWeight="SemiBold"
FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text=" → "
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="12" VerticalAlignment="Center"/>
FontSize="11" VerticalAlignment="Center"/>
<TextBlock Text="{Binding ServerAddress}"
FontSize="12"
FontSize="11"
Foreground="{StaticResource AccentBrush}"
FlowDirection="LeftToRight"/>
</StackPanel>
<!-- Date and time -->
<StackPanel Orientation="Horizontal" Margin="0,6,0,0"
<StackPanel Orientation="Horizontal" Margin="0,4,0,0"
FlowDirection="LeftToRight">
<TextBlock Text="🕐 " FontSize="11"/>
<TextBlock Text="{Binding ConnectedAt, StringFormat={}{0:yyyy/MM/dd HH:mm}}"
@@ -86,7 +87,7 @@
</StackPanel>
<!-- Duration and data -->
<StackPanel Orientation="Horizontal" Margin="0,4,0,0"
<StackPanel Orientation="Horizontal" Margin="0,3,0,0"
FlowDirection="LeftToRight">
<TextBlock Text="⏱ " FontSize="11"/>
<TextBlock Text="{Binding DurationText}"
@@ -100,7 +101,7 @@
</StackPanel>
<!-- Sent/Received breakdown -->
<StackPanel Orientation="Horizontal" Margin="0,4,0,0"
<StackPanel Orientation="Horizontal" Margin="0,3,0,0"
FlowDirection="LeftToRight">
<TextBlock Text="↑ " Foreground="#E07820" FontSize="10"/>
<TextBlock Text="{Binding SentText}"
@@ -119,8 +120,8 @@
<!-- Empty state -->
<TextBlock Text="هنوز اتصالی ثبت نشده است."
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="14" HorizontalAlignment="Center"
Margin="0,30">
FontSize="12" HorizontalAlignment="Center"
Margin="0,22">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
+15 -10
View File
@@ -1,14 +1,16 @@
<Window x:Class="AppTunnel.Views.ModernDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:services="clr-namespace:AppTunnel.Services"
Title="TunnelX"
Width="340" Height="200"
MinWidth="300" MinHeight="170"
Width="360"
MinWidth="330" MinHeight="220"
SizeToContent="Height"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
FlowDirection="RightToLeft"
FlowDirection="{Binding Source={x:Static services:LocalizationService.Instance}, Path=FlowDirection}"
ShowInTaskbar="False"
ResizeMode="NoResize">
@@ -21,11 +23,11 @@
<DropShadowEffect Color="Black" BlurRadius="30" Opacity="0.7" ShadowDepth="0"/>
</Border.Effect>
<Grid Margin="18">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
@@ -40,7 +42,9 @@
FontSize="15" FontWeight="Bold"
Foreground="{StaticResource TextPrimaryBrush}"
HorizontalAlignment="Center"
Margin="0,0,0,8"/>
TextWrapping="Wrap"
TextAlignment="Center"
Margin="0,0,0,10"/>
<!-- Message -->
<TextBlock x:Name="MessageText" Grid.Row="2"
@@ -50,14 +54,15 @@
TextWrapping="Wrap"
TextAlignment="Center"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,0,0,14"
LineHeight="20"/>
VerticalAlignment="Top"
MaxWidth="310"
Margin="0,0,0,18"
LineHeight="22"/>
<!-- Buttons -->
<StackPanel Grid.Row="3" Orientation="Horizontal"
HorizontalAlignment="Center"
FlowDirection="RightToLeft">
FlowDirection="{Binding Source={x:Static services:LocalizationService.Instance}, Path=FlowDirection}">
<Button x:Name="PrimaryButton" Content="بله"
Style="{StaticResource PrimaryButton}"
MinWidth="80" Margin="0,0,6,0"
+18 -16
View File
@@ -1,4 +1,5 @@
using System.Windows;
using AppTunnel.Services;
using Application = System.Windows.Application;
namespace AppTunnel.Views;
@@ -14,6 +15,7 @@ public partial class ModernDialog : Window
public ModernDialog()
{
InitializeComponent();
Loaded += (_, _) => LocalizationService.Instance.ApplyTo(this);
}
/// <summary>
@@ -24,11 +26,11 @@ public partial class ModernDialog : Window
var dialog = new ModernDialog
{
Owner = owner ?? Application.Current.MainWindow,
TitleText = { Text = title },
MessageText = { Text = message },
TitleText = { Text = LocalizationService.Instance.T(title) },
MessageText = { Text = LocalizationService.Instance.T(message) },
IconText = { Text = "⚠️" },
PrimaryButton = { Content = "بله" },
SecondaryButton = { Content = "خیر" }
PrimaryButton = { Content = LocalizationService.Instance.T("بله") },
SecondaryButton = { Content = LocalizationService.Instance.T("خیر") }
};
dialog.ShowDialog();
@@ -43,10 +45,10 @@ public partial class ModernDialog : Window
var dialog = new ModernDialog
{
Owner = owner ?? Application.Current.MainWindow,
TitleText = { Text = title },
MessageText = { Text = message },
TitleText = { Text = LocalizationService.Instance.T(title) },
MessageText = { Text = LocalizationService.Instance.T(message) },
IconText = { Text = "️" },
PrimaryButton = { Content = "متوجه شدم", Visibility = Visibility.Visible },
PrimaryButton = { Content = LocalizationService.Instance.T("متوجه شدم"), Visibility = Visibility.Visible },
SecondaryButton = { Visibility = Visibility.Collapsed }
};
@@ -61,10 +63,10 @@ public partial class ModernDialog : Window
var dialog = new ModernDialog
{
Owner = owner ?? Application.Current.MainWindow,
TitleText = { Text = title },
MessageText = { Text = message },
TitleText = { Text = LocalizationService.Instance.T(title) },
MessageText = { Text = LocalizationService.Instance.T(message) },
IconText = { Text = "✅" },
PrimaryButton = { Content = "عالی", Visibility = Visibility.Visible },
PrimaryButton = { Content = LocalizationService.Instance.T("عالی"), Visibility = Visibility.Visible },
SecondaryButton = { Visibility = Visibility.Collapsed }
};
@@ -79,10 +81,10 @@ public partial class ModernDialog : Window
var dialog = new ModernDialog
{
Owner = owner ?? Application.Current.MainWindow,
TitleText = { Text = title },
MessageText = { Text = message },
TitleText = { Text = LocalizationService.Instance.T(title) },
MessageText = { Text = LocalizationService.Instance.T(message) },
IconText = { Text = "❌" },
PrimaryButton = { Content = "متوجه شدم", Visibility = Visibility.Visible },
PrimaryButton = { Content = LocalizationService.Instance.T("متوجه شدم"), Visibility = Visibility.Visible },
SecondaryButton = { Visibility = Visibility.Collapsed }
};
@@ -97,10 +99,10 @@ public partial class ModernDialog : Window
var dialog = new ModernDialog
{
Owner = owner ?? Application.Current.MainWindow,
TitleText = { Text = title },
MessageText = { Text = message },
TitleText = { Text = LocalizationService.Instance.T(title) },
MessageText = { Text = LocalizationService.Instance.T(message) },
IconText = { Text = "⚠️" },
PrimaryButton = { Content = "متوجه شدم", Visibility = Visibility.Visible },
PrimaryButton = { Content = LocalizationService.Instance.T("متوجه شدم"), Visibility = Visibility.Visible },
SecondaryButton = { Visibility = Visibility.Collapsed }
};
+320
View File
@@ -0,0 +1,320 @@
<Window x:Class="AppTunnel.Views.ProfileEditorDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:AppTunnel.Converters"
xmlns:models="clr-namespace:AppTunnel.Models"
xmlns:services="clr-namespace:AppTunnel.Services"
Title="Profile Editor"
Width="500"
Height="560"
MinWidth="480"
MinHeight="520"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
FlowDirection="{Binding Source={x:Static services:LocalizationService.Instance}, Path=FlowDirection}"
ShowInTaskbar="False"
ResizeMode="NoResize">
<Window.Resources>
<conv:EnumToVisibilityConverter x:Key="EnumToVis"/>
</Window.Resources>
<Border Background="{StaticResource BackgroundBrush}"
CornerRadius="18"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="32" Opacity="0.65" ShadowDepth="0"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0"
Background="{StaticResource SurfaceBrush}"
CornerRadius="18,18,0,0"
Padding="16,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock x:Name="DialogTitleText"
Text="ویرایش پروفایل"
FontSize="15"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="تنظیمات این کانفیگ بعد از ذخیره در لیست پروفایل‌ها نمایش داده می‌شود."
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,5,0,0"/>
</StackPanel>
<Button Grid.Column="1"
Content="×"
Style="{StaticResource WindowControlButton}"
Width="34"
Height="30"
FontSize="18"
Click="OnCancelClick"/>
</Grid>
</Border>
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="14,12">
<Border Style="{StaticResource CardPanel}" Padding="12" Margin="0,0,0,8">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="اطلاعات پروفایل"/>
<TextBlock Style="{StaticResource FieldLabel}" Text="نام پروفایل *"/>
<TextBox x:Name="ProfileNameField"
Style="{StaticResource ModernTextBox}"
Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
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}"
SelectedValue="{Binding TunnelType, Mode=TwoWay}"
SelectedValuePath="Tag">
<ComboBoxItem Content="L2TP/IPsec"
Tag="{x:Static models:TunnelType.L2tpIpsec}"/>
<ComboBoxItem Content="V2Ray / Xray"
Tag="{x:Static models:TunnelType.V2Ray}"/>
<ComboBoxItem Content="OpenVPN"
Tag="{x:Static models:TunnelType.OpenVpn}"/>
<ComboBoxItem Content="SOCKS / Proxy"
Tag="{x:Static models:TunnelType.SocksProxy}"/>
</ComboBox>
</StackPanel>
</Border>
<Border Style="{StaticResource CardPanel}" Padding="12">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="تنظیمات اتصال"/>
<StackPanel Visibility="{Binding TunnelType, Converter={StaticResource EnumToVis}, ConverterParameter=L2tpIpsec}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Style="{StaticResource FieldLabel}" Text="آدرس سرور"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding ServerAddress, UpdateSourceTrigger=PropertyChanged}"
FlowDirection="LeftToRight"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Style="{StaticResource FieldLabel}" Text="نام کاربری"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}"
FlowDirection="LeftToRight"/>
</StackPanel>
</Grid>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Style="{StaticResource FieldLabel}" Text="رمز عبور"/>
<PasswordBox x:Name="L2tpPasswordField"
Style="{StaticResource ModernPasswordBox}"
FlowDirection="LeftToRight"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Style="{StaticResource FieldLabel}" Text="Pre-Shared Key"/>
<PasswordBox x:Name="PskField"
Style="{StaticResource ModernPasswordBox}"
FlowDirection="LeftToRight"/>
</StackPanel>
</Grid>
</StackPanel>
<StackPanel Visibility="{Binding TunnelType, Converter={StaticResource EnumToVis}, ConverterParameter=V2Ray}">
<TextBlock Style="{StaticResource FieldLabel}" Text="کانفیگ V2Ray / Xray"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding V2RayConfig, UpdateSourceTrigger=PropertyChanged}"
AcceptsReturn="True"
MinHeight="112"
FontFamily="Consolas"
FlowDirection="LeftToRight"
VerticalScrollBarVisibility="Auto"
TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="پیست از کلیپ‌بورد"
FontSize="11"
Padding="12,6"
Click="OnPasteV2RayClick"/>
<Button Style="{StaticResource SecondaryButton}"
Content="پاک کردن"
FontSize="11"
Padding="12,6"
Margin="6,0,0,0"
Click="OnClearV2RayClick"/>
</StackPanel>
</StackPanel>
<StackPanel Visibility="{Binding TunnelType, Converter={StaticResource EnumToVis}, ConverterParameter=OpenVpn}">
<TextBlock Text="فایل .ovpn و اطلاعات احراز هویت OpenVPN را وارد کنید. اگر سرور رمز نمی‌خواهد، فیلد رمز را خالی بگذارید."
TextWrapping="Wrap"
FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,0,0,8"/>
<TextBlock Style="{StaticResource FieldLabel}" Text="فایل OpenVPN (.ovpn)"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Style="{StaticResource ModernTextBox}"
Text="{Binding OpenVpnConfigPath}"
IsReadOnly="True"
FlowDirection="LeftToRight"/>
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="انتخاب فایل"
FontSize="11"
Padding="12,7"
Click="OnBrowseOpenVpnClick"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Style="{StaticResource FieldLabel}" Text="نام کاربری"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding OpenVpnUsername, UpdateSourceTrigger=PropertyChanged}"
FlowDirection="LeftToRight"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Style="{StaticResource FieldLabel}" Text="رمز عبور"/>
<PasswordBox x:Name="OpenVpnPasswordField"
Style="{StaticResource ModernPasswordBox}"
FlowDirection="LeftToRight"/>
</StackPanel>
</Grid>
</StackPanel>
<StackPanel Visibility="{Binding TunnelType, Converter={StaticResource EnumToVis}, ConverterParameter=SocksProxy}">
<TextBlock Text="برای SOCKS5 یا HTTP Proxy، اطلاعات سرور را جداگانه وارد کنید."
TextWrapping="Wrap"
FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,0,0,8"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Style="{StaticResource FieldLabel}" Text="نوع پراکسی"/>
<ComboBox Style="{StaticResource DarkComboBox}"
SelectedValue="{Binding ProxyProtocol, Mode=TwoWay}"
SelectedValuePath="Tag">
<ComboBoxItem Content="SOCKS5"
Tag="{x:Static models:ProxyProtocol.Socks5}"/>
<ComboBoxItem Content="HTTP Proxy"
Tag="{x:Static models:ProxyProtocol.Http}"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Style="{StaticResource FieldLabel}" Text="پورت"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding ProxyPort, UpdateSourceTrigger=PropertyChanged}"
FlowDirection="LeftToRight"/>
</StackPanel>
</Grid>
<TextBlock Style="{StaticResource FieldLabel}" Text="آدرس IP یا دامنه سرور" Margin="0,8,0,3"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding ProxyServerAddress, UpdateSourceTrigger=PropertyChanged}"
FlowDirection="LeftToRight"/>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Style="{StaticResource FieldLabel}" Text="نام کاربری"/>
<TextBox Style="{StaticResource ModernTextBox}"
Text="{Binding ProxyUsername, UpdateSourceTrigger=PropertyChanged}"
FlowDirection="LeftToRight"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Style="{StaticResource FieldLabel}" Text="رمز عبور"/>
<PasswordBox x:Name="ProxyPasswordField"
Style="{StaticResource ModernPasswordBox}"
FlowDirection="LeftToRight"/>
</StackPanel>
</Grid>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<Border Grid.Row="2"
Background="{StaticResource SurfaceBrush}"
CornerRadius="0,0,18,18"
Padding="14,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="ValidationText"
Grid.Column="0"
FontSize="11"
Foreground="{StaticResource WarningBrush}"
TextWrapping="Wrap"
VerticalAlignment="Center"/>
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="لغو"
MinWidth="92"
Padding="16,8"
Click="OnCancelClick"/>
<Button Grid.Column="4"
Style="{StaticResource PrimaryButton}"
Content="ذخیره"
MinWidth="110"
Padding="18,8"
Click="OnSaveClick"/>
</Grid>
</Border>
</Grid>
</Border>
</Window>
+164
View File
@@ -0,0 +1,164 @@
using System.IO;
using System.Windows;
using AppTunnel.Models;
using AppTunnel.Services;
namespace AppTunnel.Views;
public partial class ProfileEditorDialog : Window
{
private readonly ConnectionProfile _profile;
public ProfileEditorDialog(ConnectionProfile profile, string title)
{
_profile = profile;
InitializeComponent();
Loaded += (_, _) => LocalizationService.Instance.ApplyTo(this);
DataContext = profile;
DialogTitleText.Text = LocalizationService.Instance.T(title);
Loaded += OnLoaded;
}
public static bool? Show(ConnectionProfile profile, string title, Window? owner)
{
var dialog = new ProfileEditorDialog(profile, title)
{
Owner = owner
};
return dialog.ShowDialog();
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
L2tpPasswordField.Password = _profile.Password;
PskField.Password = _profile.PreSharedKey;
OpenVpnPasswordField.Password = _profile.OpenVpnPassword;
ProxyPasswordField.Password = _profile.ProxyPassword;
}
private void OnBrowseOpenVpnClick(object sender, RoutedEventArgs e)
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = LocalizationService.Instance.T("انتخاب فایل OpenVPN"),
Filter = LocalizationService.Instance.T("OpenVPN config (*.ovpn)|*.ovpn|All files (*.*)|*.*"),
CheckFileExists = true,
Multiselect = false
};
if (dialog.ShowDialog(this) != true) return;
try
{
_profile.OpenVpnConfigPath = dialog.FileName;
_profile.OpenVpnConfig = File.ReadAllText(dialog.FileName);
}
catch (Exception ex)
{
ValidationText.Text = LocalizationService.Instance.Format("خواندن فایل OpenVPN ناموفق بود: {0}", ex.Message);
}
}
private void OnPasteV2RayClick(object sender, RoutedEventArgs e)
{
try
{
if (System.Windows.Clipboard.ContainsText())
_profile.V2RayConfig = System.Windows.Clipboard.GetText().Trim();
}
catch (Exception ex)
{
ValidationText.Text = LocalizationService.Instance.Format("خواندن کلیپ‌بورد ناموفق بود: {0}", ex.Message);
}
}
private void OnClearV2RayClick(object sender, RoutedEventArgs e)
{
_profile.V2RayConfig = "";
}
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;
_profile.ProxyPassword = ProxyPasswordField.Password;
if (!ValidateProfile(out var message))
{
ValidationText.Text = message;
return;
}
DialogResult = true;
Close();
}
private bool ValidateProfile(out string message)
{
if (string.IsNullOrWhiteSpace(_profile.Name))
{
message = LocalizationService.Instance.T("نام پروفایل را وارد کنید");
return false;
}
switch (_profile.TunnelType)
{
case TunnelType.L2tpIpsec when string.IsNullOrWhiteSpace(_profile.ServerAddress):
message = LocalizationService.Instance.T("آدرس سرور L2TP را وارد کنید");
return false;
case TunnelType.V2Ray when string.IsNullOrWhiteSpace(_profile.V2RayConfig):
message = LocalizationService.Instance.T("کانفیگ V2Ray/Xray را وارد کنید");
return false;
case TunnelType.OpenVpn when string.IsNullOrWhiteSpace(_profile.OpenVpnConfig):
message = LocalizationService.Instance.T("فایل OpenVPN (.ovpn) را انتخاب کنید");
return false;
case TunnelType.SocksProxy when string.IsNullOrWhiteSpace(_profile.ProxyServerAddress):
message = LocalizationService.Instance.T("آدرس سرور پراکسی را وارد کنید");
return false;
case TunnelType.SocksProxy when _profile.ProxyPort <= 0 || _profile.ProxyPort > 65535:
message = LocalizationService.Instance.T("پورت پراکسی باید بین 1 تا 65535 باشد");
return false;
}
message = "";
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;
Close();
}
}
+54 -5
View File
@@ -2,7 +2,9 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<ScrollViewer FlowDirection="{Binding AppFlowDirection}"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel HorizontalAlignment="Stretch" Margin="16,8,16,4">
<!-- Local Proxy Settings -->
@@ -19,20 +21,20 @@
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="پورت SOCKS5"
Text="پورت پروکسی محلی (SOCKS5/HTTP)"
FontSize="11"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
VerticalAlignment="Center"/>
<TextBox Grid.Column="2"
Style="{StaticResource ModernTextBox}"
Text="{Binding Socks5PortText, UpdateSourceTrigger=PropertyChanged}"
Text="{Binding MixedProxyPortText, UpdateSourceTrigger=PropertyChanged}"
FlowDirection="LeftToRight"
FontSize="12"
Padding="8,5"
ToolTip="پورت داخلی 127.0.0.1 برای پروکسی SOCKS5"/>
ToolTip="پورت داخلی 127.0.0.1 برای پروکسی SOCKS5 و HTTP"/>
<TextBlock Grid.Column="4"
Text="{Binding Socks5PortStatusText}"
Text="{Binding MixedProxyPortStatusText}"
FontSize="10"
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
@@ -43,6 +45,7 @@
Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
Margin="0,6,0,0"/>
</StackPanel>
</Border>
@@ -112,6 +115,52 @@
</StackPanel>
</Border>
<!-- Startup & Auto-Connect -->
<Border Style="{StaticResource CardPanel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="🖥️ استارت‌آپ و اتصال خودکار"
Margin="0,0,0,4"/>
<Grid Margin="0,2,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="اجرای خودکار هنگام روشن شدن ویندوز" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="⚠️ پس از فعال کردن، نباید محل فایل اجرایی TunnelX را تغییر دهید."
FontSize="10" Foreground="{StaticResource WarningBrush}"
TextWrapping="Wrap" Margin="0,2,0,0"/>
</StackPanel>
<CheckBox Grid.Column="1"
Style="{StaticResource ToggleSwitch}"
IsChecked="{Binding StartWithWindows, Mode=TwoWay}"
VerticalAlignment="Center"/>
</Grid>
<Border Background="#18FFFFFF" Height="1" Margin="0,8,0,8"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="اتصال خودکار به آخرین کانکشن فعال" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="اگر آخرین بار یک پروفایل متصل بوده، هنگام اجرای برنامه به آن وصل می‌شود."
FontSize="10" Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap" Margin="0,2,0,0"/>
</StackPanel>
<CheckBox Grid.Column="1"
Style="{StaticResource ToggleSwitch}"
IsChecked="{Binding AutoConnectOnStartup, Mode=TwoWay}"
VerticalAlignment="Center"/>
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+109
View File
@@ -2,6 +2,111 @@
## Unreleased
## 1.2.32 - 2026-05-18
### English
- Fixed VLESS REALITY configs in the sing-box path by enabling uTLS with the configured fingerprint, preventing startup failures that reported `uTLS is required by reality client`.
- Replaced icon-only log-panel actions with compact localized text buttons for clearing logs, copying the latest error, and copying all logs.
### فارسی
- مشکل کانفیگ‌های VLESS REALITY در مسیر sing-box اصلاح شد؛ uTLS با fingerprint کانفیگ فعال می‌شود تا خطای `uTLS is required by reality client` هنگام شروع اتصال رخ ندهد.
- دکمه‌های فقط‌آیکونی پنل لاگ با دکمه‌های متنی و فشرده جایگزین شدند تا پاک کردن لاگ، کپی آخرین خطا و کپی همه لاگ‌ها در فارسی و انگلیسی واضح باشد.
## 1.2.31 - 2026-05-18
- Update release packaging and connection UX
- Update README contact and localization notes
## 1.2.30 - 2026-05-18
### English
- Added bilingual Persian/English UI switching with automatic system-language detection, persisted language selection, RTL/LTR layout handling, and localized dialogs, tray text, runtime status messages, help content, and profile/app/routing views.
- Refined the desktop UI with adaptive window sizing, disabled maximize/system-menu fullscreen paths, smoother log-panel animation, polished dialogs, refreshed TunnelX branding, updated app icon, compact app-list rows, footer donation modal, and header update notification.
- Improved V2Ray/Xray reliability by dynamically reserving free local ports for Xray SOCKS and sing-box mixed proxy inbounds instead of relying on fixed `2080/2081` ports.
- Improved connection UX with more reliable exit-IP detection retries, consistent text-field behavior, better log window direction/localization, and localized README screenshots for Persian and English documentation.
### فارسی
- تغییر زبان فارسی/انگلیسی به برنامه اضافه شد؛ شامل تشخیص زبان سیستم، ذخیره زبان انتخاب‌شده، رعایت RTL/LTR، و ترجمه دیالوگ‌ها، متن tray، وضعیت‌های runtime، راهنما، پروفایل‌ها، برنامه‌ها و قوانین مسیر.
- رابط کاربری دسکتاپ بهبود یافت؛ شامل اندازه‌گیری تطبیقی پنجره، جلوگیری از maximize/fullscreen و منوی Alt+Space، انیمیشن نرم پنل لاگ، دیالوگ‌های تمیزتر، برندینگ و آیکون جدید TunnelX، ردیف‌های فشرده‌تر برنامه‌ها، مودال حمایت مالی و دکمه اعلان بروزرسانی در هدر.
- پایداری V2Ray/Xray بهتر شد؛ پورت‌های داخلی Xray SOCKS و sing-box mixed proxy دیگر ثابت نیستند و به‌صورت آزاد از سیستم رزرو می‌شوند تا خطای اشغال بودن `2080/2081` تکرار نشود.
- تجربه اتصال بهتر شد؛ شامل تلاش دوباره برای دریافت IP خروجی، رفتار یکدست فیلدهای متنی، جهت و ترجمه بهتر پنجره لاگ، و استفاده از اسکرین‌شات‌های فارسی/انگلیسی در READMEهای مربوطه.
## 1.2.29 - 2026-05-17
### English
- Expanded the GitHub README with Russian and Simplified Chinese summaries for international users.
- Expanded the in-app Persian Help tab with fuller guidance for profiles, connection types, routing rules, logs, updates, and troubleshooting.
### فارسی
- توضیح‌های روسی و چینی ساده‌شده به README گیت‌هاب اضافه شد تا کاربران بین‌المللی سریع‌تر با کاربرد برنامه آشنا شوند.
- تب راهنمای فارسی داخل برنامه با توضیح کامل‌تر درباره پروفایل‌ها، نوع‌های اتصال، قوانین مسیر، لاگ‌ها، بروزرسانی و عیب‌یابی گسترش پیدا کرد.
## 1.2.28 - 2026-05-17
### English
- Fixed Full Route default-route installation by preferring the VPN gateway, retrying with an on-link gateway when needed, and cleaning up the pinned physical route to the tunnel server when Full Route is disabled.
- Updated English/Persian README and in-app Help content for the new SOCKS/Proxy profile flow, connection types, routing notes, local data, and troubleshooting guidance.
### فارسی
- نصب default route در حالت Full Route اصلاح شد؛ ابتدا gateway تونل استفاده می‌شود، در صورت نیاز با gateway روی‌لینک دوباره تلاش می‌شود، و route فیزیکی ثابت‌شده برای سرور تونل هنگام خاموش شدن Full Route پاک‌سازی می‌شود.
- README فارسی/انگلیسی و محتوای راهنمای داخل برنامه برای جریان جدید SOCKS/Proxy، نوع‌های اتصال، نکته‌های مسیر، داده‌های محلی و عیب‌یابی به‌روز شد.
## 1.2.27 - 2026-05-17
### English
- Added a dedicated SOCKS5/HTTP Proxy profile type with separate server, port, username, and password fields, encrypted proxy password persistence, validation hints, and proxy-specific connection handling through sing-box.
- Reworked profile management into a compact profile list with separate add/edit dialogs, clearer active profile selection, and improved Persian-first profile cards.
- Improved the connected dashboard with public exit IP detection, shorter ping results, clearer tunnel/direct traffic cards, manual proxy guidance, full-route controls, and a dedicated disconnect action.
- Refined Persian font rendering, global WPF text settings, tab headers, footer, connection controls, route rules, app selection, history, and traffic views for a cleaner desktop UI.
- Improved routing diagnostics and split-tunnel handling for V2Ray, SOCKS/Proxy, OpenVPN, include/exclude destination rules, DNS rule learning, and tunnel server health checks.
- Fixed OpenVPN internal reconnect handling by detecting runtime tunnel IP, gateway, interface, or remote endpoint changes and restarting TunnelX packet routing with the new values.
### فارسی
- نوع پروفایل اختصاصی SOCKS5/HTTP Proxy اضافه شد؛ شامل فیلدهای جداگانه سرور، پورت، نام کاربری و رمز عبور، ذخیره امن رمز پراکسی، راهنمای اعتبارسنجی و اتصال از طریق sing-box.
- مدیریت پروفایل‌ها به لیست فشرده کانفیگ‌ها با پنجره جدا برای افزودن/ویرایش، انتخاب واضح پروفایل فعال و کارت‌های فارسی‌محور بهتر بازطراحی شد.
- داشبورد بعد از اتصال بهبود یافت؛ نمایش IP خروجی عمومی، نتیجه کوتاه پینگ، کارت‌های واضح‌تر مصرف تونل/خارج تونل، راهنمای پراکسی دستی، کنترل Full Route و دکمه اختصاصی قطع اتصال اضافه شد.
- رندر فونت فارسی، تنظیمات عمومی متن در WPF، تب‌ها، فوتر، کنترل‌های اتصال، قوانین مسیر، انتخاب برنامه‌ها، تاریخچه و نمای مصرف ترافیک برای رابط کاربری تمیزتر اصلاح شد.
- عیب‌یابی مسیر و Split Tunneling برای V2Ray، SOCKS/Proxy، OpenVPN، قوانین include/exclude، یادگیری قوانین DNS و health check سرور تونل بهبود پیدا کرد.
- مشکل reconnect داخلی OpenVPN اصلاح شد؛ اگر هنگام اتصال طولانی IP تونل، gateway، interface یا سرور مقصد عوض شود، TunnelX مسیر‌دهی ترافیک را با مقادیر جدید دوباره راه‌اندازی می‌کند.
## 1.2.26 - 2026-05-17
### English
- Added OpenVPN Community support as an external tunnel provider for split tunneling.
- Added `.ovpn` file selection, OpenVPN username/password fields, install detection, and clearer Persian guidance in the connection and help screens.
- Added split-compatible OpenVPN config preparation with route/DNS push filtering, credential file handling without UTF-8 BOM, remote candidate filtering, and faster retry behavior.
- Fixed OpenVPN split routing by capturing the real connected remote, assigned tunnel IP, and route gateway before starting packet routing.
- Added OpenVPN stale-process cleanup for TunnelX-started OpenVPN processes and prevented stale TAP adapters from being treated as a fresh connection.
- Improved server testing and post-connect ping behavior for OpenVPN profiles.
### فارسی
- پشتیبانی از OpenVPN Community به‌عنوان ارائه‌دهنده خارجی تونل برای Split Tunneling اضافه شد.
- انتخاب فایل `.ovpn`، فیلدهای نام کاربری و رمز عبور OpenVPN، تشخیص نصب بودن OpenVPN Community و راهنمای فارسی واضح‌تر در صفحه اتصال و راهنما اضافه شد.
- آماده‌سازی کانفیگ OpenVPN سازگار با Split Tunnel اضافه شد؛ شامل نادیده گرفتن route/DNSهای push شده، ذخیره فایل credential بدون UTF-8 BOM، فیلتر کردن remoteهای نامعتبر و retry سریع‌تر.
- مسیر‌دهی Split Tunnel در OpenVPN با ثبت remote واقعی متصل‌شده، IP اختصاص داده‌شده به تونل و route gateway قبل از شروع packet routing اصلاح شد.
- پاک‌سازی پردازش‌های قدیمی OpenVPN که توسط TunnelX اجرا شده‌اند اضافه شد و از شناسایی آداپترهای TAP خراب یا قدیمی به‌عنوان اتصال جدید جلوگیری شد.
- تست سرور و پینگ بعد از اتصال برای پروفایل‌های OpenVPN بهبود پیدا کرد.
## 1.2.25 - 2026-05-16
- Merge pull request #13 from BlacKSnowDot0/pr-clean
- Merge pull request #16 from mohammad-parvizi-dev/main
- Improve tab headers, theme styling, and tray notifications
- Add startup and auto-connect app settings
- feat(proxy): SOCKS5/HTTP via V2Ray/sing-box, add MixedProxyServer, remove standalone proxy types and local auth
## 1.2.24 - 2026-05-12
- Added README screenshots in English and Persian.
@@ -29,3 +134,7 @@
- Added project metadata for MaxFan and GPL-3.0-or-later licensing.
- Improved leak logging and traffic accounting in recent internal builds.
+75 -12
View File
@@ -2,11 +2,9 @@
# TunnelX
فارسی | <span dir="ltr">[English](README.md)</span>
فارسی | <span dir="ltr">[English](README.md)</span> | <span dir="ltr">[Русский](README.md#русский)</span> | <span dir="ltr">[简体中文](README.md#简体中文)</span>
<span dir="ltr">TunnelX</span> یک نرم‌افزار آزاد و رایگان برای ویندوز است که توسط **<span dir="ltr">MaxFan</span>** ساخته شده و برای مدیریت تونل، وی‌پی‌ان و <span dir="ltr">Split Tunneling</span> استفاده می‌شود. این برنامه می‌تواند ترافیک برنامه‌های انتخاب‌شده، مقصدهای مشخص، یا کل سیستم را از تونل عبور دهد و هم‌زمان مسیر عادی شبکه را برای مقصدهای محلی یا مستثنی‌شده حفظ کند.
> وضعیت پروژه: پیش‌انتشار. قبل از انتشار عمومی فایل اجرایی، نکات ساخت و انتشار را در <span dir="ltr">`docs/BUILD.md`</span> بررسی کنید.
<span dir="ltr">TunnelX</span> یک نرم‌افزار آزاد و رایگان برای ویندوز است که توسط **<span dir="ltr">MaxFan</span>** ساخته شده و برای مدیریت تونل، وی‌پی‌ان و <span dir="ltr">Split Tunneling</span> استفاده می‌شود. این برنامه می‌تواند ترافیک برنامه‌های انتخاب‌شده، مقصدهای مشخص، یا کل سیستم را از تونل عبور دهد و هم‌زمان مسیر عادی شبکه را برای مقصدهای محلی یا مستثنی‌شده حفظ کند. رابط برنامه دو‌زبانه است، زبان سیستم را تشخیص می‌دهد و چینش راست‌به‌چپ/چپ‌به‌راست را برای فارسی و انگلیسی رعایت می‌کند.
## کاربرد برنامه
@@ -16,20 +14,79 @@
- <span dir="ltr">Split tunneling</span> بر اساس برنامه‌های انتخاب‌شده در ویندوز
- حالت <span dir="ltr">Full-route</span> برای تونل کردن کل سیستم
- پشتیبانی از پروفایل‌های <span dir="ltr">L2TP/IPsec</span> ویندوز
- پشتیبانی از جریان‌های <span dir="ltr">V2Ray</span> بر پایه <span dir="ltr">Xray-core</span> و <span dir="ltr">sing-box</span>
- پشتیبانی از پروفایل‌های اختصاصی <span dir="ltr">SOCKS5/HTTP Proxy</span> با سرور، پورت، نام کاربری و رمز عبور جداگانه
- پشتیبانی از <span dir="ltr">OpenVPN Community</span> با فایل‌های <span dir="ltr">`.ovpn`</span> برای <span dir="ltr">Split tunneling</span> برنامه‌های انتخاب‌شده
- پروکسی <span dir="ltr">SOCKS5</span> محلی روی <span dir="ltr">`127.0.0.1`</span> برای ابزارهایی که تنظیم پروکسی داخلی دارند
- تغییر مسیر <span dir="ltr">DNS</span>، مسدودسازی <span dir="ltr">IPv6</span>، محافظ نشت، عیب‌یابی <span dir="ltr">route</span> و تاریخچه مصرف تونل
- رابط کاربری فارسی‌محور برای ویندوز
- مدیریت چند پروفایل، کپی/ویرایش کانفیگ‌ها، تست سرور، تشخیص <span dir="ltr">IP</span> خروجی و اعلان بروزرسانی
- رابط کاربری فارسی و انگلیسی با تشخیص خودکار زبان، دکمه تغییر زبان و رعایت کامل راست‌به‌چپ/چپ‌به‌راست
- انتخاب پورت داخلی آزاد برای <span dir="ltr">V2Ray/Xray</span> تا خطاهای اشغال بودن پورت‌های <span dir="ltr">`2080/2081`</span> کمتر شود
## شروع سریع
1. آخرین فایل <span dir="ltr">standalone</span> را از بخش <span dir="ltr">GitHub Releases</span> دانلود کنید.
2. برنامه را با دسترسی <span dir="ltr">Administrator</span> اجرا کنید؛ قابلیت‌های تغییر مسیر، <span dir="ltr">WinDivert</span> و مدیریت ترافیک به سطح دسترسی بالا نیاز دارند.
3. از تب اتصال، یک کانفیگ جدید بسازید یا کانفیگ موجود را انتخاب کنید.
4. نوع اتصال را انتخاب کنید: <span dir="ltr">L2TP/IPsec</span>، <span dir="ltr">V2Ray/Xray</span>، <span dir="ltr">SOCKS5/HTTP Proxy</span> یا <span dir="ltr">OpenVPN</span>.
5. قبل از اتصال، تست سرور را اجرا کنید و سپس برنامه‌هایی را که باید از تونل عبور کنند در تب برنامه‌ها فعال کنید.
6. در صورت نیاز، مقصدهای لزومی یا استثنا را اضافه کنید و بعد از اتصال کارت سلامت ترافیک، <span dir="ltr">DNS</span>، <span dir="ltr">IPv6</span> و <span dir="ltr">Route</span> را بررسی کنید.
## انواع اتصال
### <span dir="ltr">L2TP/IPsec</span>
برای اتصال‌های <span dir="ltr">L2TP/IPsec</span>، آدرس سرور، نام کاربری، رمز عبور و <span dir="ltr">Pre-Shared Key</span> را وارد کنید. <span dir="ltr">TunnelX</span> اتصال ویندوز را ایجاد می‌کند و سپس مسیرها را بر اساس حالت انتخابی یا <span dir="ltr">Full-route</span> مدیریت می‌کند.
### <span dir="ltr">V2Ray / Xray</span>
لینک یا کانفیگ <span dir="ltr">V2Ray/Xray</span> را در پروفایل وارد کنید. برنامه برای کانفیگ‌های معمول از <span dir="ltr">sing-box</span> استفاده می‌کند و برای کانفیگ‌هایی که به قابلیت‌های خاص <span dir="ltr">Xray</span> مثل <span dir="ltr">xhttp</span> نیاز دارند، <span dir="ltr">Xray-core</span> را انتخاب می‌کند.
### <span dir="ltr">SOCKS5/HTTP Proxy</span>
اگر از پراکسی آماده استفاده می‌کنید، نوع پروفایل <span dir="ltr">SOCKS5/HTTP Proxy</span> را انتخاب کنید و سرور، پورت و در صورت نیاز نام کاربری و رمز عبور را وارد کنید. این حالت برای عبور برنامه‌های انتخاب‌شده از یک پراکسی خارجی مناسب است و با پراکسی محلی <span dir="ltr">`127.0.0.1`</span> تفاوت دارد.
## پشتیبانی از <span dir="ltr">OpenVPN</span>
<span dir="ltr">TunnelX</span> می‌تواند نسخه نصب‌شده <span dir="ltr">OpenVPN Community</span> و فایل انتخابی <span dir="ltr">`.ovpn`</span> کاربر را اجرا کند و سپس سیاست <span dir="ltr">Split tunneling</span> خودش را اعمال کند؛ یعنی فقط برنامه‌ها و مقصدهای انتخاب‌شده از تونل <span dir="ltr">OpenVPN</span> عبور می‌کنند.
<span dir="ltr">OpenVPN</span> همراه <span dir="ltr">TunnelX</span> توزیع نمی‌شود. برای این حالت باید <span dir="ltr">OpenVPN Community</span> را جداگانه نصب کنید، فایل <span dir="ltr">`.ovpn`</span> را در <span dir="ltr">TunnelX</span> انتخاب کنید و در صورت نیاز نام کاربری و رمز عبور <span dir="ltr">OpenVPN</span> را داخل برنامه وارد کنید. نصب بودن <span dir="ltr">OpenVPN Connect</span> به‌تنهایی برای این حالت کافی نیست، چون آن برنامه مسیرها و <span dir="ltr">DNS</span> را با کلاینت خودش مدیریت می‌کند.
<span dir="ltr">TunnelX</span> برای سازگاری با <span dir="ltr">Split tunneling</span>، تنظیمات مسیر و <span dir="ltr">DNS</span> تحمیلی فایل <span dir="ltr">`.ovpn`</span> را کنترل می‌کند و در صورت تغییر <span dir="ltr">IP</span> تونل، <span dir="ltr">gateway</span>، <span dir="ltr">interface</span> یا مقصد ریموت هنگام <span dir="ltr">reconnect</span>، مسیر‌دهی داخلی خودش را دوباره راه‌اندازی می‌کند.
## نکته‌های مسیر و دامنه
قانون‌های <span dir="ltr">Include</span> و <span dir="ltr">Exclude</span> هم خود دامنه واردشده و هم زیردامنه‌های آن را پوشش می‌دهند. برای نمونه، افزودن <span dir="ltr">`githubusercontent.com`</span> پس از resolve شدن <span dir="ltr">DNS</span> شامل <span dir="ltr">`raw.githubusercontent.com`</span> هم می‌شود. اگر یک کلاینت <span dir="ltr">HTTPS</span> در مرحله بررسی <span dir="ltr">certificate revocation</span> خطا داد، ممکن است میزبان‌های <span dir="ltr">OCSP/CRL</span> آن از مسیر انتخابی قابل دسترسی نباشند؛ در این حالت خود برنامه دانلودکننده یا دامنه‌های revocation مربوطه را هم در لیست لزومی قرار دهید.
- مقصدهای استثناشده حتی برای برنامه‌های انتخاب‌شده مستقیم می‌مانند.
- مقصدهای لزومی حتی اگر برنامه مربوطه انتخاب نشده باشد از تونل عبور می‌کنند.
- برای برنامه‌های <span dir="ltr">Store/MSIX</span>، <span dir="ltr">WebView2</span> یا برنامه‌های چندپردازشی، برنامه را باز نگه دارید و فهرست برنامه‌ها را دوباره بارگذاری کنید.
- اگر <span dir="ltr">Full-route</span> روشن باشد، کل ترافیک سیستم از تونل عبور می‌کند و قانون‌های مستقیم/استثنا همچنان برای نگه داشتن مقصدهای خاص روی مسیر عادی کاربرد دارند.
## تنظیمات و داده‌های محلی
پروفایل‌ها، برنامه‌های انتخاب‌شده، مقصدهای لزومی/استثنا، تاریخچه اتصال و لاگ‌ها روی دستگاه کاربر نگهداری می‌شوند و معمولاً در مسیر <span dir="ltr">`%LOCALAPPDATA%\TunnelX`</span> یا کنار برنامه قرار می‌گیرند. <span dir="ltr">TunnelX</span> عمداً تحلیل آماری یا <span dir="ltr">telemetry</span> برای نگهدارنده ارسال نمی‌کند.
لاگ‌ها ممکن است شامل نام پردازش‌ها، نام دامنه‌ها، آدرس‌های <span dir="ltr">IP</span>، پورت‌ها و وضعیت اتصال باشند. قبل از ارسال عمومی لاگ در <span dir="ltr">GitHub Issues</span>، اطلاعات حساس مثل آدرس سرور خصوصی، کلیدها، <span dir="ltr">UUID</span>، رمزها و endpointهای شخصی را حذف کنید.
## عیب‌یابی سریع
- اگر اتصال برقرار نمی‌شود، اجرای برنامه با دسترسی <span dir="ltr">Administrator</span>، فایروال، درستی کانفیگ، پورت‌های پراکسی و نصب بودن پیش‌نیازهای مربوط به همان نوع اتصال را بررسی کنید.
- اگر ترافیک یک برنامه از تونل عبور نمی‌کند، برنامه را در تب برنامه‌ها فعال کنید، برنامه را باز نگه دارید و فهرست برنامه‌ها را دوباره بارگذاری کنید.
- اگر فقط یک سایت یا دامنه باید از تونل عبور کند، آن را به مقصدهای لزومی اضافه کنید؛ اگر باید مستقیم بماند، آن را به استثناها اضافه کنید.
- اگر خطای <span dir="ltr">DNS</span> یا <span dir="ltr">IPv6</span> می‌بینید، کارت سلامت بعد از اتصال را بررسی کنید و در صورت نیاز یک‌بار قطع و وصل کنید تا مسیرها و قانون‌های <span dir="ltr">DNS</span> دوباره ساخته شوند.
- اگر از <span dir="ltr">OpenVPN</span> استفاده می‌کنید و اتصال طولانی می‌شود، فایل <span dir="ltr">`.ovpn`</span>، نام کاربری/رمز و نصب بودن <span dir="ltr">OpenVPN Community</span> را بررسی کنید.
## تصاویر برنامه
| داشبورد اتصال | تنظیم پروفایل و سرور |
| --- | --- |
| <img src="docs/ScreenShots/Screenshot%202026-05-12%20115349.png" alt="داشبورد اتصال TunnelX"> | <img src="docs/ScreenShots/Screenshot%202026-05-12%20115544.png" alt="تنظیم پروفایل و سرور TunnelX"> |
| <img src="docs/ScreenShots/fa/connection-dashboard.png" alt="داشبورد اتصال TunnelX"> | <img src="docs/ScreenShots/fa/apps.png" alt="انتخاب برنامه‌ها برای تونل در TunnelX"> |
| انتخاب برنامه‌ها برای تونل | تنظیمات تونل |
| قوانین مسیر | راهنما و عیب‌یابی |
| --- | --- |
| <img src="docs/ScreenShots/Screenshot%202026-05-12%20115646.png" alt="انتخاب برنامه‌ها برای تونل در TunnelX"> | <img src="docs/ScreenShots/Screenshot%202026-05-12%20115718.png" alt="تنظیمات تونل در TunnelX"> |
| <img src="docs/ScreenShots/fa/routing-rules.png" alt="قوانین مسیر در TunnelX"> | <img src="docs/ScreenShots/fa/help.png" alt="راهنما و عیب‌یابی در TunnelX"> |
## دانلود
@@ -65,17 +122,23 @@ dotnet publish AppTunnel\AppTunnel.csproj -c Release -r win-x64 --self-contained
## مجوز
<span dir="ltr">TunnelX</span> تحت مجوز **<span dir="ltr">GPL-3.0-or-later</span>** منتشر شده است. اجزای شخص ثالث همراه پروژه مجوزهای خودشان را دارند. برای جزئیات بیشتر:
<span dir="ltr">TunnelX</span> تحت مجوز **<span dir="ltr">GPL-3.0-or-later</span>** منتشر شده است. استفاده تجاری با رعایت شرایط <span dir="ltr">GPL</span> مجاز است. اجزای شخص ثالث همراه پروژه مجوزهای خودشان را دارند. برای جزئیات بیشتر:
- <span dir="ltr">`LICENSE`</span>
- <span dir="ltr">`THIRD_PARTY_NOTICES.md`</span>
- <span dir="ltr">`docs/LEGAL.md`</span>
## حمایت مالی
## پشتیبانی، سفارشی‌سازی و حمایت مالی
<span dir="ltr">TunnelX</span> رایگان است. حمایت مالی کاملا اختیاری است و فقط به نگهداری و توسعه پروژه کمک می‌کند.
<span dir="ltr">TunnelX</span> آزاد و رایگان است. حمایت مالی کاملا اختیاری است و فقط به نگهداری و توسعه پروژه کمک می‌کند.
گزینه‌های حمایت از طریق <span dir="ltr">GitHub Sponsors/Funding</span> یا فایل <span dir="ltr">`docs/DONATE.md`</span> در دسترس هستند.
برای ارتباط مستقیم، درخواست پشتیبانی، سفارشی‌سازی خصوصی یا سفارش توسعه، از طریق تلگرام پیام بدهید: <span dir="ltr">[t.me/maxifaan](https://t.me/maxifaan)</span>
خدمات پولی می‌تواند به صورت جداگانه برای پشتیبانی خصوصی، راه‌اندازی، بیلد اختصاصی، سفارشی‌سازی برای شرکت‌ها، یا توسعه برنامه‌ای مشابه ارائه شود. این خدمات پولی حقوقی را که مجوز <span dir="ltr">GPL</span> به کاربران می‌دهد محدود نمی‌کند.
پذیرش تبلیغات ثابت داخل <span dir="ltr">TunnelX</span> امکان‌پذیر است. تبلیغات به‌صورت مستقیم با نگهدارنده هماهنگ می‌شود، از طریق شبکه‌های تبلیغاتی یا سایت‌های واسط نمایش داده نمی‌شود و با هدف ساده، ثابت و امن ماندن تجربه کاربر انجام می‌شود.
گزینه‌های حمایت مالی از طریق <span dir="ltr">GitHub Sponsors/Funding</span> یا فایل <span dir="ltr">`docs/DONATE.md`</span> در دسترس هستند.
## نکته ایمنی و سلب مسئولیت
+95 -12
View File
@@ -1,35 +1,112 @@
# TunnelX
[فارسی](README.fa.md) | English
[فارسی](README.fa.md) | English | [Русский](#русский) | [简体中文](#简体中文)
TunnelX is a free and open-source Windows split-tunneling client built by **MaxFan**. It routes selected apps, selected destinations, or the whole system through supported tunnel cores while keeping local and excluded destinations on the normal network path.
TunnelX is a free and open-source Windows split-tunneling client built by **MaxFan**. It routes selected apps, selected destinations, or the whole system through supported tunnel cores while keeping local and excluded destinations on the normal network path. The app supports Persian and English UI modes with automatic system-language detection and correct RTL/LTR layout handling.
> Status: pre-release. Review the release notes in `docs/BUILD.md` before publishing a public artifact.
## Русский
TunnelX — бесплатный клиент split tunneling для Windows от **MaxFan**. Он позволяет направлять через VPN, V2Ray/Xray, OpenVPN или SOCKS5/HTTP Proxy только выбранные приложения, выбранные домены/IP или весь системный трафик. Интерфейс поддерживает персидский и английский языки с автоматическим выбором языка системы и корректным RTL/LTR отображением.
Основные возможности: профили L2TP/IPsec, V2Ray/Xray, SOCKS5/HTTP Proxy и OpenVPN Community; выбор приложений для туннеля; правила include/exclude для доменов и IP; режим Full Route; локальный прокси `127.0.0.1`; отображение публичного выходного IP; история трафика; защита от DNS/IPv6/leak проблем.
Для обычного использования скачайте последний standalone-файл из [GitHub Releases](https://github.com/MaxiFan/TunnelX/releases/latest), запустите TunnelX от имени Administrator, создайте профиль подключения, выберите приложения для туннеля и подключитесь. Отдельная установка .NET Runtime для standalone-сборки не требуется.
Связаться с автором можно в Telegram: [t.me/maxifaan](https://t.me/maxifaan).
## 简体中文
TunnelX 是由 **MaxFan** 构建的免费 Windows 分流隧道客户端。它可以只让选定的应用、指定的域名/IP,或整个系统流量通过 VPN、V2Ray/Xray、OpenVPN 或 SOCKS5/HTTP Proxy,同时让本地或排除的目标继续走普通网络。应用支持波斯语和英语界面,可自动检测系统语言并正确处理 RTL/LTR 布局。
主要功能包括:L2TP/IPsec、V2Ray/Xray、SOCKS5/HTTP Proxy 和 OpenVPN Community 配置文件;按应用分流;域名/IP include 与 exclude 规则;Full Route 全局模式;本地 `127.0.0.1` 代理;公网出口 IP 显示;流量历史;DNS、IPv6 与泄漏防护诊断。
普通用户可以从 [GitHub Releases](https://github.com/MaxiFan/TunnelX/releases/latest) 下载最新 standalone 版本,以 Administrator 权限运行 TunnelX,创建连接配置,选择需要进入隧道的应用,然后连接。standalone 版本不需要单独安装 .NET Runtime。
可通过 Telegram 联系作者:[t.me/maxifaan](https://t.me/maxifaan)。
## Features
- App-based split tunneling for selected Windows processes
- Full-route mode for whole-system tunneling
- Windows L2TP/IPsec profile support
- Xray-core / sing-box based V2Ray workflows
- Dedicated SOCKS5/HTTP Proxy profiles with separate server, port, username, and password fields
- OpenVPN Community support via user-provided `.ovpn` files for app-based split tunneling
- Local SOCKS5 proxy for tools that need `127.0.0.1`
- DNS redirect, IPv6 blocking, leak guard, route diagnostics, and traffic history
- Persian-first Windows desktop UI
- Multiple profiles, duplicate/edit flows, server tests, public exit IP detection, and release update checks
- Persian and English desktop UI with automatic language detection, manual language switching, and correct RTL/LTR layout behavior
- Dynamic local port selection for V2Ray/Xray internals to reduce `2080/2081` binding conflicts
## Quick Start
1. Download the latest standalone release from GitHub Releases.
2. Run TunnelX as Administrator. Route management, WinDivert, and packet interception require elevated privileges.
3. Create a new profile or select an existing profile from the connection tab.
4. Choose the connection type: L2TP/IPsec, V2Ray/Xray, SOCKS5/HTTP Proxy, or OpenVPN.
5. Test the server, then enable the Windows apps that should use the tunnel.
6. Add include or exclude destinations when needed, connect, and check the traffic health cards for DNS, IPv6, leaks, and route status.
## Connection Types
### L2TP/IPsec
Enter the server address, username, password, and pre-shared key. TunnelX creates the Windows VPN connection and manages routes according to the selected-app policy or full-route mode.
### V2Ray / Xray
Paste a V2Ray/Xray link or JSON config into the profile. TunnelX uses sing-box for regular configs and switches to Xray-core for configs that require Xray-specific behavior such as `xhttp`.
### SOCKS5/HTTP Proxy
Use a SOCKS5/HTTP Proxy profile when you already have an external proxy endpoint. Enter the proxy server, port, and optional credentials. This is different from the local `127.0.0.1` SOCKS5 proxy, which is exposed after connection for tools that need a local proxy address.
## OpenVPN
TunnelX can run an installed **OpenVPN Community** `openvpn.exe` with a user-selected `.ovpn` profile, then apply its own split-tunneling policy so only selected apps and included destinations use the OpenVPN tunnel.
OpenVPN is not bundled with TunnelX. Install OpenVPN Community separately, select the `.ovpn` file in TunnelX, and enter the OpenVPN username/password if the server requires credentials. OpenVPN Connect alone is not enough for this mode because it manages routes and DNS through its own client.
For split-tunnel compatibility, TunnelX prepares the OpenVPN config by controlling pushed route and DNS behavior. If OpenVPN reconnects and changes the tunnel IP, gateway, interface, or remote endpoint, TunnelX restarts its packet routing with the new values.
## Routing Notes
Destination include/exclude rules match both the entered domain and its subdomains. For example, adding `githubusercontent.com` also covers `raw.githubusercontent.com` after DNS resolves it. Some HTTPS clients may still fail during certificate revocation checks if their OCSP/CRL hosts are not reachable through the selected route; add the downloader app or the relevant revocation domains to the include list when that happens.
- Excluded destinations stay direct even for selected apps.
- Included destinations use the tunnel even when the matching app is not selected.
- For Store/MSIX, WebView2, or multi-process apps, keep the app open and refresh the app list.
- In full-route mode, the whole system uses the tunnel; direct/exclude rules are still useful for keeping specific destinations on the normal route.
## Local Data and Logs
Profiles, selected apps, include/exclude destinations, connection history, and logs are stored on the user's Windows machine, typically under `%LOCALAPPDATA%\TunnelX` or next to the app depending on the feature. TunnelX does not intentionally send analytics or telemetry to the maintainer.
Logs can contain process names, hostnames, IP addresses, ports, and connection state. Before posting logs publicly, remove server credentials, UUIDs, private keys, private endpoints, and other sensitive data.
## Troubleshooting
- If connection fails, check Administrator privileges, firewall rules, config validity, proxy ports, and prerequisites for the selected connection type.
- If an app does not use the tunnel, enable it in the apps tab, keep it running, and refresh the app list.
- If only one site or domain should use the tunnel, add it to include destinations. If it should stay direct, add it to exclusions.
- If DNS or IPv6 status looks wrong, check the health cards after connection and reconnect once to rebuild routes and DNS rules.
- For OpenVPN connection delays, verify the `.ovpn` file, credentials, and OpenVPN Community installation.
## Screenshots
| Connection dashboard | Profile and server setup |
| --- | --- |
| ![TunnelX connection dashboard](docs/ScreenShots/Screenshot%202026-05-12%20115349.png) | ![TunnelX profile and server setup](docs/ScreenShots/Screenshot%202026-05-12%20115544.png) |
| ![TunnelX connection dashboard](docs/ScreenShots/en/connection-dashboard.png) | ![TunnelX app split tunneling](docs/ScreenShots/en/apps.png) |
| App split tunneling | Tunnel settings |
| Routing rules | Help and troubleshooting |
| --- | --- |
| ![TunnelX app split tunneling](docs/ScreenShots/Screenshot%202026-05-12%20115646.png) | ![TunnelX tunnel settings](docs/ScreenShots/Screenshot%202026-05-12%20115718.png) |
| ![TunnelX routing rules](docs/ScreenShots/en/routing-rules.png) | ![TunnelX help and troubleshooting](docs/ScreenShots/en/help.png) |
## Download
Public downloads should be attached to GitHub Releases after release validation is complete:
Public downloads are published through GitHub Releases:
[GitHub project](https://github.com/MaxiFan/TunnelX)
[Download the latest release](https://github.com/MaxiFan/TunnelX/releases/latest)
Release assets are built and uploaded by GitHub Actions. Each published standalone executable includes a `.sha256` checksum file, and the release notes link back to the workflow run that produced the artifact.
@@ -55,15 +132,21 @@ More release notes are in `docs/BUILD.md`. Future ideas are tracked in `docs/ROA
## License
TunnelX is licensed under **GPL-3.0-or-later**. Bundled third-party components keep their own licenses. See:
TunnelX is licensed under **GPL-3.0-or-later**. Commercial use is allowed under the terms of the GPL. Bundled third-party components keep their own licenses. See:
- `LICENSE`
- `THIRD_PARTY_NOTICES.md`
- `docs/LEGAL.md`
## Donate
## Support, Customization, and Donations
TunnelX is free. Donations are optional and help keep the project maintained.
TunnelX is free and open-source. Donations are optional and help keep the project maintained.
For direct contact, support requests, private customization, or paid development work, message MaxFan on Telegram: [t.me/maxifaan](https://t.me/maxifaan).
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.
+11 -1
View File
@@ -6,6 +6,16 @@ For public support after GitHub publication:
- Include the app version, Windows version, selected mode, and a sanitized log.
- Remove server credentials, UUIDs, private keys, and private endpoints from logs before posting.
Commercial or private support is not defined yet.
Private support, deployment help, custom builds, company-specific customization, or development of a similar application may be available as paid services. For paid work or customization requests, contact: `gallafan@gmail.com`.
## پشتیبانی فارسی
<div dir="rtl" align="right">
برای باگ‌های قابل تکرار و درخواست قابلیت، از <span dir="ltr">GitHub Issues</span> استفاده کنید و اطلاعات نسخه برنامه، نسخه ویندوز، حالت انتخاب‌شده و لاگ پاک‌سازی‌شده را قرار دهید.
پشتیبانی خصوصی، راه‌اندازی، بیلد اختصاصی، سفارشی‌سازی برای شرکت‌ها، یا توسعه برنامه‌ای مشابه می‌تواند به صورت خدمات پولی جداگانه انجام شود. برای درخواست کار پولی یا سفارشی‌سازی تماس بگیرید: <span dir="ltr">`gallafan@gmail.com`</span>
</div>
TunnelX is provided for free, as-is. The maintainer has no obligation to provide updates, bug fixes, compatibility updates, release builds, or individual support.
+2
View File
@@ -10,6 +10,7 @@ TunnelX includes or works with third-party software. These notices are part of t
| Wintun | https://www.wintun.net/ | Windows TUN driver DLL | WireGuard project license terms |
| CommunityToolkit.Mvvm | https://github.com/CommunityToolkit/dotnet | MVVM helpers | MIT |
| Microsoft .NET / WPF | https://github.com/dotnet | Runtime and desktop framework | MIT and Microsoft component licenses |
| Vazirmatn | https://github.com/rastikerdar/vazirmatn | Embedded Persian UI font | SIL Open Font License 1.1 |
## Redistributing Binaries
@@ -17,6 +18,7 @@ TunnelX includes or works with third-party software. These notices are part of t
- Keep `AppTunnel/NativeLibs/LICENSE` with WinDivert notices.
- The current repository bundles x64 native components only. Do not advertise 32-bit Windows support unless separate x86 binaries and tests are added.
- Do not remove upstream copyright or license notices.
- Keep the Vazirmatn SIL Open Font License notice when redistributing builds that embed the font.
- If native binaries are updated, refresh this table and the release checklist.
- If crypto, GeoIP, GeoSite, or core binaries are downloaded from upstream releases, record the exact version and source URL in the release notes.
+18
View File
@@ -2,6 +2,24 @@
TunnelX is free and open-source. Donations are optional.
## Paid Services
Private support, deployment help, custom builds, company-specific customization, or development of a similar application may be available as paid services. These services are separate from the GPL license and do not restrict the rights granted by the GPL-covered project.
For paid work or customization requests, contact: `gallafan@gmail.com`
## حمایت مالی و خدمات پولی
<div dir="rtl" align="right">
<span dir="ltr">TunnelX</span> آزاد و رایگان است و حمایت مالی کاملا اختیاری است.
پشتیبانی خصوصی، راه‌اندازی، بیلد اختصاصی، سفارشی‌سازی برای شرکت‌ها، یا توسعه برنامه‌ای مشابه می‌تواند به صورت خدمات پولی جداگانه انجام شود. این خدمات جدا از مجوز <span dir="ltr">GPL</span> هستند و حقوقی را که مجوز <span dir="ltr">GPL</span> به کاربران پروژه می‌دهد محدود نمی‌کنند.
برای درخواست کار پولی یا سفارشی‌سازی تماس بگیرید: <span dir="ltr">`gallafan@gmail.com`</span>
</div>
## PayPal
- PayPal email: `gallafan@gmail.com`
+5 -1
View File
@@ -4,7 +4,11 @@ TunnelX is published as free/open-source software by MaxFan.
## Project License
The project is marked as `GPL-3.0-or-later` to stay compatible with bundled GPL components, especially sing-box. If the release packaging changes and no GPL binary is bundled, the maintainer can revisit the license choice.
The project is marked as `GPL-3.0-or-later` to stay compatible with bundled GPL components, especially sing-box. Commercial use is allowed under the terms of the GPL. If the release packaging changes and no GPL binary is bundled, the maintainer can revisit the license choice.
## Paid Services
The maintainer may offer paid services separately, including private support, deployment help, custom builds, company-specific customization, or development of a similar application. These paid services are separate from the GPL license and do not restrict any rights granted by the GPL-covered source code and releases.
## Third-Party Components
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB