Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3145b9a01e | |||
| 04d8d52ba9 | |||
| bae98cf9e9 | |||
| 2ca962ddd6 | |||
| 0bc8992813 | |||
| b713074ee2 | |||
| d9a2a3b7d2 | |||
| dfe623dd99 | |||
| 8283b9d6d1 | |||
| a686edd027 | |||
| 83ea7560f4 | |||
| 4906a1e123 | |||
| 3b9a917645 | |||
| d877fe2c70 | |||
| b311473df4 |
@@ -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.
|
||||
@@ -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,28 +122,34 @@ jobs:
|
||||
throw "CHANGELOG.md must contain a '## Unreleased' section."
|
||||
}
|
||||
|
||||
$unreleasedMatch = [regex]::Match($changelog, '(?ms)^## Unreleased\s*(?<notes>.*?)(?=^##\s|\z)')
|
||||
$notes = $unreleasedMatch.Groups["notes"].Value.Trim()
|
||||
|
||||
if (-not $notes) {
|
||||
$previousTag = git describe --tags --abbrev=0 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $previousTag) {
|
||||
$subjects = git log "$previousTag..HEAD" --pretty=format:"- %s"
|
||||
}
|
||||
else {
|
||||
$subjects = git log --pretty=format:"- %s"
|
||||
}
|
||||
|
||||
$notes = ($subjects | Where-Object { $_ -and ($_ -notmatch '^- Prepare release v') }) -join "`n"
|
||||
if (-not $notes) {
|
||||
$notes = "- Maintenance release."
|
||||
}
|
||||
$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()
|
||||
|
||||
$releaseHeading = "## $version - $date"
|
||||
$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
|
||||
if (-not $notes) {
|
||||
$previousTag = git describe --tags --abbrev=0 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $previousTag) {
|
||||
$subjects = git log "$previousTag..HEAD" --pretty=format:"- %s"
|
||||
}
|
||||
else {
|
||||
$subjects = git log --pretty=format:"- %s"
|
||||
}
|
||||
|
||||
$notes = ($subjects | Where-Object { $_ -and ($_ -notmatch '^- Prepare release v') }) -join "`n"
|
||||
if (-not $notes) {
|
||||
$notes = "- Maintenance release."
|
||||
}
|
||||
}
|
||||
|
||||
$releaseHeading = "## $version - $date"
|
||||
$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
|
||||
git commit -m "Prepare release $tag"
|
||||
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,17 @@ jobs:
|
||||
$title = "TunnelX $tag"
|
||||
$runUrl = "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
$sha256 = "${{ steps.package.outputs.sha256 }}".ToUpperInvariant()
|
||||
$frameworkSha256 = "${{ steps.framework_package.outputs.sha256 }}".ToUpperInvariant()
|
||||
$artifactName = "${{ steps.meta.outputs.artifact_name }}"
|
||||
$frameworkArtifactName = "${{ steps.meta.outputs.framework_artifact_name }}"
|
||||
|
||||
$notes = Get-Content -Raw -LiteralPath "${{ steps.release_notes.outputs.notes_file }}"
|
||||
$downloadLines = @(
|
||||
"## Downloads",
|
||||
"",
|
||||
"- Recommended for most users: ``$artifactName``. This is the standalone self-contained EXE and does not require a separate .NET installation.",
|
||||
"- Smaller package for users who already have Microsoft .NET 8 Desktop Runtime (x64): ``$frameworkArtifactName``. Extract the ZIP and run ``TunnelX.exe`` as Administrator. The ZIP includes ``README.txt`` with the same requirement."
|
||||
)
|
||||
$provenanceLines = @(
|
||||
"<!-- release-provenance:start -->",
|
||||
"## Build provenance",
|
||||
@@ -232,9 +312,10 @@ jobs:
|
||||
"- Run: $runUrl",
|
||||
"- Commit: ``${{ steps.release_commit.outputs.sha }}``",
|
||||
"- SHA256: ``$sha256 $artifactName``",
|
||||
"- SHA256: ``$frameworkSha256 $frameworkArtifactName``",
|
||||
"<!-- release-provenance:end -->"
|
||||
)
|
||||
$notes = "$($notes.Trim())`n`n$($provenanceLines -join "`n")"
|
||||
$notes = "$($notes.Trim())`n`n$($downloadLines -join "`n")`n`n$($provenanceLines -join "`n")"
|
||||
|
||||
$notesFile = Join-Path $env:RUNNER_TEMP "final-release-notes.md"
|
||||
$notes | Set-Content -Encoding UTF8 -LiteralPath $notesFile
|
||||
@@ -243,6 +324,8 @@ jobs:
|
||||
"release", "create", $tag,
|
||||
"${{ steps.package.outputs.asset }}",
|
||||
"${{ steps.package.outputs.checksum }}",
|
||||
"${{ steps.framework_package.outputs.asset }}",
|
||||
"${{ steps.framework_package.outputs.checksum }}",
|
||||
"--title", $title,
|
||||
"--notes-file", $notesFile,
|
||||
"--latest"
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
|
||||
<NeutralLanguage>fa-IR</NeutralLanguage>
|
||||
<!-- Version Management -->
|
||||
<Version>1.2.25</Version>
|
||||
<AssemblyVersion>1.2.25.0</AssemblyVersion>
|
||||
<FileVersion>1.2.25.0</FileVersion>
|
||||
<Version>1.2.31</Version>
|
||||
<AssemblyVersion>1.2.31.0</AssemblyVersion>
|
||||
<FileVersion>1.2.31.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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), "خطا");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
xmlns:vm="clr-namespace:AppTunnel.ViewModels"
|
||||
xmlns:model="clr-namespace:AppTunnel.Models"
|
||||
Title="TunnelX — Split Traffic Per App"
|
||||
Width="620" Height="760"
|
||||
MinWidth="580" 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,13 +152,75 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 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="2" Content="🔍 جزئیات"
|
||||
<Button Grid.Column="3" Content="جزئیات"
|
||||
Click="OnShowLogClick"
|
||||
Background="#11FFFFFF"
|
||||
Background="#121212"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
BorderThickness="0"
|
||||
Padding="10,6"
|
||||
BorderBrush="#20FFFFFF"
|
||||
BorderThickness="1"
|
||||
Padding="11,6"
|
||||
Margin="0,0,8,0"
|
||||
FontSize="11"
|
||||
Cursor="Hand"
|
||||
@@ -129,8 +230,10 @@
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
CornerRadius="6" Padding="{TemplateBinding Padding}">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
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}"
|
||||
@@ -172,8 +275,8 @@
|
||||
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="وضعیت اتصال VPN و کنترل اتصال">
|
||||
<TabItem.Header>
|
||||
<StackPanel>
|
||||
<TextBlock Text="⚡" FontSize="13" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="اتصال VPN" FontSize="10" TextAlignment="Center"/>
|
||||
<TextBlock Text="⚡" FontSize="12" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="اتصال VPN" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
<views:ConnectionTabView/>
|
||||
@@ -181,8 +284,8 @@
|
||||
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="انتخاب برنامههایی که باید از تونل عبور کنند">
|
||||
<TabItem.Header>
|
||||
<StackPanel>
|
||||
<TextBlock Text="📱" FontSize="13" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="برنامهها" FontSize="10" TextAlignment="Center"/>
|
||||
<TextBlock Text="📱" FontSize="12" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="برنامهها" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
<views:AppsTabView/>
|
||||
@@ -191,8 +294,8 @@
|
||||
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="تنظیمات عمومی تونل و DNS">
|
||||
<TabItem.Header>
|
||||
<StackPanel>
|
||||
<TextBlock Text="⚙" FontSize="13" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="تنظیمات" FontSize="10" TextAlignment="Center"/>
|
||||
<TextBlock Text="⚙" FontSize="12" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="تنظیمات" FontSize="10" FontWeight="Medium" TextAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
<views:SettingsTabView/>
|
||||
@@ -202,58 +305,64 @@
|
||||
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="قوانین Include و Exclude مسیرها">
|
||||
<TabItem.Header>
|
||||
<StackPanel>
|
||||
<TextBlock Text="🧭" FontSize="13" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="قوانین مسیر" FontSize="10" TextAlignment="Center"/>
|
||||
<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="استثناها مقصدها را مستقیم نگه میدارند؛ لزومیها مقصدها را حتی بدون انتخاب برنامه از تونل عبور میدهند."
|
||||
TextWrapping="Wrap"
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<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="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="*"/>
|
||||
@@ -263,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"
|
||||
@@ -291,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"/>
|
||||
@@ -303,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}"/>
|
||||
@@ -339,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="*"/>
|
||||
@@ -359,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"
|
||||
@@ -387,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"/>
|
||||
@@ -399,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}"/>
|
||||
@@ -439,8 +548,8 @@
|
||||
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="نمایش ترافیک، تاریخچه و آمار اتصال">
|
||||
<TabItem.Header>
|
||||
<StackPanel>
|
||||
<TextBlock Text="📊" FontSize="13" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="ترافیک/تاریخچه" FontSize="9.5" TextAlignment="Center"/>
|
||||
<TextBlock Text="📊" FontSize="12" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="ترافیک/تاریخچه" FontSize="9.5" FontWeight="Medium" TextAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
|
||||
@@ -448,7 +557,7 @@
|
||||
<StackPanel HorizontalAlignment="Stretch" Margin="16,0">
|
||||
|
||||
<!-- Summary Card -->
|
||||
<Border Style="{StaticResource CardPanel}">
|
||||
<Border Style="{StaticResource CardPanel}" BorderBrush="#22E8803A">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
@@ -500,7 +609,7 @@
|
||||
</Border>
|
||||
|
||||
<!-- Split Tunnel Health -->
|
||||
<Border Style="{StaticResource CardPanel}">
|
||||
<Border Style="{StaticResource CardPanel}" BorderBrush="#2233C481">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
@@ -542,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="مصرف تونل به تفکیک برنامه"/>
|
||||
@@ -641,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">
|
||||
<TextBlock Text="{Binding AppVersion}"
|
||||
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>
|
||||
@@ -766,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"/>
|
||||
@@ -776,14 +867,16 @@
|
||||
|
||||
<!-- Log Header -->
|
||||
<Border Grid.Row="0" Padding="12,10" Background="#11FFFFFF">
|
||||
<Grid>
|
||||
<Grid FlowDirection="{Binding AppFlowDirection}">
|
||||
<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"/>
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<ComboBox x:Name="LogFilterCombo"
|
||||
Grid.Column="1"
|
||||
SelectedIndex="0"
|
||||
@@ -797,7 +890,9 @@
|
||||
<ComboBoxItem Content="DNS" Tag="Dns"/>
|
||||
<ComboBoxItem Content="Route" Tag="Route"/>
|
||||
</ComboBox>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal">
|
||||
<StackPanel Grid.Column="2"
|
||||
Orientation="Horizontal"
|
||||
FlowDirection="{Binding AppFlowDirection}">
|
||||
<Button Content="🗑" ToolTip="پاک کردن" Click="OnLogClearClick"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Padding="8,4" FontSize="12" Margin="0,0,4,0"/>
|
||||
@@ -822,6 +917,7 @@
|
||||
IsReadOnly="True"
|
||||
TextWrapping="Wrap"
|
||||
FlowDirection="LeftToRight"
|
||||
TextAlignment="Left"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -852,21 +948,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}"
|
||||
|
||||
@@ -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 در پسزمینه فعال است",
|
||||
"برای باز کردن پنجره، روی آیکن کنار ساعت دوبار کلیک کنید.",
|
||||
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("تونل فعال شد", GetConnectedTrayMessage(),
|
||||
ShowTrayNotification(LocalizationService.Instance.T("تونل فعال شد"), GetConnectedTrayMessage(),
|
||||
System.Windows.Forms.ToolTipIcon.Info);
|
||||
break;
|
||||
case ConnectionState.Disconnected:
|
||||
ShowTrayNotification("تونل خاموش شد", "ارتباط امن متوقف شده و ترافیک دیگر از TunnelX عبور نمیکند.",
|
||||
ShowTrayNotification(LocalizationService.Instance.T("تونل خاموش شد"), LocalizationService.Instance.T("ارتباط امن متوقف شده و ترافیک دیگر از TunnelX عبور نمیکند."),
|
||||
System.Windows.Forms.ToolTipIcon.Info);
|
||||
break;
|
||||
case ConnectionState.Error:
|
||||
ShowTrayNotification("اتصال برقرار نشد", GetErrorTrayMessage(),
|
||||
ShowTrayNotification(LocalizationService.Instance.T("اتصال برقرار نشد"), GetErrorTrayMessage(),
|
||||
System.Windows.Forms.ToolTipIcon.Warning);
|
||||
break;
|
||||
}
|
||||
@@ -187,8 +264,8 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
|
||||
_updateNotificationShown = true;
|
||||
ShowTrayNotification("نسخه جدید آماده است",
|
||||
"از منوی System Tray یا بخش بروزرسانی، صفحه دانلود TunnelX را باز کنید.",
|
||||
ShowTrayNotification(LocalizationService.Instance.T("نسخه جدید آماده است"),
|
||||
LocalizationService.Instance.T("از منوی System Tray یا بخش بروزرسانی، صفحه دانلود TunnelX را باز کنید."),
|
||||
System.Windows.Forms.ToolTipIcon.Info);
|
||||
}
|
||||
|
||||
@@ -196,20 +273,23 @@ public partial class MainWindow : Window
|
||||
{
|
||||
var profileName = _viewModel.SelectedProfileName;
|
||||
if (!string.IsNullOrWhiteSpace(profileName))
|
||||
return $"پروفایل «{profileName}» فعال است و ترافیک انتخابشده از تونل عبور میکند.";
|
||||
return LocalizationService.Instance.Format("پروفایل «{0}» فعال است و ترافیک انتخابشده از تونل عبور میکند.", profileName);
|
||||
|
||||
return "ترافیک انتخابشده از TunnelX عبور میکند.";
|
||||
return LocalizationService.Instance.T("ترافیک انتخابشده از TunnelX عبور میکند.");
|
||||
}
|
||||
|
||||
private string GetErrorTrayMessage()
|
||||
{
|
||||
var status = _viewModel.StatusText?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(status) || status == "خطا")
|
||||
return "جزئیات خطا را در پنجره برنامه یا لاگها بررسی کنید.";
|
||||
if (string.IsNullOrWhiteSpace(status) ||
|
||||
status == LocalizationService.Instance.T("خطا") ||
|
||||
status.Equals("Error", StringComparison.OrdinalIgnoreCase))
|
||||
return LocalizationService.Instance.T("جزئیات خطا را در پنجره برنامه یا لاگها بررسی کنید.");
|
||||
|
||||
return status.StartsWith("خطا", StringComparison.Ordinal)
|
||||
return status.StartsWith(LocalizationService.Instance.T("خطا"), StringComparison.OrdinalIgnoreCase) ||
|
||||
status.StartsWith("Error", StringComparison.OrdinalIgnoreCase)
|
||||
? status
|
||||
: $"جزئیات: {status}";
|
||||
: LocalizationService.Instance.Format("جزئیات: {0}", status);
|
||||
}
|
||||
|
||||
private void ShowTrayNotification(string title, string message, System.Windows.Forms.ToolTipIcon icon)
|
||||
@@ -221,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();
|
||||
@@ -231,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(() =>
|
||||
{
|
||||
@@ -359,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)
|
||||
{
|
||||
@@ -379,9 +461,76 @@ public partial class MainWindow : Window
|
||||
Logger.LogAdded += OnLogAdded;
|
||||
}
|
||||
|
||||
Width += LogPanelWidth;
|
||||
LogPanel.Visibility = Visibility.Visible;
|
||||
LogTextBox.ScrollToEnd();
|
||||
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)
|
||||
@@ -398,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)
|
||||
@@ -416,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)
|
||||
@@ -463,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;
|
||||
|
||||
@@ -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 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,6 +139,60 @@ public class ConnectionProfile : INotifyPropertyChanged
|
||||
set => SetField(ref _v2RayConfig, value);
|
||||
}
|
||||
|
||||
public string OpenVpnConfig
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -138,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,
|
||||
@@ -147,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
|
||||
@@ -164,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="6,6,6,4">
|
||||
Padding="4,4,4,3">
|
||||
<TabPanel IsItemsHost="True" HorizontalAlignment="Center"/>
|
||||
</Border>
|
||||
<!-- Tab content -->
|
||||
@@ -280,13 +311,17 @@
|
||||
</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="10"/>
|
||||
<Setter Property="FontWeight" Value="Normal"/>
|
||||
<Setter Property="Padding" Value="6,6"/>
|
||||
<Setter Property="Width" Value="104"/>
|
||||
<Setter Property="MinHeight" Value="50"/>
|
||||
<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>
|
||||
@@ -306,19 +341,19 @@
|
||||
<ContentPresenter Grid.Row="0" ContentSource="Header"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,0,5"/>
|
||||
Margin="0,0,0,3"/>
|
||||
<!-- Bottom indicator line -->
|
||||
<Border x:Name="indicator" Grid.Row="1"
|
||||
Height="3" CornerRadius="1.5"
|
||||
Height="2" CornerRadius="1"
|
||||
Background="Transparent"
|
||||
HorizontalAlignment="Stretch"
|
||||
Margin="10,0"/>
|
||||
Margin="8,0"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<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}"/>
|
||||
@@ -364,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"/>
|
||||
@@ -389,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>
|
||||
@@ -419,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}}"
|
||||
@@ -471,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">
|
||||
|
||||
@@ -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¤cy_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,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
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 latest error or warning",
|
||||
["کپی کردن"] = "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."
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,8 @@ public class ProfileService
|
||||
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>
|
||||
@@ -118,14 +120,26 @@ public class ProfileService
|
||||
PreSharedKey = DecryptString(s.EncryptedPsk),
|
||||
TunnelType = s.TunnelType,
|
||||
V2RayConfig = s.V2RayConfig,
|
||||
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>();
|
||||
}
|
||||
}
|
||||
@@ -149,6 +163,15 @@ public class ProfileService
|
||||
EncryptedPsk = EncryptString(p.PreSharedKey),
|
||||
TunnelType = p.TunnelType,
|
||||
V2RayConfig = p.V2RayConfig,
|
||||
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,
|
||||
@@ -206,6 +229,17 @@ 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,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.
|
||||
@@ -49,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;
|
||||
@@ -295,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;
|
||||
@@ -310,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
|
||||
@@ -389,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.");
|
||||
@@ -469,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):
|
||||
@@ -740,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;
|
||||
|
||||
@@ -749,6 +760,8 @@ public partial class TrafficRouterService : IDisposable
|
||||
_cts?.Cancel();
|
||||
_statsTimer?.Dispose();
|
||||
_statsTimer = null;
|
||||
_destinationRefreshTimer?.Dispose();
|
||||
_destinationRefreshTimer = null;
|
||||
|
||||
try { _mixedProxy?.Stop(); } catch { }
|
||||
_mixedProxy = null;
|
||||
@@ -852,7 +865,8 @@ public partial class TrafficRouterService : IDisposable
|
||||
Interlocked.Exchange(ref _statLeakBlockedRecovered, 0);
|
||||
Interlocked.Exchange(ref _statLeakBlockedSuppressed, 0);
|
||||
Interlocked.Exchange(ref _policyTransitionGraceUntilTick, 0);
|
||||
ResetLiveTrafficCounters();
|
||||
if (resetCounters)
|
||||
ResetLiveTrafficCounters();
|
||||
Interlocked.Exchange(ref _statRoutesAdded, 0);
|
||||
Interlocked.Exchange(ref _statRoutesFailed, 0);
|
||||
Interlocked.Exchange(ref _statFlowEstablished, 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]);
|
||||
}
|
||||
@@ -11,10 +11,14 @@ public partial class TrafficRouterService
|
||||
/// </summary>
|
||||
public void SetExcludedDestinations(IEnumerable<string> entries)
|
||||
{
|
||||
_excludedIps.Clear();
|
||||
_excludedEntries.Clear();
|
||||
foreach (var entry in entries)
|
||||
AddExcludedDestination(entry);
|
||||
lock (_destinationListLock)
|
||||
{
|
||||
_excludedIps.Clear();
|
||||
_excludedEntries.Clear();
|
||||
_excludedDomainRules.Clear();
|
||||
foreach (var entry in entries)
|
||||
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);
|
||||
}
|
||||
}
|
||||
var normalizedSuffix = originalEntry.Equals(entry, StringComparison.OrdinalIgnoreCase)
|
||||
? ""
|
||||
: $" (from '{originalEntry}')";
|
||||
Logger.Info($"[EXCLUDE] Added domain '{entry}'{normalizedSuffix} → {ips.Count} IPs");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"[EXCLUDE] Could not resolve '{entry}': {ex.Message}");
|
||||
}
|
||||
_excludedDomainRules[entry] = true;
|
||||
var normalizedSuffix = originalEntry.Equals(entry, StringComparison.OrdinalIgnoreCase)
|
||||
? ""
|
||||
: $" (from '{originalEntry}')";
|
||||
Logger.Info($"[EXCLUDE] Added domain rule '*.{entry}'{normalizedSuffix} → {ips.Count} current IPs");
|
||||
}
|
||||
|
||||
foreach (var nbo in ips)
|
||||
{
|
||||
_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);
|
||||
if (_excludedEntries.TryRemove(entry, out var ips))
|
||||
lock (_destinationListLock)
|
||||
{
|
||||
foreach (var nbo in ips)
|
||||
_excludedIps.TryRemove(nbo, out _);
|
||||
Logger.Info($"[EXCLUDE] Removed '{entry}'");
|
||||
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;
|
||||
|
||||
@@ -11,10 +13,14 @@ public partial class TrafficRouterService
|
||||
/// </summary>
|
||||
public void SetIncludedDestinations(IEnumerable<string> entries)
|
||||
{
|
||||
_includedIps.Clear();
|
||||
_includedEntries.Clear();
|
||||
foreach (var entry in entries)
|
||||
AddIncludedDestination(entry);
|
||||
lock (_destinationListLock)
|
||||
{
|
||||
_includedIps.Clear();
|
||||
_includedEntries.Clear();
|
||||
_includedDomainRules.Clear();
|
||||
foreach (var entry in entries)
|
||||
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;
|
||||
}
|
||||
}
|
||||
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}");
|
||||
}
|
||||
_includedDomainRules[entry] = true;
|
||||
var normalizedSuffix = originalEntry.Equals(entry, StringComparison.OrdinalIgnoreCase)
|
||||
? ""
|
||||
: $" (from '{originalEntry}')";
|
||||
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);
|
||||
if (_includedEntries.TryRemove(entry, out var ips))
|
||||
lock (_destinationListLock)
|
||||
{
|
||||
foreach (var nbo in ips)
|
||||
_includedIps.TryRemove(nbo, out _);
|
||||
Logger.Info($"[INCLUDE] Removed '{entry}'");
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -469,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
|
||||
@@ -846,6 +858,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
|
||||
|
||||
@@ -18,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.V2Ray => TunnelProviderFactory.Create(config.V2RayConfig),
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,6 +56,29 @@ public partial class MainViewModel
|
||||
StatusText = "کانفیگ V2Ray را وارد کنید";
|
||||
return;
|
||||
}
|
||||
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;
|
||||
@@ -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
|
||||
{
|
||||
@@ -85,6 +116,14 @@ public partial class MainViewModel
|
||||
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
|
||||
@@ -102,6 +141,7 @@ public partial class MainViewModel
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await CleanupAfterFailedConnectionAsync();
|
||||
ConnectionState = ConnectionState.Disconnected;
|
||||
StatusText = "اتصال لغو شد";
|
||||
IsBusy = false;
|
||||
@@ -111,49 +151,104 @@ 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
|
||||
var enabledApps = TunnelApps.Where(a => a.IsEnabled).ToList();
|
||||
_trafficRouter.ClearTargetApps();
|
||||
foreach (var app in enabledApps)
|
||||
{
|
||||
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 = 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
|
||||
StartTrafficRouterForCurrentStatus(resetAppCounters: true);
|
||||
|
||||
_vpnHealthCheckCounter = 0;
|
||||
_timer.Start();
|
||||
|
||||
var exitIpProxyPort = _vpnService.Status.SingBoxMixedPort > 0
|
||||
? _vpnService.Status.SingBoxMixedPort
|
||||
: _trafficRouter.Socks5Port;
|
||||
_ = RefreshExitIpAsync(exitIpProxyPort);
|
||||
_ = RefreshGitHubInstallCountAsync(exitIpProxyPort);
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
ConnectionState = ConnectionState.Error;
|
||||
StatusText = _vpnService.Status.Message;
|
||||
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);
|
||||
}
|
||||
|
||||
_trafficRouter.SetExcludedDestinations(ExcludedDestinations);
|
||||
_trafficRouter.SetIncludedDestinations(IncludedDestinations);
|
||||
_trafficRouter.Socks5Port = MixedProxyPort;
|
||||
_trafficRouter.EnableDnsOptimization = IsDnsOptimizationEnabled;
|
||||
_trafficRouter.EnableGameMode = IsGameModeEnabled;
|
||||
|
||||
_trafficRouter.Start(
|
||||
_vpnService.Status.VpnInterfaceIndex,
|
||||
_vpnService.Status.VpnLocalIp,
|
||||
_vpnService.Status.VpnServerIp,
|
||||
_vpnService.Status.VpnGatewayIp,
|
||||
_vpnService.Status.VpnServerPort,
|
||||
resetCounters: resetAppCounters);
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
@@ -173,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));
|
||||
@@ -223,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));
|
||||
@@ -255,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)
|
||||
{
|
||||
@@ -299,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(() =>
|
||||
@@ -340,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
|
||||
@@ -372,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))
|
||||
{
|
||||
@@ -381,8 +722,8 @@ public partial class MainViewModel
|
||||
|
||||
using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var ms2 = await MeasureEndpointLatencyAsync(endpoint, cts2.Token);
|
||||
var mode = endpoint.UseTls ? "TLS handshake" : "TCP connect";
|
||||
ServerPingResult = $"{mode} {endpoint.Server}:{endpoint.Port} {ms2} ms";
|
||||
var mode = endpoint.UseTls ? "TLS" : "TCP";
|
||||
ServerPingResult = $"{mode} {ms2} ms";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -390,7 +731,7 @@ public partial class MainViewModel
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ServerPingResult = $"خطا: {ex.Message}";
|
||||
ServerPingResult = LocalizationService.Instance.Format("خطا: {0}", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -398,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)
|
||||
{
|
||||
@@ -423,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))
|
||||
@@ -527,7 +1038,7 @@ public partial class MainViewModel
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"پارس کانفیگ ناموفق بود: {ex.Message}";
|
||||
error = LocalizationService.Instance.Format("پارس کانفیگ ناموفق بود: {0}", ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -693,7 +1204,7 @@ public partial class MainViewModel
|
||||
{
|
||||
IsPinging = false;
|
||||
if (sent > 0)
|
||||
PingResult += " [پایان]";
|
||||
PingResult += LocalizationService.Instance.T(" [پایان]");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -25,6 +26,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
private CancellationTokenSource? _connectionCts;
|
||||
private DateTime _connectionStartTime;
|
||||
private ProfileService.AppSettings _appSettings = new();
|
||||
private bool _isLoadingProfile;
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
@@ -35,9 +37,9 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
Application.Current?.Dispatcher.Invoke(() =>
|
||||
StatusText = $"خطا: {t.Exception?.InnerException?.Message}");
|
||||
StatusText = LocalizationService.Instance.Format("خطا: {0}", t.Exception?.InnerException?.Message));
|
||||
}, TaskScheduler.Default);
|
||||
}, _ => !IsBusy);
|
||||
}, _ => !IsBusy || ConnectionState == ConnectionState.Connecting);
|
||||
AddAppCommand = new RelayCommand(_ => AddCustomApp());
|
||||
RemoveAppCommand = new RelayCommand(RemoveApp);
|
||||
ToggleAppCommand = new RelayCommand(ToggleApp);
|
||||
@@ -45,8 +47,10 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
|
||||
// Profile commands
|
||||
NewProfileCommand = new RelayCommand(_ => CreateNewProfile(), _ => !IsConnected);
|
||||
DeleteProfileCommand = new RelayCommand(_ => DeleteCurrentProfile(), _ => !IsConnected && Profiles.Count > 1);
|
||||
DuplicateProfileCommand = new RelayCommand(_ => DuplicateCurrentProfile(), _ => !IsConnected);
|
||||
DeleteProfileCommand = new RelayCommand(DeleteCurrentProfile, _ => !IsConnected && Profiles.Count > 1);
|
||||
DuplicateProfileCommand = new RelayCommand(DuplicateCurrentProfile, _ => !IsConnected);
|
||||
EditProfileCommand = new RelayCommand(EditProfile, _ => !IsConnected);
|
||||
SelectProfileCommand = new RelayCommand(SelectProfile, _ => !IsConnected);
|
||||
|
||||
// History command
|
||||
ClearHistoryCommand = new RelayCommand(_ => ClearHistory());
|
||||
@@ -61,14 +65,21 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
|
||||
// Ping command
|
||||
TogglePingCommand = new RelayCommand(_ => TogglePing(), _ => IsConnected);
|
||||
TestConnectedServerPingCommand = new RelayCommand(_ => _ = TestConnectedServerPingAsync(), _ => IsConnected && !IsTestingConnectedServerPing);
|
||||
TestServerPingCommand = new RelayCommand(_ => _ = TestServerPingAsync(), _ => !IsConnected && !IsTestingServerPing);
|
||||
PasteConfigCommand = new RelayCommand(_ => PasteConfigFromClipboard(), _ => !IsConnected && CurrentTunnelType == TunnelType.V2Ray);
|
||||
ClearConfigCommand = new RelayCommand(_ => SelectedV2RayConfig = "", _ => !IsConnected && CurrentTunnelType == TunnelType.V2Ray);
|
||||
PasteConfigCommand = new RelayCommand(_ => PasteConfigFromClipboard(), _ => !IsConnected && (CurrentTunnelType == TunnelType.V2Ray || CurrentTunnelType == TunnelType.OpenVpn));
|
||||
ClearConfigCommand = new RelayCommand(_ => ClearCurrentConfig(), _ => !IsConnected && (CurrentTunnelType == TunnelType.V2Ray || CurrentTunnelType == TunnelType.OpenVpn));
|
||||
BrowseOpenVpnConfigCommand = new RelayCommand(_ => BrowseForOpenVpnConfig(), _ => !IsConnected && CurrentTunnelType == TunnelType.OpenVpn);
|
||||
OpenOpenVpnCommunityDownloadCommand = new RelayCommand(_ => OpenExternalLink(OpenVpnCommunityDownloadUrl));
|
||||
OpenGitHubCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.GitHubUrl));
|
||||
OpenDonateCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.PayPalDonateUrl));
|
||||
OpenAdRequestCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.TelegramContactUrl));
|
||||
CopyDonationInfoCommand = new RelayCommand(_ => CopyDonationInfoToClipboard());
|
||||
CheckForUpdatesCommand = new RelayCommand(_ => _ = CheckForUpdatesAsync(false), _ => !IsCheckingForUpdates);
|
||||
OpenLatestReleaseCommand = new RelayCommand(_ => OpenExternalLink(LatestReleaseUrl), _ => !string.IsNullOrWhiteSpace(LatestReleaseUrl));
|
||||
ToggleLanguageCommand = new RelayCommand(_ => ToggleLanguage());
|
||||
|
||||
LocalizationService.Instance.LanguageChanged += (_, _) => OnLanguageChanged();
|
||||
|
||||
_trafficRouter.TrafficUpdated += OnTrafficUpdated;
|
||||
|
||||
@@ -93,6 +104,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
LoadIncludes();
|
||||
LoadHistory();
|
||||
LoadAppSettings();
|
||||
RefreshOpenVpnInstallStatus();
|
||||
_ = CheckForUpdatesAsync(true);
|
||||
|
||||
// Auto-connect to last active profile if enabled
|
||||
@@ -106,7 +118,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
Application.Current?.Dispatcher.Invoke(() =>
|
||||
StatusText = $"خطای اتصال خودکار: {t.Exception?.InnerException?.Message}");
|
||||
StatusText = LocalizationService.Instance.Format("خطای اتصال خودکار: {0}", t.Exception?.InnerException?.Message));
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
@@ -118,7 +130,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
public string ServerAddress
|
||||
{
|
||||
get => _serverAddress;
|
||||
set { _serverAddress = value; OnPropertyChanged(); UpdateConfigDiagnostics(); SaveCurrentState(); }
|
||||
set { _serverAddress = value; OnPropertyChanged(); UpdateConfigDiagnostics(); RaiseProfileCardChanged(); SaveCurrentState(); }
|
||||
}
|
||||
|
||||
private string _username = "";
|
||||
@@ -189,7 +201,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
private string _mixedProxyPortStatusText = "";
|
||||
public string MixedProxyPortStatusText
|
||||
{
|
||||
get => _mixedProxyPortStatusText;
|
||||
get => LocalizationService.Instance.T(_mixedProxyPortStatusText);
|
||||
set { _mixedProxyPortStatusText = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
@@ -236,8 +248,8 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
}
|
||||
|
||||
public string GameModeStatusText => IsGameModeEnabled
|
||||
? "Game Mode فعال است: Route نگهداری طولانیتر، DNS سریعتر و DSCP برای بستههای بازی اعمال میشود."
|
||||
: "Game Mode غیرفعال است: حالت متعادل برای مصرف عمومی.";
|
||||
? LocalizationService.Instance.T("Game Mode فعال است: Route نگهداری طولانیتر، DNS سریعتر و DSCP برای بستههای بازی اعمال میشود.")
|
||||
: LocalizationService.Instance.T("Game Mode غیرفعال است: حالت متعادل برای مصرف عمومی.");
|
||||
|
||||
private bool _startWithWindows;
|
||||
public bool StartWithWindows
|
||||
@@ -279,11 +291,30 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
public string LanguageToggleText => LocalizationService.Instance.ToggleLanguageText;
|
||||
public bool AppIsRightToLeft => LocalizationService.Instance.IsRightToLeft;
|
||||
public string AppTitleText => LocalizationService.Instance.IsRightToLeft ? "تانلکس" : "TunnelX";
|
||||
public string AppTitleAccentText => LocalizationService.Instance.IsRightToLeft ? "س" : "X";
|
||||
public System.Windows.FlowDirection AppTitleFlowDirection => LocalizationService.Instance.FlowDirection;
|
||||
public System.Windows.HorizontalAlignment AppTitleAccentAlignment => LocalizationService.Instance.IsRightToLeft
|
||||
? System.Windows.HorizontalAlignment.Left
|
||||
: System.Windows.HorizontalAlignment.Right;
|
||||
public System.Windows.FlowDirection AppFlowDirection => LocalizationService.Instance.FlowDirection;
|
||||
public System.Windows.TextAlignment AppTextAlignment => LocalizationService.Instance.TextAlignment;
|
||||
public System.Windows.HorizontalAlignment AppStartHorizontalAlignment => LocalizationService.Instance.StartHorizontalAlignment;
|
||||
public System.Windows.HorizontalAlignment AppEndHorizontalAlignment => LocalizationService.Instance.EndHorizontalAlignment;
|
||||
|
||||
private bool _isBusy;
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
set { _isBusy = value; OnPropertyChanged(); OnPropertyChanged(nameof(ConnectButtonText)); }
|
||||
set
|
||||
{
|
||||
_isBusy = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(ConnectButtonText));
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
|
||||
private ConnectionState _connectionState = ConnectionState.Disconnected;
|
||||
@@ -296,13 +327,52 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(IsConnected));
|
||||
OnPropertyChanged(nameof(ConnectButtonText));
|
||||
OnPropertyChanged(nameof(ConnectButtonToolTip));
|
||||
OnPropertyChanged(nameof(StatusColor));
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
OnPropertyChanged(nameof(IsOpenVpnConnectionPending));
|
||||
RaiseHealthStatusChanged();
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsConnected => _connectionState == ConnectionState.Connected;
|
||||
public bool IsOpenVpnConnectionPending =>
|
||||
_connectionState == ConnectionState.Connecting && CurrentTunnelType == TunnelType.OpenVpn;
|
||||
public const string OpenVpnCommunityDownloadUrl = "https://openvpn.net/community-downloads/";
|
||||
|
||||
private bool _isOpenVpnCommunityInstalled;
|
||||
public bool IsOpenVpnCommunityInstalled
|
||||
{
|
||||
get => _isOpenVpnCommunityInstalled;
|
||||
private set
|
||||
{
|
||||
if (_isOpenVpnCommunityInstalled == value) return;
|
||||
_isOpenVpnCommunityInstalled = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(OpenVpnPrerequisiteText));
|
||||
OnPropertyChanged(nameof(OpenVpnPrerequisiteColor));
|
||||
}
|
||||
}
|
||||
|
||||
private string _openVpnDetectedPath = "";
|
||||
public string OpenVpnDetectedPath
|
||||
{
|
||||
get => _openVpnDetectedPath;
|
||||
private set
|
||||
{
|
||||
if (_openVpnDetectedPath == value) return;
|
||||
_openVpnDetectedPath = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(OpenVpnPrerequisiteText));
|
||||
}
|
||||
}
|
||||
|
||||
public string OpenVpnPrerequisiteText => IsOpenVpnCommunityInstalled
|
||||
? LocalizationService.Instance.Format("پیشنیاز آماده است: نسخه Community اوپنویپیان پیدا شد: {0}", OpenVpnDetectedPath)
|
||||
: LocalizationService.Instance.T("اخطار: نسخه Community اوپنویپیان نصب نیست. برای استفاده از اسپلیتتانلینگ با این نوع اتصال، ابتدا آن را از لینک رسمی نصب کنید.");
|
||||
|
||||
public string OpenVpnPrerequisiteColor => IsOpenVpnCommunityInstalled ? "#6CCB5F" : "#E0A020";
|
||||
|
||||
/// <summary>App version read from a single app-wide source.</summary>
|
||||
public string AppVersion => AppInfo.VersionText;
|
||||
@@ -310,8 +380,18 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
public string AppCreatorText => AppInfo.CreatorText;
|
||||
public string AppGitHubUrl => AppInfo.GitHubUrl;
|
||||
public string AppLicenseText => AppInfo.LicenseName;
|
||||
public string DonatePayPalText => $"پیپل: {AppInfo.PayPalEmail}";
|
||||
public string CryptoDonationText => AppInfo.CryptoDonationText;
|
||||
public string AppLicenseDisplayText => LocalizationService.Instance.Format("لایسنس: {0}", AppInfo.LicenseName);
|
||||
public string AdPlaceholderTitleText => LocalizationService.Instance.T("محل تبلیغات شما");
|
||||
public string AdRequestButtonText => LocalizationService.Instance.T("درخواست تبلیغ");
|
||||
public string AdAudienceText => _githubInstallCount.HasValue
|
||||
? LocalizationService.Instance.Format("تبلیغ شما میتواند در معرض دید کاربران TunnelX با بیش از {0} نصب از GitHub باشد.", GitHubInstallCountDisplay)
|
||||
: "";
|
||||
public string DonatePayPalText => LocalizationService.Instance.IsRightToLeft
|
||||
? $"پیپل: {AppInfo.PayPalEmail}"
|
||||
: $"PayPal: {AppInfo.PayPalEmail}";
|
||||
public string CryptoDonationText => LocalizationService.Instance.IsRightToLeft
|
||||
? AppInfo.CryptoDonationText
|
||||
: AppInfo.CryptoDonationTextEn;
|
||||
|
||||
private bool _isCheckingForUpdates;
|
||||
public bool IsCheckingForUpdates
|
||||
@@ -342,7 +422,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
private string _updateStatusText = "برای بررسی نسخه جدید، دکمه بررسی بروزرسانی را بزنید.";
|
||||
public string UpdateStatusText
|
||||
{
|
||||
get => _updateStatusText;
|
||||
get => LocalizationService.Instance.T(_updateStatusText);
|
||||
set
|
||||
{
|
||||
if (_updateStatusText == value) return;
|
||||
@@ -364,16 +444,41 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
public string UpdateButtonText => IsCheckingForUpdates ? "در حال بررسی..." : "بررسی بروزرسانی";
|
||||
private long? _githubInstallCount;
|
||||
private int _githubInstallCountRequestId;
|
||||
|
||||
public bool HasGitHubInstallCount => _githubInstallCount.HasValue;
|
||||
|
||||
public string GitHubInstallCountText => _githubInstallCount.HasValue
|
||||
? LocalizationService.Instance.Format(
|
||||
"تعداد نصب این برنامه از گیت هاب: {0}",
|
||||
GitHubInstallCountDisplay)
|
||||
: "";
|
||||
|
||||
private string GitHubInstallCountDisplay =>
|
||||
(_githubInstallCount ?? 0).ToString("N0", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
public string UpdateButtonText => IsCheckingForUpdates
|
||||
? LocalizationService.Instance.T("در حال بررسی...")
|
||||
: LocalizationService.Instance.T("بررسی بروزرسانی");
|
||||
|
||||
public string ConnectButtonText => _connectionState switch
|
||||
{
|
||||
ConnectionState.Disconnected => "🔌 اتصال",
|
||||
ConnectionState.Connecting => "❌ لغو اتصال",
|
||||
ConnectionState.Connected => "🔴 قطع اتصال",
|
||||
ConnectionState.Disconnecting => "⏳ در حال قطع...",
|
||||
ConnectionState.Error => "🔌 اتصال مجدد",
|
||||
_ => "اتصال"
|
||||
ConnectionState.Disconnected => LocalizationService.Instance.T("🔌 اتصال"),
|
||||
ConnectionState.Connecting => LocalizationService.Instance.T("❌ لغو اتصال"),
|
||||
ConnectionState.Connected => LocalizationService.Instance.T("🔴 قطع اتصال"),
|
||||
ConnectionState.Disconnecting => LocalizationService.Instance.T("⏳ در حال قطع..."),
|
||||
ConnectionState.Error => LocalizationService.Instance.T("🔌 اتصال مجدد"),
|
||||
_ => LocalizationService.Instance.T("اتصال")
|
||||
};
|
||||
|
||||
public string ConnectButtonToolTip => _connectionState switch
|
||||
{
|
||||
ConnectionState.Connecting => LocalizationService.Instance.T("لغو تلاش اتصال"),
|
||||
ConnectionState.Connected => LocalizationService.Instance.T("قطع اتصال فعلی"),
|
||||
ConnectionState.Disconnecting => LocalizationService.Instance.T("در حال قطع اتصال..."),
|
||||
ConnectionState.Error => LocalizationService.Instance.T("اتصال مجدد"),
|
||||
_ => LocalizationService.Instance.T("شروع اتصال با پروفایل انتخابشده")
|
||||
};
|
||||
|
||||
public string StatusColor => _connectionState switch
|
||||
@@ -387,7 +492,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
private string _statusText = "آماده اتصال";
|
||||
public string StatusText
|
||||
{
|
||||
get => _statusText;
|
||||
get => LocalizationService.Instance.T(_statusText);
|
||||
set { _statusText = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
@@ -405,9 +510,16 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
if (_currentTunnelType == value) return;
|
||||
_currentTunnelType = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(IsOpenVpnConnectionPending));
|
||||
OnPropertyChanged(nameof(ConnectedBadgeText));
|
||||
OnPropertyChanged(nameof(ConnectionIpLabel));
|
||||
OnPropertyChanged(nameof(ConnectedServerPingButtonText));
|
||||
if (_selectedProfile != null)
|
||||
_selectedProfile.TunnelType = value;
|
||||
if (value == TunnelType.OpenVpn)
|
||||
RefreshOpenVpnInstallStatus();
|
||||
UpdateConfigDiagnostics();
|
||||
RaiseProfileCardChanged();
|
||||
RaiseHealthStatusChanged();
|
||||
SaveCurrentState();
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
@@ -427,31 +539,199 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
OnPropertyChanged();
|
||||
TryAutoNameProfileFromConfig(value);
|
||||
UpdateConfigDiagnostics();
|
||||
RaiseProfileCardChanged();
|
||||
RaiseHealthStatusChanged();
|
||||
SaveCurrentState();
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
|
||||
private string _selectedOpenVpnConfig = "";
|
||||
public string SelectedOpenVpnConfig
|
||||
{
|
||||
get => _selectedOpenVpnConfig;
|
||||
set
|
||||
{
|
||||
if (_selectedOpenVpnConfig == value) return;
|
||||
_selectedOpenVpnConfig = value;
|
||||
if (_selectedProfile != null)
|
||||
_selectedProfile.OpenVpnConfig = value;
|
||||
OnPropertyChanged();
|
||||
UpdateConfigDiagnostics();
|
||||
RaiseProfileCardChanged();
|
||||
RaiseHealthStatusChanged();
|
||||
SaveCurrentState();
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
|
||||
private string _selectedOpenVpnConfigPath = "";
|
||||
public string SelectedOpenVpnConfigPath
|
||||
{
|
||||
get => _selectedOpenVpnConfigPath;
|
||||
set
|
||||
{
|
||||
if (_selectedOpenVpnConfigPath == value) return;
|
||||
_selectedOpenVpnConfigPath = value;
|
||||
if (_selectedProfile != null)
|
||||
_selectedProfile.OpenVpnConfigPath = value;
|
||||
OnPropertyChanged();
|
||||
RaiseProfileCardChanged();
|
||||
SaveCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
private string _openVpnUsername = "";
|
||||
public string OpenVpnUsername
|
||||
{
|
||||
get => _openVpnUsername;
|
||||
set
|
||||
{
|
||||
if (_openVpnUsername == value) return;
|
||||
_openVpnUsername = value;
|
||||
if (_selectedProfile != null)
|
||||
_selectedProfile.OpenVpnUsername = value;
|
||||
OnPropertyChanged();
|
||||
SaveCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
private string _openVpnPassword = "";
|
||||
public string OpenVpnPassword
|
||||
{
|
||||
get => _openVpnPassword;
|
||||
set
|
||||
{
|
||||
if (_openVpnPassword == value) return;
|
||||
_openVpnPassword = value;
|
||||
if (_selectedProfile != null)
|
||||
_selectedProfile.OpenVpnPassword = value;
|
||||
OnPropertyChanged();
|
||||
SaveCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
private ProxyProtocol _proxyProtocol = ProxyProtocol.Socks5;
|
||||
public ProxyProtocol ProxyProtocol
|
||||
{
|
||||
get => _proxyProtocol;
|
||||
set
|
||||
{
|
||||
if (_proxyProtocol == value) return;
|
||||
_proxyProtocol = value;
|
||||
if (_selectedProfile != null)
|
||||
_selectedProfile.ProxyProtocol = value;
|
||||
OnPropertyChanged();
|
||||
UpdateConfigDiagnostics();
|
||||
RaiseProfileCardChanged();
|
||||
SaveCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
private string _proxyServerAddress = "";
|
||||
public string ProxyServerAddress
|
||||
{
|
||||
get => _proxyServerAddress;
|
||||
set
|
||||
{
|
||||
if (_proxyServerAddress == value) return;
|
||||
_proxyServerAddress = value;
|
||||
if (_selectedProfile != null)
|
||||
_selectedProfile.ProxyServerAddress = value;
|
||||
OnPropertyChanged();
|
||||
UpdateConfigDiagnostics();
|
||||
RaiseProfileCardChanged();
|
||||
SaveCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
private int _proxyPort = 1080;
|
||||
public int ProxyPort
|
||||
{
|
||||
get => _proxyPort;
|
||||
set
|
||||
{
|
||||
var normalized = value;
|
||||
if (_proxyPort == normalized) return;
|
||||
_proxyPort = normalized;
|
||||
if (_selectedProfile != null)
|
||||
_selectedProfile.ProxyPort = normalized;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(ProxyPortText));
|
||||
UpdateConfigDiagnostics();
|
||||
RaiseProfileCardChanged();
|
||||
SaveCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
public string ProxyPortText
|
||||
{
|
||||
get => _proxyPort.ToString();
|
||||
set
|
||||
{
|
||||
if (int.TryParse((value ?? "").Trim(), out var port))
|
||||
{
|
||||
ProxyPort = port;
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigValidationText = string.IsNullOrWhiteSpace(value)
|
||||
? "پورت پراکسی را وارد کنید"
|
||||
: "پورت پراکسی باید عدد باشد";
|
||||
}
|
||||
}
|
||||
|
||||
private string _proxyUsername = "";
|
||||
public string ProxyUsername
|
||||
{
|
||||
get => _proxyUsername;
|
||||
set
|
||||
{
|
||||
if (_proxyUsername == value) return;
|
||||
_proxyUsername = value;
|
||||
if (_selectedProfile != null)
|
||||
_selectedProfile.ProxyUsername = value;
|
||||
OnPropertyChanged();
|
||||
UpdateConfigDiagnostics();
|
||||
RaiseProfileCardChanged();
|
||||
SaveCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
private string _proxyPassword = "";
|
||||
public string ProxyPassword
|
||||
{
|
||||
get => _proxyPassword;
|
||||
set
|
||||
{
|
||||
if (_proxyPassword == value) return;
|
||||
_proxyPassword = value;
|
||||
if (_selectedProfile != null)
|
||||
_selectedProfile.ProxyPassword = value;
|
||||
OnPropertyChanged();
|
||||
SaveCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
private string _configCoreHint = "";
|
||||
public string ConfigCoreHint
|
||||
{
|
||||
get => _configCoreHint;
|
||||
get => LocalizationService.Instance.T(_configCoreHint);
|
||||
set { _configCoreHint = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private string _configValidationText = "";
|
||||
public string ConfigValidationText
|
||||
{
|
||||
get => _configValidationText;
|
||||
get => LocalizationService.Instance.T(_configValidationText);
|
||||
set { _configValidationText = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private string _saveStatusText = "";
|
||||
public string SaveStatusText
|
||||
{
|
||||
get => _saveStatusText;
|
||||
set { _saveStatusText = value; OnPropertyChanged(); }
|
||||
get => LocalizationService.Instance.T(_saveStatusText);
|
||||
set { _saveStatusText = value; OnPropertyChanged(); OnPropertyChanged(nameof(ProfileSaveHintText)); }
|
||||
}
|
||||
|
||||
private string _connectionDuration = "--:--:--";
|
||||
@@ -468,6 +748,15 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
set { _vpnIp = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private string _connectionIpText = "-";
|
||||
public string ConnectionIpText
|
||||
{
|
||||
get => LocalizationService.Instance.T(_connectionIpText);
|
||||
set { _connectionIpText = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
public string ConnectionIpLabel => LocalizationService.Instance.T("IP خروجی");
|
||||
|
||||
private string _vpnAdapterName = "";
|
||||
public string VpnAdapterName
|
||||
{
|
||||
@@ -504,13 +793,15 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
}
|
||||
|
||||
public string FullRouteStatusText => _isFullRouteEnabled
|
||||
? "Full Route فعال است؛ کل سیستم از تونل عبور میکند"
|
||||
: "Split فعال است؛ فقط برنامهها و مقصدهای انتخابی از تونل عبور میکنند";
|
||||
? LocalizationService.Instance.T("Full Route فعال است؛ کل سیستم از تونل عبور میکند")
|
||||
: LocalizationService.Instance.T("Split فعال است؛ فقط برنامهها و مقصدهای انتخابی از تونل عبور میکنند");
|
||||
|
||||
public string RouteModeTitle => IsFullRouteEnabled ? "حالت کل سیستم" : "حالت انتخابی";
|
||||
public string RouteModeTitle => IsFullRouteEnabled
|
||||
? LocalizationService.Instance.T("حالت کل سیستم")
|
||||
: LocalizationService.Instance.T("حالت انتخابی");
|
||||
public string RouteModeDescription => IsFullRouteEnabled
|
||||
? "همه برنامهها از تونل عبور میکنند. برای تست یا وقتی میخواهید کل ویندوز پشت VPN باشد مناسب است."
|
||||
: "فقط برنامههای فعال در تب برنامهها و مقصدهای لزومی از تونل عبور میکنند. حالت پیشنهادی برای مصرف کمتر و کنترل بهتر.";
|
||||
? LocalizationService.Instance.T("ترافیک کل سیستم از تونل عبور خواهد کرد؛ برای وقتی مناسب است که همه برنامهها باید پشت تونل باشند.")
|
||||
: LocalizationService.Instance.T("فقط برنامهها و مقصدهای انتخابی از تونل عبور میکنند؛ بقیه ترافیک مستقیم میماند.");
|
||||
|
||||
public string HeaderCoreText => $"Core: {ActiveCoreName}";
|
||||
public string HeaderRouteText => IsFullRouteEnabled ? "Mode: Full" : "Mode: Split";
|
||||
@@ -544,11 +835,25 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
? $"routes {_trafficRouter.ActiveRouteCount}/{_trafficRouter.RouteFailureCount} fail"
|
||||
: "-";
|
||||
|
||||
public string ConnectedBadgeText => CurrentTunnelType == TunnelType.SocksProxy
|
||||
? LocalizationService.Instance.T("متصل به پراکسی")
|
||||
: LocalizationService.Instance.T("متصل به VPN");
|
||||
|
||||
public string ConnectedProfileName => string.IsNullOrWhiteSpace(SelectedProfileName)
|
||||
? LocalizationService.Instance.T("پروفایل فعال")
|
||||
: SelectedProfileName;
|
||||
|
||||
public string SelectedProfileSummaryText => LocalizationService.Instance.Format(
|
||||
"پروفایل فعال: {0}",
|
||||
ConnectedProfileName);
|
||||
|
||||
private string ActiveCoreName => CurrentTunnelType switch
|
||||
{
|
||||
TunnelType.L2tpIpsec => "L2TP",
|
||||
TunnelType.V2Ray when TunnelProviderFactory.RequiresXray(SelectedV2RayConfig) => "Xray",
|
||||
TunnelType.V2Ray => "sing-box",
|
||||
TunnelType.OpenVpn => "OpenVPN",
|
||||
TunnelType.SocksProxy => ProxyProtocol == ProxyProtocol.Http ? "HTTP Proxy" : "SOCKS5",
|
||||
_ => "-"
|
||||
};
|
||||
|
||||
@@ -594,12 +899,31 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
set { _isPinging = value; OnPropertyChanged(); OnPropertyChanged(nameof(PingButtonText)); }
|
||||
}
|
||||
|
||||
public string PingButtonText => _isPinging ? "⏹ توقف" : "▶ شروع";
|
||||
public string PingButtonText => _isPinging
|
||||
? LocalizationService.Instance.T("توقف تست")
|
||||
: LocalizationService.Instance.T("تست مقصد");
|
||||
|
||||
private bool _isTestingConnectedServerPing;
|
||||
public bool IsTestingConnectedServerPing
|
||||
{
|
||||
get => _isTestingConnectedServerPing;
|
||||
set
|
||||
{
|
||||
_isTestingConnectedServerPing = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(ConnectedServerPingButtonText));
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
|
||||
public string ConnectedServerPingButtonText => IsTestingConnectedServerPing
|
||||
? LocalizationService.Instance.T("در حال پینگ...")
|
||||
: LocalizationService.Instance.T("پینگ سرور");
|
||||
|
||||
private string _pingResult = "";
|
||||
public string PingResult
|
||||
{
|
||||
get => _pingResult;
|
||||
get => LocalizationService.Instance.T(_pingResult);
|
||||
set { _pingResult = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
@@ -616,12 +940,14 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
public string ServerPingButtonText => _isTestingServerPing ? "در حال تست..." : "تست سرور";
|
||||
public string ServerPingButtonText => _isTestingServerPing
|
||||
? LocalizationService.Instance.T("در حال تست...")
|
||||
: LocalizationService.Instance.T("تست سرور");
|
||||
|
||||
private string _serverPingResult = "";
|
||||
public string ServerPingResult
|
||||
{
|
||||
get => _serverPingResult;
|
||||
get => LocalizationService.Instance.T(_serverPingResult);
|
||||
set { _serverPingResult = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
@@ -722,20 +1048,27 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
public ICommand NewProfileCommand { get; }
|
||||
public ICommand DeleteProfileCommand { get; }
|
||||
public ICommand DuplicateProfileCommand { get; }
|
||||
public ICommand EditProfileCommand { get; }
|
||||
public ICommand SelectProfileCommand { get; }
|
||||
public ICommand ClearHistoryCommand { get; }
|
||||
public ICommand AddExcludeCommand { get; }
|
||||
public ICommand RemoveExcludeCommand { get; }
|
||||
public ICommand AddIncludeCommand { get; }
|
||||
public ICommand RemoveIncludeCommand { get; }
|
||||
public ICommand TogglePingCommand { get; }
|
||||
public ICommand TestConnectedServerPingCommand { get; }
|
||||
public ICommand TestServerPingCommand { get; }
|
||||
public ICommand PasteConfigCommand { get; }
|
||||
public ICommand ClearConfigCommand { get; }
|
||||
public ICommand BrowseOpenVpnConfigCommand { get; }
|
||||
public ICommand OpenOpenVpnCommunityDownloadCommand { get; }
|
||||
public ICommand OpenGitHubCommand { get; }
|
||||
public ICommand OpenDonateCommand { get; }
|
||||
public ICommand OpenAdRequestCommand { get; }
|
||||
public ICommand CopyDonationInfoCommand { get; }
|
||||
public ICommand CheckForUpdatesCommand { get; }
|
||||
public ICommand OpenLatestReleaseCommand { get; }
|
||||
public ICommand ToggleLanguageCommand { get; }
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -762,10 +1095,10 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
try
|
||||
{
|
||||
var text =
|
||||
$"{AppInfo.AppName} - حمایت از پروژه\n" +
|
||||
$"{AppInfo.AppName} - {LocalizationService.Instance.T("حمایت از پروژه")}\n" +
|
||||
$"PayPal: {AppInfo.PayPalEmail}\n" +
|
||||
$"PayPal link: {AppInfo.PayPalDonateUrl}\n\n" +
|
||||
AppInfo.CryptoDonationText;
|
||||
CryptoDonationText;
|
||||
System.Windows.Clipboard.SetText(text);
|
||||
Logger.Info("[UI] Donation info copied to clipboard");
|
||||
}
|
||||
@@ -803,13 +1136,13 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
if (latest.Version > current)
|
||||
{
|
||||
IsUpdateAvailable = true;
|
||||
UpdateStatusText = $"نسخه جدید آماده است: {latest.TagName} - برای دانلود از GitHub باز کنید.";
|
||||
UpdateStatusText = LocalizationService.Instance.Format("نسخه جدید آماده است: {0} - برای دانلود از GitHub باز کنید.", latest.TagName);
|
||||
Logger.Info($"[UPDATE] New version available: current={current} latest={latest.TagName}");
|
||||
return;
|
||||
}
|
||||
|
||||
IsUpdateAvailable = false;
|
||||
UpdateStatusText = $"TunnelX بهروز است. نسخه فعلی: {AppInfo.VersionText}";
|
||||
UpdateStatusText = LocalizationService.Instance.Format("TunnelX بهروز است. نسخه فعلی: {0}", AppInfo.VersionText);
|
||||
Logger.Info($"[UPDATE] App is up to date: current={current} latest={latest.TagName}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -821,7 +1154,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!silent)
|
||||
UpdateStatusText = $"بررسی بروزرسانی ناموفق بود: {ex.Message}";
|
||||
UpdateStatusText = LocalizationService.Instance.Format("بررسی بروزرسانی ناموفق بود: {0}", ex.Message);
|
||||
Logger.Warning($"[UPDATE] Latest release check failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
@@ -830,19 +1163,122 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshGitHubInstallCountAsync(int proxyPort)
|
||||
{
|
||||
var requestId = ++_githubInstallCountRequestId;
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(12));
|
||||
var count = await GitHubReleaseChecker.GetAppDownloadCountAsync(cts.Token, proxyPort);
|
||||
if (requestId != _githubInstallCountRequestId || !IsConnected)
|
||||
return;
|
||||
|
||||
if (count.HasValue)
|
||||
{
|
||||
SetGitHubInstallCount(count.Value, persist: true);
|
||||
Logger.Info($"[GITHUB-STATS] App downloads={count.Value}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Warning("[GITHUB-STATS] App download count unavailable");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.Warning("[GITHUB-STATS] App download count timed out");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"[GITHUB-STATS] App download count failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SetGitHubInstallCount(long? count, bool persist = false)
|
||||
{
|
||||
if (_githubInstallCount == count)
|
||||
{
|
||||
if (persist && _appSettings.GitHubAppDownloadCount != count)
|
||||
{
|
||||
_appSettings.GitHubAppDownloadCount = count;
|
||||
_profileService.SaveAppSettings(_appSettings);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_githubInstallCount = count;
|
||||
if (persist)
|
||||
{
|
||||
_appSettings.GitHubAppDownloadCount = count;
|
||||
_profileService.SaveAppSettings(_appSettings);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasGitHubInstallCount));
|
||||
OnPropertyChanged(nameof(GitHubInstallCountText));
|
||||
OnPropertyChanged(nameof(AdAudienceText));
|
||||
}
|
||||
|
||||
private void PasteConfigFromClipboard()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.Windows.Clipboard.ContainsText())
|
||||
SelectedV2RayConfig = System.Windows.Clipboard.GetText().Trim();
|
||||
{
|
||||
var text = System.Windows.Clipboard.GetText().Trim();
|
||||
if (CurrentTunnelType == TunnelType.OpenVpn)
|
||||
SelectedOpenVpnConfig = text;
|
||||
else
|
||||
SelectedV2RayConfig = text;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ConfigValidationText = $"خواندن کلیپبورد ناموفق بود: {ex.Message}";
|
||||
ConfigValidationText = LocalizationService.Instance.Format("خواندن کلیپبورد ناموفق بود: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearCurrentConfig()
|
||||
{
|
||||
if (CurrentTunnelType == TunnelType.OpenVpn)
|
||||
{
|
||||
SelectedOpenVpnConfig = "";
|
||||
SelectedOpenVpnConfigPath = "";
|
||||
}
|
||||
else
|
||||
SelectedV2RayConfig = "";
|
||||
}
|
||||
|
||||
private void BrowseForOpenVpnConfig()
|
||||
{
|
||||
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() != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
SelectedOpenVpnConfigPath = dialog.FileName;
|
||||
SelectedOpenVpnConfig = File.ReadAllText(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ConfigValidationText = LocalizationService.Instance.Format("خواندن فایل OpenVPN ناموفق بود: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshOpenVpnInstallStatus()
|
||||
{
|
||||
var path = OpenVpnTunnelProvider.FindOpenVpnExecutable();
|
||||
IsOpenVpnCommunityInstalled = !string.IsNullOrWhiteSpace(path);
|
||||
OpenVpnDetectedPath = path ?? "";
|
||||
UpdateConfigDiagnostics();
|
||||
}
|
||||
|
||||
private void UpdateConfigDiagnostics()
|
||||
{
|
||||
if (CurrentTunnelType == TunnelType.L2tpIpsec)
|
||||
@@ -854,6 +1290,28 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
return;
|
||||
}
|
||||
|
||||
if (CurrentTunnelType == TunnelType.OpenVpn)
|
||||
{
|
||||
ConfigCoreHint = "OpenVPN";
|
||||
ConfigValidationText = !IsOpenVpnCommunityInstalled
|
||||
? "OpenVPN Community نصب نیست؛ ابتدا آن را از لینک رسمی نصب کنید"
|
||||
: string.IsNullOrWhiteSpace(SelectedOpenVpnConfig)
|
||||
? "فایل .ovpn را انتخاب کنید؛ TunnelX آن را در حالت split-compatible اجرا میکند"
|
||||
: string.IsNullOrWhiteSpace(OpenVpnUsername)
|
||||
? "کانفیگ انتخاب شد؛ اگر سرور احراز هویت دارد نام کاربری را وارد کنید"
|
||||
: "کانفیگ و نام کاربری OpenVPN آماده است";
|
||||
return;
|
||||
}
|
||||
|
||||
if (CurrentTunnelType == TunnelType.SocksProxy)
|
||||
{
|
||||
ConfigCoreHint = ProxyProtocol == ProxyProtocol.Http ? "HTTP Proxy" : "SOCKS5 Proxy";
|
||||
ConfigValidationText = ValidateProxySettings(out var proxyMessage)
|
||||
? BuildProxyValidationText()
|
||||
: proxyMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
var config = SelectedV2RayConfig.Trim();
|
||||
if (string.IsNullOrWhiteSpace(config))
|
||||
{
|
||||
@@ -867,10 +1325,45 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
: "هسته: sing-box";
|
||||
|
||||
ConfigValidationText = TryExtractProxyEndpoint(config, out var server, out var port, out var error)
|
||||
? $"سرور: {server}:{port}"
|
||||
? LocalizationService.Instance.Format("سرور: {0}:{1}", server, port)
|
||||
: error;
|
||||
}
|
||||
|
||||
private string BuildProxyValidationText()
|
||||
{
|
||||
var endpoint = $"{ProxyServerAddress.Trim()}:{ProxyPort}";
|
||||
return IsLoopbackProxyServer()
|
||||
? LocalizationService.Instance.Format("پراکسی آماده است: {0} — توجه: این پراکسی محلی است؛ برنامههایی که خودشان مستقیم از همین پراکسی استفاده کنند خارج از لیست برنامههای TunnelX هم پروکسی میشوند.", endpoint)
|
||||
: LocalizationService.Instance.Format("پراکسی آماده است: {0}", endpoint);
|
||||
}
|
||||
|
||||
private bool IsLoopbackProxyServer()
|
||||
{
|
||||
var host = ProxyServerAddress.Trim();
|
||||
return host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.StartsWith("127.", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.Equals("::1", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private bool ValidateProxySettings(out string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ProxyServerAddress))
|
||||
{
|
||||
message = "آدرس IP یا دامنه سرور پراکسی را وارد کنید";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ProxyPort <= 0 || ProxyPort > 65535)
|
||||
{
|
||||
message = "پورت پراکسی باید بین 1 تا 65535 باشد";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = "تنظیمات پراکسی آماده است";
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ValidateMixedProxyPort(out string message)
|
||||
{
|
||||
var port = _mixedProxyPort;
|
||||
@@ -924,8 +1417,11 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
var currentName = _selectedProfile.Name?.Trim() ?? "";
|
||||
var canRename = string.IsNullOrWhiteSpace(currentName) ||
|
||||
currentName.StartsWith("پروفایل ", StringComparison.OrdinalIgnoreCase) ||
|
||||
currentName.StartsWith("Profile ", StringComparison.OrdinalIgnoreCase) ||
|
||||
currentName == "پروفایل جدید" ||
|
||||
currentName == "پیشفرض";
|
||||
currentName == "پیشفرض" ||
|
||||
currentName == "New Profile" ||
|
||||
currentName == "Default";
|
||||
if (!canRename)
|
||||
return;
|
||||
|
||||
@@ -935,6 +1431,8 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
|
||||
_selectedProfile.Name = remark;
|
||||
OnPropertyChanged(nameof(SelectedProfileName));
|
||||
OnPropertyChanged(nameof(ConnectedProfileName));
|
||||
OnPropertyChanged(nameof(SelectedProfileSummaryText));
|
||||
}
|
||||
|
||||
private static string ExtractConfigRemark(string config)
|
||||
@@ -980,10 +1478,87 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
private void LoadAppSettings()
|
||||
{
|
||||
_appSettings = _profileService.LoadAppSettings();
|
||||
LocalizationService.Instance.Initialize(_appSettings.Language);
|
||||
_startWithWindows = _appSettings.StartWithWindows;
|
||||
_autoConnectOnStartup = _appSettings.AutoConnectOnStartup;
|
||||
_githubInstallCount = _appSettings.GitHubAppDownloadCount;
|
||||
OnPropertyChanged(nameof(StartWithWindows));
|
||||
OnPropertyChanged(nameof(AutoConnectOnStartup));
|
||||
OnPropertyChanged(nameof(HasGitHubInstallCount));
|
||||
OnPropertyChanged(nameof(GitHubInstallCountText));
|
||||
OnPropertyChanged(nameof(AdAudienceText));
|
||||
OnPropertyChanged(nameof(LanguageToggleText));
|
||||
OnPropertyChanged(nameof(AppIsRightToLeft));
|
||||
OnPropertyChanged(nameof(AppFlowDirection));
|
||||
OnPropertyChanged(nameof(AppTextAlignment));
|
||||
OnPropertyChanged(nameof(AppStartHorizontalAlignment));
|
||||
OnPropertyChanged(nameof(AppEndHorizontalAlignment));
|
||||
}
|
||||
|
||||
private void ToggleLanguage()
|
||||
{
|
||||
LocalizationService.Instance.ToggleLanguage();
|
||||
_appSettings.Language = LocalizationService.Instance.EffectiveLanguage;
|
||||
_profileService.SaveAppSettings(_appSettings);
|
||||
}
|
||||
|
||||
private void OnLanguageChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(LanguageToggleText));
|
||||
OnPropertyChanged(nameof(AppIsRightToLeft));
|
||||
OnPropertyChanged(nameof(AppTitleText));
|
||||
OnPropertyChanged(nameof(AppTitleAccentText));
|
||||
OnPropertyChanged(nameof(AppTitleFlowDirection));
|
||||
OnPropertyChanged(nameof(AppTitleAccentAlignment));
|
||||
OnPropertyChanged(nameof(AppFlowDirection));
|
||||
OnPropertyChanged(nameof(AppTextAlignment));
|
||||
OnPropertyChanged(nameof(AppStartHorizontalAlignment));
|
||||
OnPropertyChanged(nameof(AppEndHorizontalAlignment));
|
||||
OnPropertyChanged(nameof(GameModeStatusText));
|
||||
OnPropertyChanged(nameof(OpenVpnPrerequisiteText));
|
||||
OnPropertyChanged(nameof(DonatePayPalText));
|
||||
OnPropertyChanged(nameof(CryptoDonationText));
|
||||
OnPropertyChanged(nameof(AppCreatorText));
|
||||
OnPropertyChanged(nameof(AdPlaceholderTitleText));
|
||||
OnPropertyChanged(nameof(AdRequestButtonText));
|
||||
OnPropertyChanged(nameof(AdAudienceText));
|
||||
OnPropertyChanged(nameof(UpdateButtonText));
|
||||
OnPropertyChanged(nameof(UpdateStatusText));
|
||||
OnPropertyChanged(nameof(GitHubInstallCountText));
|
||||
OnPropertyChanged(nameof(ConnectButtonText));
|
||||
OnPropertyChanged(nameof(ConnectButtonToolTip));
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
OnPropertyChanged(nameof(MixedProxyPortStatusText));
|
||||
OnPropertyChanged(nameof(AppLicenseDisplayText));
|
||||
OnPropertyChanged(nameof(ConfigCoreHint));
|
||||
OnPropertyChanged(nameof(ConfigValidationText));
|
||||
OnPropertyChanged(nameof(SaveStatusText));
|
||||
OnPropertyChanged(nameof(ConnectionIpText));
|
||||
OnPropertyChanged(nameof(ConnectionIpLabel));
|
||||
OnPropertyChanged(nameof(FullRouteStatusText));
|
||||
OnPropertyChanged(nameof(RouteModeTitle));
|
||||
OnPropertyChanged(nameof(RouteModeDescription));
|
||||
OnPropertyChanged(nameof(HeaderCoreText));
|
||||
OnPropertyChanged(nameof(HeaderRouteText));
|
||||
OnPropertyChanged(nameof(HeaderLeakText));
|
||||
OnPropertyChanged(nameof(HealthLeakText));
|
||||
OnPropertyChanged(nameof(HealthDnsText));
|
||||
OnPropertyChanged(nameof(HealthIpv6Text));
|
||||
OnPropertyChanged(nameof(HealthRoutesText));
|
||||
OnPropertyChanged(nameof(ConnectedBadgeText));
|
||||
OnPropertyChanged(nameof(ConnectedProfileName));
|
||||
OnPropertyChanged(nameof(SelectedProfileSummaryText));
|
||||
OnPropertyChanged(nameof(PingButtonText));
|
||||
OnPropertyChanged(nameof(ConnectedServerPingButtonText));
|
||||
OnPropertyChanged(nameof(ServerPingButtonText));
|
||||
OnPropertyChanged(nameof(PingResult));
|
||||
OnPropertyChanged(nameof(ServerPingResult));
|
||||
OnPropertyChanged(nameof(ProfileCountText));
|
||||
OnPropertyChanged(nameof(ActiveProfileTypeText));
|
||||
OnPropertyChanged(nameof(ActiveProfileEndpointText));
|
||||
OnPropertyChanged(nameof(ProfileSaveHintText));
|
||||
RaiseProfileCardChanged();
|
||||
OnPropertyChanged(nameof(Profiles));
|
||||
}
|
||||
|
||||
private static void UpdateStartupRegistry(bool enable)
|
||||
|
||||
@@ -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,6 +153,15 @@ public partial class MainViewModel
|
||||
_selectedProfile.PreSharedKey = PreSharedKey;
|
||||
_selectedProfile.TunnelType = _currentTunnelType;
|
||||
_selectedProfile.V2RayConfig = SelectedV2RayConfig;
|
||||
_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;
|
||||
@@ -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
|
||||
@@ -141,77 +193,201 @@ public partial class MainViewModel
|
||||
|
||||
private void LoadProfileIntoUi(ConnectionProfile profile)
|
||||
{
|
||||
ServerAddress = profile.ServerAddress;
|
||||
Username = profile.Username;
|
||||
Password = profile.Password;
|
||||
PreSharedKey = profile.PreSharedKey;
|
||||
_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;
|
||||
_trafficRouter.EnableDnsOptimization = _isDnsOptimizationEnabled;
|
||||
_trafficRouter.EnableGameMode = _isGameModeEnabled;
|
||||
OnPropertyChanged(nameof(AutoTuneMtu));
|
||||
OnPropertyChanged(nameof(IsDnsOptimizationEnabled));
|
||||
OnPropertyChanged(nameof(IsGameModeEnabled));
|
||||
OnPropertyChanged(nameof(GameModeStatusText));
|
||||
// Use the field directly to avoid writing back to the old profile
|
||||
// while the new profile is being loaded.
|
||||
_currentTunnelType = profile.TunnelType;
|
||||
_selectedV2RayConfig = profile.V2RayConfig;
|
||||
OnPropertyChanged(nameof(CurrentTunnelType));
|
||||
OnPropertyChanged(nameof(SelectedV2RayConfig));
|
||||
UpdateConfigDiagnostics();
|
||||
_isLoadingProfile = true;
|
||||
try
|
||||
{
|
||||
ServerAddress = profile.ServerAddress;
|
||||
Username = profile.Username;
|
||||
Password = profile.Password;
|
||||
PreSharedKey = profile.PreSharedKey;
|
||||
_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;
|
||||
_trafficRouter.EnableDnsOptimization = _isDnsOptimizationEnabled;
|
||||
_trafficRouter.EnableGameMode = _isGameModeEnabled;
|
||||
OnPropertyChanged(nameof(AutoTuneMtu));
|
||||
OnPropertyChanged(nameof(IsDnsOptimizationEnabled));
|
||||
OnPropertyChanged(nameof(IsGameModeEnabled));
|
||||
OnPropertyChanged(nameof(GameModeStatusText));
|
||||
// Use the field directly to avoid writing back to the old profile
|
||||
// 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);
|
||||
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}", MixedProxyPort = MixedProxyPort };
|
||||
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()
|
||||
private void DuplicateCurrentProfile(object? parameter = null)
|
||||
{
|
||||
if (_selectedProfile == null) return;
|
||||
var source = parameter as ConnectionProfile ?? _selectedProfile;
|
||||
if (source == null) return;
|
||||
SaveCurrentProfileState();
|
||||
|
||||
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,
|
||||
MixedProxyPort = _selectedProfile.MixedProxyPort,
|
||||
AutoTuneMtu = _selectedProfile.AutoTuneMtu,
|
||||
EnableDnsOptimization = _selectedProfile.EnableDnsOptimization,
|
||||
EnableGameMode = _selectedProfile.EnableGameMode,
|
||||
};
|
||||
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 DeleteCurrentProfile()
|
||||
private void EditProfile(object? parameter)
|
||||
{
|
||||
if (_selectedProfile == null || Profiles.Count <= 1) return;
|
||||
var toRemove = _selectedProfile;
|
||||
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 DeleteCurrentProfile(object? parameter = null)
|
||||
{
|
||||
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 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,
|
||||
|
||||
@@ -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"/>
|
||||
<!-- 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="0,0,4,0"/>
|
||||
|
||||
<!-- Toggle -->
|
||||
<CheckBox Grid.Column="3" Style="{StaticResource ToggleSwitch}"
|
||||
IsChecked="{Binding IsEnabled, Mode=OneWay}"
|
||||
Command="{Binding DataContext.ToggleAppCommand,
|
||||
RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
CommandParameter="{Binding}"
|
||||
VerticalAlignment="Center" Margin="4,0"/>
|
||||
|
||||
<!-- Remove -->
|
||||
<Button Grid.Column="4" Style="{StaticResource DangerButton}"
|
||||
Content="✕" FontSize="14"
|
||||
Command="{Binding DataContext.RemoveAppCommand,
|
||||
RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<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"
|
||||
Foreground="{StaticResource PrimaryBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<Button Grid.Column="4"
|
||||
x:Name="addBadge"
|
||||
Style="{StaticResource CompactIconButton}"
|
||||
Content="+"
|
||||
ToolTip="افزودن"
|
||||
Foreground="{StaticResource PrimaryBrush}"
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +436,179 @@
|
||||
</StackPanel>
|
||||
<!-- End V2Ray fields -->
|
||||
|
||||
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,8,0,0">
|
||||
<!-- 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>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
@@ -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"
|
||||
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>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Connection Info - 4 cards in one row -->
|
||||
<Grid Margin="0,10,0,0">
|
||||
<!-- Connection summary -->
|
||||
<Grid Margin="0,4,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="0.95*"/>
|
||||
<ColumnDefinition Width="6"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="0.9*"/>
|
||||
<ColumnDefinition Width="6"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="1.45*"/>
|
||||
<ColumnDefinition Width="6"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<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}"
|
||||
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>
|
||||
|
||||
<!-- 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 + Mixed Proxy 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>
|
||||
|
||||
<!-- Mixed Proxy -->
|
||||
<Border Grid.Column="2" Background="#11FFFFFF" CornerRadius="10" Padding="12,10"
|
||||
ToolTip="پروکسی Mixed داخلی روی 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="Mixed" 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 MixedProxyInfo}" 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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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">
|
||||
<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" Style="{StaticResource HelpChip}">
|
||||
<TextBlock Text="Full Route: کل سیستم وارد تونل میشود؛ استثناها میتوانند مستقیم بمانند."
|
||||
Style="{StaticResource HelpBody}" FontSize="10"/>
|
||||
</Border>
|
||||
<Border Grid.Row="2" Grid.Column="0" Style="{StaticResource HelpChip}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="۱. پروفایل" FontSize="12" FontWeight="SemiBold"
|
||||
<TextBlock Text="پراکسی داخلی"
|
||||
FontSize="11"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="نوع اتصال را انتخاب کنید و کانفیگ L2TP یا V2Ray/Xray را وارد کنید."
|
||||
FontSize="10" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,5,0,0"/>
|
||||
<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"
|
||||
Margin="0,3,0,0"/>
|
||||
</StackPanel>
|
||||
</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>
|
||||
|
||||
<Border Grid.Row="2" Grid.Column="0" Background="#11FFFFFF" CornerRadius="8" Padding="12,10">
|
||||
<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.Row="2" Grid.Column="2" Style="{StaticResource HelpChip}">
|
||||
<TextBlock Text="سلامت: Leak باید صفر باشد. DNS، IPv6 و Route را بعد از اتصال بررسی کنید."
|
||||
Style="{StaticResource HelpBody}" FontSize="10"/>
|
||||
</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="فقط برنامههای انتخابشده و مقصدهای لزومی از تونل عبور میکنند. این حالت برای مصرف کمتر و کنترل دقیقتر پیشنهاد میشود."
|
||||
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">
|
||||
<TextBlock Text="127.0.0.1:1080"
|
||||
FontSize="11"
|
||||
FontFamily="Consolas"
|
||||
Foreground="{StaticResource AccentBrush}"
|
||||
FlowDirection="LeftToRight"/>
|
||||
</Border>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
<Border Grid.Row="1" Grid.ColumnSpan="2"
|
||||
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="*"/>
|
||||
@@ -64,20 +65,20 @@
|
||||
<StackPanel Grid.Column="0">
|
||||
<!-- Profile name and server -->
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding ProfileName}"
|
||||
FontSize="14" FontWeight="SemiBold"
|
||||
<TextBlock Text="{Binding ProfileName}"
|
||||
FontSize="13" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text=" → "
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
FontSize="12" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding ServerAddress}"
|
||||
FontSize="12"
|
||||
FontSize="11" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding ServerAddress}"
|
||||
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"/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 -->
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 16 KiB |
@@ -2,6 +2,91 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 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
|
||||
@@ -38,3 +123,5 @@
|
||||
- Improved leak logging and traffic accounting in recent internal builds.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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> در دسترس هستند.
|
||||
|
||||
## نکته ایمنی و سلب مسئولیت
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| App split tunneling | Tunnel settings |
|
||||
| Routing rules | Help and troubleshooting |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 88 KiB |