8 Commits

Author SHA1 Message Date
MaxFan dfe623dd99 Fix release workflow for prepared versions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:26:05 +03:30
MaxFan 8283b9d6d1 Prepare release v1.2.27
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:20:44 +03:30
MaxFan a686edd027 Fix OpenVPN reconnect routing
Update the router when OpenVPN reconnects with a new runtime endpoint, and keep the release notes and README status in sync.

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 15:20:37 +03:30
37 changed files with 4302 additions and 623 deletions
+33 -22
View File
@@ -81,7 +81,7 @@ jobs:
}
}
if ($nextVersion -le $currentVersion) {
if ($nextVersion -lt $currentVersion -or (-not $requestedVersion -and $nextVersion -le $currentVersion)) {
throw "Release version ($nextVersion) must be greater than current project version ($currentVersion)."
}
@@ -120,28 +120,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 +161,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 }}
+17 -2
View File
@@ -8,19 +8,28 @@
</ResourceDictionary.MergedDictionaries>
<!-- Embedded Persian Font (Vazirmatn) with fallbacks -->
<FontFamily x:Key="PersianFont">pack://application:,,,/Fonts/#Vazirmatn</FontFamily>
<FontFamily x:Key="AppFont">pack://application:,,,/Fonts/#Vazirmatn, Segoe UI, Tahoma</FontFamily>
<FontFamily x:Key="PersianFont">pack://application:,,,/AppTunnel;component/Fonts/#Vazirmatn</FontFamily>
<FontFamily x:Key="AppFont">pack://application:,,,/AppTunnel;component/Fonts/#Vazirmatn, Vazirmatn, Vazir, Segoe UI, Tahoma</FontFamily>
<!-- Global Font Settings -->
<Style TargetType="{x:Type Window}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
</Style>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
<Setter Property="TextOptions.TextRenderingMode" Value="Auto"/>
<Setter Property="TextOptions.TextHintingMode" Value="Fixed"/>
</Style>
<Style TargetType="{x:Type TextBox}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
<Style TargetType="{x:Type PasswordBox}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
<Style TargetType="{x:Type Button}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
@@ -33,6 +42,12 @@
<Style TargetType="{x:Type CheckBox}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
<Style TargetType="{x:Type ListBox}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
</Style>
<Style TargetType="{x:Type ToolTip}">
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Background" Value="#252525"/>
+3 -3
View File
@@ -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.27</Version>
<AssemblyVersion>1.2.27.0</AssemblyVersion>
<FileVersion>1.2.27.0</FileVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
+159 -176
View File
@@ -51,8 +51,10 @@
</Grid.RowDefinitions>
<!-- ═══ CUSTOM TITLE BAR ═══ -->
<Border Grid.Row="0" Background="{StaticResource SurfaceBrush}"
CornerRadius="12,0,0,0" Padding="16,10">
<Border 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"/>
@@ -62,9 +64,9 @@
</Grid.ColumnDefinitions>
<!-- Logo & Title -->
<StackPanel Grid.Column="0" Orientation="Horizontal">
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Tx Icon SVG -->
<Viewbox Width="28" Height="28" Margin="0,0,10,0">
<Viewbox Width="30" Height="30" 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"
@@ -72,28 +74,37 @@
FontFamily="Segoe UI"/>
</Canvas>
</Viewbox>
<StackPanel VerticalAlignment="Center">
<StackPanel VerticalAlignment="Bottom" Margin="0,0,0,1">
<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}"/>
<TextBlock Text="Per-app split tunneling" 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"
@@ -114,12 +125,13 @@
</StackPanel>
<!-- Details Button -->
<Button Grid.Column="2" Content="🔍 جزئیات"
<Button Grid.Column="2" 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 +141,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>
@@ -173,7 +187,7 @@
<TabItem.Header>
<StackPanel>
<TextBlock Text="⚡" FontSize="13" HorizontalAlignment="Center"/>
<TextBlock Text="اتصال VPN" FontSize="10" TextAlignment="Center"/>
<TextBlock Text="اتصال VPN" FontSize="11" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<views:ConnectionTabView/>
@@ -182,7 +196,7 @@
<TabItem.Header>
<StackPanel>
<TextBlock Text="📱" FontSize="13" HorizontalAlignment="Center"/>
<TextBlock Text="برنامه‌ها" FontSize="10" TextAlignment="Center"/>
<TextBlock Text="برنامه‌ها" FontSize="11" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<views:AppsTabView/>
@@ -192,7 +206,7 @@
<TabItem.Header>
<StackPanel>
<TextBlock Text="⚙" FontSize="13" HorizontalAlignment="Center"/>
<TextBlock Text="تنظیمات" FontSize="10" TextAlignment="Center"/>
<TextBlock Text="تنظیمات" FontSize="11" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<views:SettingsTabView/>
@@ -203,57 +217,63 @@
<TabItem.Header>
<StackPanel>
<TextBlock Text="🧭" FontSize="13" HorizontalAlignment="Center"/>
<TextBlock Text="قوانین مسیر" FontSize="10" TextAlignment="Center"/>
<TextBlock Text="قوانین مسیر" FontSize="11" 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 +283,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 +311,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 +323,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 +359,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 +379,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 +407,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 +419,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}"/>
@@ -440,7 +460,7 @@
<TabItem.Header>
<StackPanel>
<TextBlock Text="📊" FontSize="13" HorizontalAlignment="Center"/>
<TextBlock Text="ترافیک/تاریخچه" FontSize="9.5" TextAlignment="Center"/>
<TextBlock Text="ترافیک/تاریخچه" FontSize="10.5" FontWeight="Medium" TextAlignment="Center"/>
</StackPanel>
</TabItem.Header>
@@ -448,7 +468,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 +520,7 @@
</Border>
<!-- Split Tunnel Health -->
<Border Style="{StaticResource CardPanel}">
<Border Style="{StaticResource CardPanel}" BorderBrush="#2233C481">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -542,12 +562,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="مصرف تونل به تفکیک برنامه"/>
@@ -643,120 +666,80 @@
</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 Grid.Row="2"
Background="#121212"
BorderBrush="#18FFFFFF"
BorderThickness="0,1,0,0"
CornerRadius="0,0,0,12"
Padding="12,6">
<Grid VerticalAlignment="Center" FlowDirection="RightToLeft">
<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"
FlowDirection="RightToLeft"
HorizontalAlignment="Right"
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="8,0,0,0"/>
</StackPanel>
<StackPanel Grid.Column="3"
Orientation="Horizontal"
HorizontalAlignment="Left"
VerticalAlignment="Center"
FlowDirection="LeftToRight">
<TextBlock Text="{Binding AppVersion}"
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="10"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<Button Content="GitHub پروژه"
FlowDirection="RightToLeft">
<Button Content="بروزرسانی"
Command="{Binding OpenLatestReleaseCommand}"
Visibility="{Binding IsUpdateAvailable, Converter={StaticResource BoolToVis}}"
Style="{StaticResource PrimaryButton}"
Padding="10,4"
FontSize="10"
Margin="0,0,0,0"
VerticalAlignment="Center"
ToolTip="دانلود نسخه جدید از صفحه Releases در GitHub"/>
<Button Content="راهنما"
Click="OnShowHelpClick"
Style="{StaticResource SecondaryButton}"
Padding="9,4"
FontSize="10"
Margin="8,0,0,0"
VerticalAlignment="Center"
ToolTip="راهنما و عیب‌یابی"/>
<Button Content="GitHub"
Command="{Binding OpenGitHubCommand}"
Style="{StaticResource SecondaryButton}"
Padding="10,5"
Padding="9,4"
FontSize="10"
Margin="8,0,0,0"
VerticalAlignment="Center"
ToolTip="باز کردن صفحه GitHub پروژه TunnelX"/>
</StackPanel>
</Grid>
+122 -1
View File
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
namespace AppTunnel.Models;
@@ -7,7 +8,15 @@ namespace AppTunnel.Models;
public enum TunnelType
{
L2tpIpsec,
V2Ray
V2Ray,
OpenVpn,
SocksProxy
}
public enum ProxyProtocol
{
Socks5,
Http
}
/// <summary>
@@ -28,6 +37,15 @@ 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;
@@ -110,6 +128,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 +210,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",
_ => "نامشخص"
};
[JsonIgnore]
public string EndpointDisplay => TunnelType switch
{
TunnelType.L2tpIpsec => string.IsNullOrWhiteSpace(ServerAddress) ? "آدرس سرور وارد نشده" : ServerAddress,
TunnelType.V2Ray => string.IsNullOrWhiteSpace(V2RayConfig) ? "کانفیگ وارد نشده" : "کانفیگ آماده",
TunnelType.OpenVpn => string.IsNullOrWhiteSpace(OpenVpnConfigPath) ? "فایل .ovpn انتخاب نشده" : Path.GetFileName(OpenVpnConfigPath),
TunnelType.SocksProxy => string.IsNullOrWhiteSpace(ProxyServerAddress) ? "آدرس پراکسی وارد نشده" : $"{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 ? "آماده اتصال" : "نیاز به تکمیل";
[JsonIgnore]
public string ReadinessColor => IsReady ? "#6CCB5F" : "#E0A020";
public ServerConfig ToServerConfig() => new()
{
ServerAddress = ServerAddress,
@@ -147,6 +255,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 +280,11 @@ public class ConnectionProfile : INotifyPropertyChanged
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(propertyName);
OnPropertyChanged(nameof(TunnelTypeDisplay));
OnPropertyChanged(nameof(EndpointDisplay));
OnPropertyChanged(nameof(IsReady));
OnPropertyChanged(nameof(ReadinessText));
OnPropertyChanged(nameof(ReadinessColor));
return true;
}
}
+3
View File
@@ -16,6 +16,9 @@ public class ConnectionStatus
public DateTime? ConnectedSince { get; set; }
public string VpnLocalIp { get; set; } = string.Empty;
public string VpnServerIp { get; set; } = string.Empty;
public string VpnServerHost { get; set; } = string.Empty;
public int VpnServerPort { get; set; }
public string VpnGatewayIp { get; set; } = string.Empty;
public int VpnInterfaceIndex { get; set; } = -1;
/// <summary>
+21
View File
@@ -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";
}
}
+43 -3
View File
@@ -1,6 +1,9 @@
<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>
@@ -31,12 +34,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,6 +58,7 @@
<!-- Placeholder text (shown when empty and not focused) -->
<TextBlock x:Name="placeholder"
Text="{TemplateBinding Tag}"
FontFamily="{TemplateBinding FontFamily}"
Foreground="#555555"
FontSize="{TemplateBinding FontSize}"
Padding="{TemplateBinding Padding}"
@@ -82,12 +90,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 +123,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 +158,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 +166,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 +197,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 +239,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 +248,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"/>
@@ -280,10 +306,14 @@
</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="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="6,6"/>
<Setter Property="Width" Value="104"/>
<Setter Property="MinHeight" Value="50"/>
@@ -318,7 +348,7 @@
<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 +394,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 +420,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 +454,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 +507,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">
+766
View File
@@ -0,0 +1,766 @@
using System.Diagnostics;
using System.IO;
using System.Collections.Concurrent;
using System.Net;
using System.Net.NetworkInformation;
using System.Text;
using AppTunnel.Models;
namespace AppTunnel.Services;
/// <summary>
/// ITunnelProvider implementation for OpenVPN.
/// Launches user-installed OpenVPN Community with a split-compatible temporary
/// config and waits for its network adapter to come Up. TunnelX does not bundle
/// OpenVPN.
/// </summary>
public class OpenVpnTunnelProvider : ITunnelProvider
{
private static readonly UTF8Encoding Utf8NoBom = new(false);
private static string OpenVpnWorkDir => Path.Combine(AppTunnel.App.AppDataDir, "openvpn");
private static string TunnelXOpenVpnPidPath => Path.Combine(OpenVpnWorkDir, "tunnelx-openvpn.pid");
private Process? _process;
private int _vpnInterfaceIndex = -1;
private string _routeGatewayIp = "";
private string _connectedRemoteIp = "";
private int _connectedRemotePort;
private string _assignedLocalIp = "";
private readonly ConcurrentQueue<string> _recentOpenVpnOutput = new();
public ConnectionStatus Status { get; } = new();
public async Task<bool> ConnectAsync(ServerConfig config, CancellationToken ct)
{
_vpnInterfaceIndex = -1;
_routeGatewayIp = "";
_connectedRemoteIp = "";
_connectedRemotePort = 0;
_assignedLocalIp = "";
while (_recentOpenVpnOutput.TryDequeue(out _)) { }
Status.State = ConnectionState.Connecting;
Status.Message = "در حال اجرای OpenVPN در حالت Split...";
Logger.Info("[OpenVPN] ConnectAsync started");
try
{
var openVpnExe = ResolveOpenVpnExecutable(config);
if (string.IsNullOrWhiteSpace(openVpnExe))
{
Status.State = ConnectionState.Error;
Status.Message = IsOpenVpnConnectInstalled()
? "فقط OpenVPN Connect پیدا شد. برای Split Tunneling باید OpenVPN Community (openvpn.exe) هم نصب باشد."
: "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 = "کانفیگ 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 = "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 = $"OpenVPN زودتر از اتصال بسته شد (exit={_process.ExitCode})";
return false;
}
Status.Message = $"منتظر بالا آمدن آداپتر OpenVPN... ({remaining}s)";
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 = "آداپتور 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 = "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 = "اتصال لغو شد";
await KillProcessAsync();
return false;
}
catch (Exception ex)
{
Status.State = ConnectionState.Error;
Status.Message = $"خطا: {ex.Message}";
Logger.Error("OpenVpnTunnelProvider.ConnectAsync failed", ex);
await KillProcessAsync();
return false;
}
}
public async Task DisconnectAsync()
{
Status.State = ConnectionState.Disconnecting;
Status.Message = "در حال قطع اتصال 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 = "قطع شد";
}
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 = "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";
}
}
+33 -1
View File
@@ -118,14 +118,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 +161,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 +227,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;
@@ -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
+28 -14
View File
@@ -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.
@@ -25,10 +25,12 @@ public partial class TrafficRouterService
_fullRouteEnabled = true;
InvalidateProcessCaches();
RefreshExcludedDirectRoutes();
Logger.Info($"[FULL-ROUTE] Enabled via VPN IF {_vpnInterfaceIndex}");
return true;
}
RemoveExcludedDirectRoutes();
RemoveFullRouteDefault();
_fullRouteEnabled = false;
MarkPolicyTransitionGrace(TimeSpan.FromSeconds(25));
@@ -355,9 +357,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 = string.IsNullOrWhiteSpace(_vpnGatewayIp) ? "0.0.0.0" : _vpnGatewayIp;
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;
}
+26 -3
View File
@@ -290,14 +290,18 @@ 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.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;
}
@@ -339,6 +343,9 @@ 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 = "قطع شد";
@@ -846,6 +853,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
+7 -2
View File
@@ -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($"نوع تانل ناشناخته: {config.TunnelType}")
};
// Wire up the tunnel-failure watchdog for V2Ray connections.
+33 -1
View File
@@ -119,7 +119,9 @@ public class XrayTunnelProvider : ITunnelProvider
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.Message = "Xray connected";
@@ -158,6 +160,9 @@ 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 = "قطع شد";
@@ -637,6 +642,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
+519 -27
View File
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.NetworkInformation;
@@ -23,6 +24,7 @@ public partial class MainViewModel
if (_connectionState == ConnectionState.Connecting)
{
// Cancel ongoing connection attempt
StatusText = "در حال لغو اتصال...";
_connectionCts?.Cancel();
return;
}
@@ -54,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,101 @@ 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;
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);
}
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 +265,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 +318,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 +353,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 +406,87 @@ 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);
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 +528,88 @@ public partial class MainViewModel
return interfaceIndex > 0 ? $"IF {interfaceIndex}" : "-";
}
private async Task RefreshExitIpAsync(int proxyPort)
{
if (proxyPort <= 0)
{
ConnectionIpText = "-";
return;
}
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
var ip = await QueryPublicIpViaHttpConnectProxyAsync(proxyPort, cts.Token);
ConnectionIpText = string.IsNullOrWhiteSpace(ip) ? "-" : ip;
}
catch (OperationCanceledException)
{
ConnectionIpText = "timeout";
}
catch (Exception ex)
{
Logger.Warning($"[EXIT-IP] Public IP check failed: {ex.Message}");
ConnectionIpText = "-";
}
}
private static async Task<string> QueryPublicIpViaHttpConnectProxyAsync(int proxyPort, CancellationToken ct)
{
const string host = "api.ipify.org";
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 /?format=text 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 +642,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 = $"هیچ remote قابل‌دسترسی نبود ({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 +703,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)
{
@@ -398,7 +720,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 = $"خطا: {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 +851,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))
+379 -12
View File
@@ -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()
{
@@ -37,7 +39,7 @@ public partial class MainViewModel : INotifyPropertyChanged
Application.Current?.Dispatcher.Invoke(() =>
StatusText = $"خطا: {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,9 +65,12 @@ 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));
CopyDonationInfoCommand = new RelayCommand(_ => CopyDonationInfoToClipboard());
@@ -93,6 +100,7 @@ public partial class MainViewModel : INotifyPropertyChanged
LoadIncludes();
LoadHistory();
LoadAppSettings();
RefreshOpenVpnInstallStatus();
_ = CheckForUpdatesAsync(true);
// Auto-connect to last active profile if enabled
@@ -118,7 +126,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 = "";
@@ -283,7 +291,13 @@ public partial class MainViewModel : INotifyPropertyChanged
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;
@@ -298,11 +312,49 @@ public partial class MainViewModel : INotifyPropertyChanged
OnPropertyChanged(nameof(ConnectButtonText));
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
? $"پیش‌نیاز آماده است: نسخه Community اوپن‌وی‌پی‌ان پیدا شد: {OpenVpnDetectedPath}"
: "اخطار: نسخه Community اوپن‌وی‌پی‌ان نصب نیست. برای استفاده از اسپلیت‌تانلینگ با این نوع اتصال، ابتدا آن را از لینک رسمی نصب کنید.";
public string OpenVpnPrerequisiteColor => IsOpenVpnCommunityInstalled ? "#6CCB5F" : "#E0A020";
/// <summary>App version read from a single app-wide source.</summary>
public string AppVersion => AppInfo.VersionText;
@@ -405,9 +457,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,12 +486,180 @@ 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
{
@@ -451,7 +678,7 @@ public partial class MainViewModel : INotifyPropertyChanged
public string SaveStatusText
{
get => _saveStatusText;
set { _saveStatusText = value; OnPropertyChanged(); }
set { _saveStatusText = value; OnPropertyChanged(); OnPropertyChanged(nameof(ProfileSaveHintText)); }
}
private string _connectionDuration = "--:--:--";
@@ -468,6 +695,15 @@ public partial class MainViewModel : INotifyPropertyChanged
set { _vpnIp = value; OnPropertyChanged(); }
}
private string _connectionIpText = "-";
public string ConnectionIpText
{
get => _connectionIpText;
set { _connectionIpText = value; OnPropertyChanged(); }
}
public string ConnectionIpLabel => "IP خروجی";
private string _vpnAdapterName = "";
public string VpnAdapterName
{
@@ -509,8 +745,8 @@ public partial class MainViewModel : INotifyPropertyChanged
public string RouteModeTitle => IsFullRouteEnabled ? "حالت کل سیستم" : "حالت انتخابی";
public string RouteModeDescription => IsFullRouteEnabled
? "همه برنامه‌ها از تونل عبور می‌کنند. برای تست یا وقتی می‌خواهید کل ویندوز پشت VPN باشد مناسب است."
: "فقط برنامه‌های فعال در تب برنامه‌ها و مقصدهای لزومی از تونل عبور می‌کنند. حالت پیشنهادی برای مصرف کمتر و کنترل بهتر.";
? "ترافیک کل سیستم از تونل عبور خواهد کرد؛ برای وقتی مناسب است که همه برنامه‌ها باید پشت تونل باشند."
: "فقط برنامه‌ها و مقصدهای انتخابی از تونل عبور می‌کنند؛ بقیه ترافیک مستقیم می‌ماند.";
public string HeaderCoreText => $"Core: {ActiveCoreName}";
public string HeaderRouteText => IsFullRouteEnabled ? "Mode: Full" : "Mode: Split";
@@ -544,11 +780,17 @@ public partial class MainViewModel : INotifyPropertyChanged
? $"routes {_trafficRouter.ActiveRouteCount}/{_trafficRouter.RouteFailureCount} fail"
: "-";
public string ConnectedBadgeText => CurrentTunnelType == TunnelType.SocksProxy
? "متصل به پراکسی"
: "متصل به VPN";
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,7 +836,22 @@ public partial class MainViewModel : INotifyPropertyChanged
set { _isPinging = value; OnPropertyChanged(); OnPropertyChanged(nameof(PingButtonText)); }
}
public string PingButtonText => _isPinging ? "توقف" : "▶ شروع";
public string PingButtonText => _isPinging ? "توقف تست" : "تست مقصد";
private bool _isTestingConnectedServerPing;
public bool IsTestingConnectedServerPing
{
get => _isTestingConnectedServerPing;
set
{
_isTestingConnectedServerPing = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ConnectedServerPingButtonText));
CommandManager.InvalidateRequerySuggested();
}
}
public string ConnectedServerPingButtonText => IsTestingConnectedServerPing ? "در حال پینگ..." : "پینگ سرور";
private string _pingResult = "";
public string PingResult
@@ -722,15 +979,20 @@ 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 CopyDonationInfoCommand { get; }
@@ -835,7 +1097,13 @@ public partial class MainViewModel : INotifyPropertyChanged
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)
{
@@ -843,6 +1111,48 @@ public partial class MainViewModel : INotifyPropertyChanged
}
}
private void ClearCurrentConfig()
{
if (CurrentTunnelType == TunnelType.OpenVpn)
{
SelectedOpenVpnConfig = "";
SelectedOpenVpnConfigPath = "";
}
else
SelectedV2RayConfig = "";
}
private void BrowseForOpenVpnConfig()
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "انتخاب فایل OpenVPN",
Filter = "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 = $"خواندن فایل OpenVPN ناموفق بود: {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 +1164,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))
{
@@ -871,6 +1203,41 @@ public partial class MainViewModel : INotifyPropertyChanged
: error;
}
private string BuildProxyValidationText()
{
var endpoint = $"{ProxyServerAddress.Trim()}:{ProxyPort}";
return IsLoopbackProxyServer()
? $"پراکسی آماده است: {endpoint} — توجه: این پراکسی محلی است؛ برنامه‌هایی که خودشان مستقیم از همین پراکسی استفاده کنند خارج از لیست برنامه‌های TunnelX هم پروکسی می‌شوند."
: $"پراکسی آماده است: {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;
@@ -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,12 @@ public partial class MainViewModel
set
{
if (_selectedProfile == value) return;
_saveDebounceTimer.Stop();
SaveCurrentProfileState();
_selectedProfile = value;
OnPropertyChanged();
OnPropertyChanged(nameof(SelectedProfileName));
RaiseProfileCardChanged();
if (value != null)
LoadProfileIntoUi(value);
SaveProfiles();
@@ -28,11 +32,44 @@ public partial class MainViewModel
}
public string SelectedProfileName => _selectedProfile?.Name ?? "";
public string ProfileCountText => Profiles.Count == 1
? "۱ پروفایل ذخیره‌شده"
: $"{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",
_ => "نوع اتصال نامشخص"
};
public string ActiveProfileEndpointText => CurrentTunnelType switch
{
TunnelType.L2tpIpsec => string.IsNullOrWhiteSpace(ServerAddress) ? "آدرس سرور هنوز وارد نشده" : ServerAddress.Trim(),
TunnelType.V2Ray => TryExtractProxyEndpoint(SelectedV2RayConfig.Trim(), out var server, out var port, out _)
? $"{server}:{port}"
: "کانفیگ V2Ray/Xray آماده نمایش نیست",
TunnelType.OpenVpn => string.IsNullOrWhiteSpace(SelectedOpenVpnConfigPath)
? "فایل OpenVPN انتخاب نشده"
: Path.GetFileName(SelectedOpenVpnConfigPath),
TunnelType.SocksProxy => string.IsNullOrWhiteSpace(ProxyServerAddress)
? "آدرس پراکسی هنوز وارد نشده"
: $"{ProxyServerAddress.Trim()}:{ProxyPort}",
_ => ""
};
public string ProfileSaveHintText => string.IsNullOrWhiteSpace(SaveStatusText)
? "تغییرات این پروفایل به‌صورت خودکار ذخیره می‌شود"
: 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()
{
@@ -44,6 +81,7 @@ public partial class MainViewModel
foreach (var p in profiles.OrderByDescending(p => p.LastUsedAt))
Profiles.Add(p);
OnPropertyChanged(nameof(ProfileCountText));
_selectedProfile = Profiles[0];
OnPropertyChanged(nameof(SelectedProfile));
@@ -111,6 +149,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 +170,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 +189,199 @@ 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 = $"پروفایل {Profiles.Count + 1}",
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 = $"{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($"پروفایل «{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(ActiveProfileTypeText));
OnPropertyChanged(nameof(ActiveProfileEndpointText));
OnPropertyChanged(nameof(ProfileSaveHintText));
}
#endregion
@@ -246,7 +416,9 @@ public partial class MainViewModel
var entry = new ConnectionHistoryEntry
{
ProfileName = _selectedProfile?.Name ?? "پیش‌فرض",
ServerAddress = ServerAddress,
ServerAddress = CurrentTunnelType == TunnelType.SocksProxy
? $"{ProxyServerAddress}:{ProxyPort}"
: ServerAddress,
ConnectedAt = _connectionStartTime,
DisconnectedAt = DateTime.Now,
BytesSent = totalSent,
+42 -32
View File
@@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid Margin="0,8,0,0">
<Grid Margin="12,8,12,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
@@ -10,25 +10,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 +41,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 -->
@@ -51,8 +55,8 @@
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListView.ItemTemplate>
<DataTemplate>
<Border Background="#11FFFFFF" CornerRadius="8"
Padding="10,8" Margin="0,2" Cursor="Arrow">
<Border Background="#12FFFFFF" BorderBrush="#12FFFFFF" BorderThickness="1" CornerRadius="9"
Padding="9,7" Margin="0,2" Cursor="Arrow">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@@ -64,22 +68,22 @@
<!-- App Icon -->
<Image Grid.Column="0" Source="{Binding Icon}"
Width="28" Height="28" Margin="0,0,10,0"/>
Width="26" Height="26" Margin="0,0,9,0"/>
<!-- App Name & Exe -->
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayName}"
Foreground="{StaticResource TextPrimaryBrush}"
FontSize="13" TextTrimming="CharacterEllipsis"/>
FontSize="12" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding ExecutableName}"
Foreground="{StaticResource TextSecondaryBrush}"
FontSize="10" FlowDirection="LeftToRight"/>
FontSize="9" FlowDirection="LeftToRight"/>
</StackPanel>
<!-- Traffic -->
<TextBlock Grid.Column="2" Text="{Binding TrafficDisplay}"
Foreground="{StaticResource AccentBrush}"
FontSize="11" VerticalAlignment="Center"
FontSize="10" VerticalAlignment="Center"
Margin="8,0" FlowDirection="LeftToRight"/>
<!-- Toggle -->
@@ -92,7 +96,7 @@
<!-- Remove -->
<Button Grid.Column="4" Style="{StaticResource DangerButton}"
Content="" FontSize="14"
Content="حذف" FontSize="10" Padding="7,3"
Command="{Binding DataContext.RemoveAppCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"/>
@@ -119,32 +123,38 @@
</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 -->
@@ -156,8 +166,8 @@
<ListView.ItemTemplate>
<DataTemplate>
<Border x:Name="itemBorder"
Background="#08FFFFFF" CornerRadius="6"
Padding="8,6" Margin="0,1" Cursor="Hand"
Background="#0CFFFFFF" BorderBrush="#10FFFFFF" BorderThickness="1" CornerRadius="9"
Padding="8,6" Margin="0,2" Cursor="Hand"
MouseLeftButtonUp="OnAvailableAppClick">
<Grid>
<Grid.ColumnDefinitions>
@@ -179,9 +189,9 @@
</StackPanel>
<Border Grid.Column="2" x:Name="addBadge"
Background="#25E07820" CornerRadius="4"
Padding="6,2" Margin="4,0,0,0">
<TextBlock Text="" FontSize="11"
Background="#1AE8803A" CornerRadius="7"
Padding="6,2" Margin="6,0,0,0">
<TextBlock Text="افزودن" FontSize="9"
Foreground="{StaticResource PrimaryBrush}"
VerticalAlignment="Center"/>
</Border>
+621 -154
View File
@@ -17,70 +17,304 @@
<!-- ══ 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="RightToLeft">
<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="RightToLeft"
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="RightToLeft">
<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"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,4,0,0"
TextTrimming="CharacterEllipsis">
<Run Text="پروفایل فعال: "/>
<Run Text="{Binding SelectedProfile.Name}" FontWeight="SemiBold"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="2"
Command="{Binding ConnectCommand}"
Content="{Binding ConnectButtonText}"
MinWidth="160"
Height="44"
Padding="18,8"
ToolTip="شروع اتصال با پروفایل انتخاب‌شده">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#10B981"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderBrush" Value="#8CFFFFFF"/>
<Setter Property="BorderThickness" Value="1"/>
<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="#0EA371"/>
<Setter Property="BorderBrush" Value="#CCFFFFFF"/>
</Trigger>
<DataTrigger Binding="{Binding ConnectionState}" Value="{x:Static models:ConnectionState.Connecting}">
<Setter Property="Background" Value="#D97706"/>
<Setter Property="BorderBrush" Value="#80FFE3BF"/>
<Setter Property="ToolTip" Value="لغو تلاش اتصال"/>
</DataTrigger>
<DataTrigger Binding="{Binding ConnectionState}" Value="{x:Static models:ConnectionState.Error}">
<Setter Property="Background" Value="#B91C1C"/>
<Setter Property="BorderBrush" Value="#80FFD2D2"/>
<Setter Property="ToolTip" Value="اتصال مجدد"/>
</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 +329,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 +440,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="RightToLeft"/>
<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 +646,49 @@
</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 SelectedProfile.Name}" FontSize="9"
Foreground="{StaticResource TextSecondaryBrush}"
HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</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 +696,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 +727,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 +742,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 +761,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 +775,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 +840,101 @@
</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="#B42318"/>
<Setter Property="BorderBrush" Value="#F97066"/>
<Setter Property="BorderThickness" Value="1"/>
<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="#F04438"
BlurRadius="16"
Opacity="0.24"
ShadowDepth="0"/>
</Border.Effect>
<Grid FlowDirection="RightToLeft">
<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="#D92D20"/>
<Setter TargetName="DisconnectButtonBorder" Property="BorderBrush" Value="#FDA29B"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="DisconnectButtonBorder" Property="Background" Value="#912018"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Button.Style>
</Button>
</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="RightToLeft"
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="RightToLeft"
Foreground="{StaticResource TextSecondaryBrush}">
<Run Text="📌 "/>
<Run Text="تلگرام، واتس‌اپ و سایر برنامه‌های مایکروسافت استور"
<Run Text="📌 برای تلگرام، واتس‌اپ و برنامه‌های Store، "/>
<Run Text="Microsoft Edge WebView2"
FlowDirection="LeftToRight"
FontWeight="SemiBold"/>
<Run Text=" معمولاً از موتور مرورگر داخلی ویندوز استفاده می‌کنند. برای تونل‌کردن صحیح ترافیک آن‌ها، علاوه‌بر خود برنامه، حتماً "/>
<Run Text="Microsoft Edge WebView2" FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}"/>
<Run Text=" را هم از ستون "/>
<Run Text="«برنامه‌های نصب‌شده»" FontWeight="SemiBold"/>
<Run Text=" به لیست تونل اضافه کنید."/>
<Run Text=" را هم به لیست تونل اضافه کنید."/>
</TextBlock>
</StackPanel>
</Border>
+46
View File
@@ -1,4 +1,5 @@
using System.Windows;
using System.Windows.Input;
using AppTunnel.ViewModels;
namespace AppTunnel.Views;
@@ -19,22 +20,34 @@ public partial class ConnectionTabView : System.Windows.Controls.UserControl
// Wire up PasswordBox (can't bind directly in WPF)
PasswordField.PasswordChanged += OnPasswordFieldChanged;
PskField.PasswordChanged += OnPskFieldChanged;
OpenVpnPasswordField.PasswordChanged += OnOpenVpnPasswordFieldChanged;
ProxyPasswordField.PasswordChanged += OnProxyPasswordFieldChanged;
// When profile changes, update PasswordBox fields
vm.PasswordChanged += OnViewModelPasswordChanged;
vm.OpenVpnPasswordChanged += OnViewModelOpenVpnPasswordChanged;
vm.ProxyPasswordChanged += OnViewModelProxyPasswordChanged;
// Load initial values
PasswordField.Password = vm.Password;
PskField.Password = vm.PreSharedKey;
OpenVpnPasswordField.Password = vm.OpenVpnPassword;
ProxyPasswordField.Password = vm.ProxyPassword;
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
PasswordField.PasswordChanged -= OnPasswordFieldChanged;
PskField.PasswordChanged -= OnPskFieldChanged;
OpenVpnPasswordField.PasswordChanged -= OnOpenVpnPasswordFieldChanged;
ProxyPasswordField.PasswordChanged -= OnProxyPasswordFieldChanged;
if (DataContext is MainViewModel vm)
{
vm.PasswordChanged -= OnViewModelPasswordChanged;
vm.OpenVpnPasswordChanged -= OnViewModelOpenVpnPasswordChanged;
vm.ProxyPasswordChanged -= OnViewModelProxyPasswordChanged;
}
}
private void OnPasswordFieldChanged(object sender, RoutedEventArgs e)
@@ -55,6 +68,18 @@ public partial class ConnectionTabView : System.Windows.Controls.UserControl
}
}
private void OnOpenVpnPasswordFieldChanged(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm && vm.OpenVpnPassword != OpenVpnPasswordField.Password)
vm.OpenVpnPassword = OpenVpnPasswordField.Password;
}
private void OnProxyPasswordFieldChanged(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm && vm.ProxyPassword != ProxyPasswordField.Password)
vm.ProxyPassword = ProxyPasswordField.Password;
}
private void OnViewModelPasswordChanged(string password, string psk)
{
Dispatcher.Invoke(() =>
@@ -64,9 +89,30 @@ public partial class ConnectionTabView : System.Windows.Controls.UserControl
});
}
private void OnViewModelOpenVpnPasswordChanged(string password)
{
Dispatcher.Invoke(() => OpenVpnPasswordField.Password = password);
}
private void OnViewModelProxyPasswordChanged(string password)
{
Dispatcher.Invoke(() => ProxyPasswordField.Password = password);
}
private void OnProfileNameChanged(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
vm.SaveCurrentState();
}
private void OnProfileListPreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
e.Handled = true;
var parentEvent = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta)
{
RoutedEvent = MouseWheelEvent,
Source = sender
};
RaiseEvent(parentEvent);
}
}
+40 -1
View File
@@ -157,7 +157,7 @@
<StackPanel>
<TextBlock Text="۱. پروفایل" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="نوع اتصال را انتخاب کنید و کانفیگ L2TP یا V2Ray/Xray را وارد کنید."
<TextBlock Text="نوع اتصال را انتخاب کنید: L2TP، V2Ray/Xray یا اوپن‌وی‌پی‌ان. برای اسپلیت اوپن‌وی‌پی‌ان، نسخه Community لازم است؛ فایل .ovpn را انتخاب کنید و نام کاربری/رمز را در TunnelX وارد کنید."
FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,5,0,0"/>
</StackPanel>
@@ -196,6 +196,45 @@
</StackPanel>
</Border>
<!-- Connection Types -->
<Border Style="{StaticResource CardPanel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionHeader}" Text="راهنمای نوع اتصال"/>
<TextBlock Text="L2TP/IPsec"
FontSize="12"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="آدرس سرور، نام کاربری، رمز عبور و Pre-Shared Key را وارد کنید؛ TunnelX اتصال ویندوز را ایجاد و مسیرها را مدیریت می‌کند."
FontSize="11"
LineHeight="18"
TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,4,0,10"/>
<TextBlock Text="V2Ray / Xray"
FontSize="12"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="لینک یا کانفیگ V2Ray/Xray را در فیلد کانفیگ وارد کنید. TunnelX با sing-box/Xray تونل را بالا می‌آورد و برنامه‌های انتخابی را از آن عبور می‌دهد."
FontSize="11"
LineHeight="18"
TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,4,0,10"/>
<TextBlock Text="OpenVPN"
FontSize="12"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="TunnelX فایل اوپن‌وی‌پی‌ان را همراه خود توزیع نمی‌کند. برای اسپلیت‌تانلینگ باید نسخه Community نصب باشد، چون TunnelX باید فایل openvpn.exe را با کانفیگ سازگار با اسپلیت اجرا کند. نسخه Connect معمولاً مسیر و DNS را خودش تغییر می‌دهد و برای جدا کردن ترافیک برنامه‌ها مناسب نیست. در تب اتصال فایل .ovpn را انتخاب کنید و اگر سرور نیاز دارد، نام کاربری و رمز را در TunnelX وارد کنید."
FontSize="11"
LineHeight="18"
TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,4,0,0"/>
</StackPanel>
</Border>
<!-- Modes -->
<Grid>
<Grid.ColumnDefinitions>
+26 -26
View File
@@ -2,11 +2,11 @@
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 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 +18,28 @@
</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"
<TextBlock Text="مجموع مصرف تونل: " FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
FlowDirection="RightToLeft"/>
<TextBlock Text="{Binding TotalHistoryData}" FontSize="15"
<TextBlock Text="{Binding TotalHistoryData}" FontSize="13"
FontWeight="Bold"
Foreground="{StaticResource WarningBrush}"/>
</StackPanel>
@@ -48,13 +48,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 +64,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 +86,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 +100,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 +119,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"/>
+309
View File
@@ -0,0 +1,309 @@
<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"
Title="Profile Editor"
Width="500"
Height="560"
MinWidth="480"
MinHeight="520"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
FlowDirection="RightToLeft"
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 Style="{StaticResource ModernTextBox}"
Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Tag="مثلاً کار، تلگرام، گیمینگ..."/>
<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>
+131
View File
@@ -0,0 +1,131 @@
using System.IO;
using System.Windows;
using AppTunnel.Models;
namespace AppTunnel.Views;
public partial class ProfileEditorDialog : Window
{
private readonly ConnectionProfile _profile;
public ProfileEditorDialog(ConnectionProfile profile, string title)
{
_profile = profile;
InitializeComponent();
DataContext = profile;
DialogTitleText.Text = 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 = "انتخاب فایل OpenVPN",
Filter = "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 = $"خواندن فایل OpenVPN ناموفق بود: {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 = $"خواندن کلیپ‌بورد ناموفق بود: {ex.Message}";
}
}
private void OnClearV2RayClick(object sender, RoutedEventArgs e)
{
_profile.V2RayConfig = "";
}
private void OnSaveClick(object sender, RoutedEventArgs e)
{
_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 = "نام پروفایل را وارد کنید";
return false;
}
switch (_profile.TunnelType)
{
case TunnelType.L2tpIpsec when string.IsNullOrWhiteSpace(_profile.ServerAddress):
message = "آدرس سرور L2TP را وارد کنید";
return false;
case TunnelType.V2Ray when string.IsNullOrWhiteSpace(_profile.V2RayConfig):
message = "کانفیگ V2Ray/Xray را وارد کنید";
return false;
case TunnelType.OpenVpn when string.IsNullOrWhiteSpace(_profile.OpenVpnConfig):
message = "فایل OpenVPN (.ovpn) را انتخاب کنید";
return false;
case TunnelType.SocksProxy when string.IsNullOrWhiteSpace(_profile.ProxyServerAddress):
message = "آدرس سرور پراکسی را وارد کنید";
return false;
case TunnelType.SocksProxy when _profile.ProxyPort <= 0 || _profile.ProxyPort > 65535:
message = "پورت پراکسی باید بین 1 تا 65535 باشد";
return false;
}
message = "";
return true;
}
private void OnCancelClick(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}
+44
View File
@@ -2,6 +2,50 @@
## Unreleased
### English
### فارسی
## 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
+17 -6
View File
@@ -6,8 +6,6 @@
<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">Full-route</span> برای عبور کل سیستم از تونل در دسترس است.
@@ -17,10 +15,21 @@
- <span dir="ltr">Split tunneling</span> بر اساس برنامه‌های انتخاب‌شده در ویندوز
- حالت <span dir="ltr">Full-route</span> برای تونل کردن کل سیستم
- پشتیبانی از جریان‌های <span dir="ltr">V2Ray</span> بر پایه <span dir="ltr">Xray-core</span> و <span dir="ltr">sing-box</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">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">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 مربوطه را هم در لیست لزومی قرار دهید.
## تصاویر برنامه
| داشبورد اتصال | تنظیم پروفایل و سرور |
@@ -65,17 +74,19 @@ 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">GPL</span> به کاربران می‌دهد محدود نمی‌کند.
گزینه‌های حمایت و راه‌های تماس از طریق <span dir="ltr">GitHub Sponsors/Funding</span> یا فایل <span dir="ltr">`docs/DONATE.md`</span> در دسترس هستند.
## نکته ایمنی و سلب مسئولیت
+17 -6
View File
@@ -4,17 +4,26 @@
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.
> Status: pre-release. Review the release notes in `docs/BUILD.md` before publishing a public artifact.
## Features
- App-based split tunneling for selected Windows processes
- Full-route mode for whole-system tunneling
- Xray-core / sing-box based V2Ray workflows
- 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
## 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.
## 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.
## Screenshots
| Connection dashboard | Profile and server setup |
@@ -55,17 +64,19 @@ 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.
Use the GitHub funding button or see `docs/DONATE.md` for donation options.
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.
Use the GitHub funding button or see `docs/DONATE.md` for donation and contact options.
## Safety Notice
+11 -1
View File
@@ -6,6 +6,16 @@ For public support after GitHub publication:
- Include the app version, Windows version, selected mode, and a sanitized log.
- Remove server credentials, UUIDs, private keys, and private endpoints from logs before posting.
Commercial or private support is not defined yet.
Private support, deployment help, custom builds, company-specific customization, or development of a similar application may be available as paid services. For paid work or customization requests, contact: `gallafan@gmail.com`.
## پشتیبانی فارسی
<div dir="rtl" align="right">
برای باگ‌های قابل تکرار و درخواست قابلیت، از <span dir="ltr">GitHub Issues</span> استفاده کنید و اطلاعات نسخه برنامه، نسخه ویندوز، حالت انتخاب‌شده و لاگ پاک‌سازی‌شده را قرار دهید.
پشتیبانی خصوصی، راه‌اندازی، بیلد اختصاصی، سفارشی‌سازی برای شرکت‌ها، یا توسعه برنامه‌ای مشابه می‌تواند به صورت خدمات پولی جداگانه انجام شود. برای درخواست کار پولی یا سفارشی‌سازی تماس بگیرید: <span dir="ltr">`gallafan@gmail.com`</span>
</div>
TunnelX is provided for free, as-is. The maintainer has no obligation to provide updates, bug fixes, compatibility updates, release builds, or individual support.
+2
View File
@@ -10,6 +10,7 @@ TunnelX includes or works with third-party software. These notices are part of t
| Wintun | https://www.wintun.net/ | Windows TUN driver DLL | WireGuard project license terms |
| CommunityToolkit.Mvvm | https://github.com/CommunityToolkit/dotnet | MVVM helpers | MIT |
| Microsoft .NET / WPF | https://github.com/dotnet | Runtime and desktop framework | MIT and Microsoft component licenses |
| Vazirmatn | https://github.com/rastikerdar/vazirmatn | Embedded Persian UI font | SIL Open Font License 1.1 |
## Redistributing Binaries
@@ -17,6 +18,7 @@ TunnelX includes or works with third-party software. These notices are part of t
- Keep `AppTunnel/NativeLibs/LICENSE` with WinDivert notices.
- The current repository bundles x64 native components only. Do not advertise 32-bit Windows support unless separate x86 binaries and tests are added.
- Do not remove upstream copyright or license notices.
- Keep the Vazirmatn SIL Open Font License notice when redistributing builds that embed the font.
- If native binaries are updated, refresh this table and the release checklist.
- If crypto, GeoIP, GeoSite, or core binaries are downloaded from upstream releases, record the exact version and source URL in the release notes.
+18
View File
@@ -2,6 +2,24 @@
TunnelX is free and open-source. Donations are optional.
## Paid Services
Private support, deployment help, custom builds, company-specific customization, or development of a similar application may be available as paid services. These services are separate from the GPL license and do not restrict the rights granted by the GPL-covered project.
For paid work or customization requests, contact: `gallafan@gmail.com`
## حمایت مالی و خدمات پولی
<div dir="rtl" align="right">
<span dir="ltr">TunnelX</span> آزاد و رایگان است و حمایت مالی کاملا اختیاری است.
پشتیبانی خصوصی، راه‌اندازی، بیلد اختصاصی، سفارشی‌سازی برای شرکت‌ها، یا توسعه برنامه‌ای مشابه می‌تواند به صورت خدمات پولی جداگانه انجام شود. این خدمات جدا از مجوز <span dir="ltr">GPL</span> هستند و حقوقی را که مجوز <span dir="ltr">GPL</span> به کاربران پروژه می‌دهد محدود نمی‌کنند.
برای درخواست کار پولی یا سفارشی‌سازی تماس بگیرید: <span dir="ltr">`gallafan@gmail.com`</span>
</div>
## PayPal
- PayPal email: `gallafan@gmail.com`
+5 -1
View File
@@ -4,7 +4,11 @@ TunnelX is published as free/open-source software by MaxFan.
## Project License
The project is marked as `GPL-3.0-or-later` to stay compatible with bundled GPL components, especially sing-box. If the release packaging changes and no GPL binary is bundled, the maintainer can revisit the license choice.
The project is marked as `GPL-3.0-or-later` to stay compatible with bundled GPL components, especially sing-box. Commercial use is allowed under the terms of the GPL. If the release packaging changes and no GPL binary is bundled, the maintainer can revisit the license choice.
## Paid Services
The maintainer may offer paid services separately, including private support, deployment help, custom builds, company-specific customization, or development of a similar application. These paid services are separate from the GPL license and do not restrict any rights granted by the GPL-covered source code and releases.
## Third-Party Components