mirror of
https://github.com/MaxiFan/TunnelX.git
synced 2026-05-19 08:04:41 +03:00
Compare commits
24 Commits
v1.2.22
...
a4e90aa2d7
| Author | SHA1 | Date | |
|---|---|---|---|
| a4e90aa2d7 | |||
| 559674a44c | |||
| 8696da6494 | |||
| 2d866e9cba | |||
| aa345b1680 | |||
| 605eb20d23 | |||
| 5f44e10e7b | |||
| f7952e60a9 | |||
| 834b0eff53 | |||
| 75d43dfb7d | |||
| 39e2df207b | |||
| 6d67476af5 | |||
| 10d6334624 | |||
| 0b4b2747d9 | |||
| 8584288b5f | |||
| 6b179063a9 | |||
| 1bcb9a2a31 | |||
| beee09746b | |||
| d70310efd1 | |||
| 247c59c563 | |||
| a51bcbdadd | |||
| 52d970f49e | |||
| 588daa1332 | |||
| b2974cdc95 |
@@ -9,8 +9,8 @@ jobs:
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
- name: Restore
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version, for example 1.2.24 or v1.2.24. Leave empty to bump automatically."
|
||||
required: false
|
||||
type: string
|
||||
bump:
|
||||
description: "Version bump to use when version is empty."
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
draft:
|
||||
description: "Create the GitHub Release as a draft."
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
prerelease:
|
||||
description: "Mark the GitHub Release as a prerelease."
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
windows:
|
||||
name: Prepare and publish Windows release
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Prepare release metadata
|
||||
id: meta
|
||||
shell: pwsh
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
[xml]$project = Get-Content "AppTunnel/AppTunnel.csproj"
|
||||
$currentVersion = [version]$project.Project.PropertyGroup.Version
|
||||
$requestedVersion = "${{ inputs.version }}".Trim()
|
||||
|
||||
if ($requestedVersion) {
|
||||
$requestedVersion = $requestedVersion.TrimStart("v")
|
||||
if ($requestedVersion -notmatch '^\d+\.\d+\.\d+$') {
|
||||
throw "Version must use MAJOR.MINOR.PATCH format. Received: $requestedVersion"
|
||||
}
|
||||
$nextVersion = [version]$requestedVersion
|
||||
}
|
||||
else {
|
||||
$bump = "${{ inputs.bump }}"
|
||||
if ($bump -eq "major") {
|
||||
$nextVersion = [version]::new($currentVersion.Major + 1, 0, 0)
|
||||
}
|
||||
elseif ($bump -eq "minor") {
|
||||
$nextVersion = [version]::new($currentVersion.Major, $currentVersion.Minor + 1, 0)
|
||||
}
|
||||
else {
|
||||
$nextVersion = [version]::new($currentVersion.Major, $currentVersion.Minor, $currentVersion.Build + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if ($nextVersion -le $currentVersion) {
|
||||
throw "Release version ($nextVersion) must be greater than current project version ($currentVersion)."
|
||||
}
|
||||
|
||||
$version = $nextVersion.ToString(3)
|
||||
$tag = "v$version"
|
||||
|
||||
git fetch --tags origin
|
||||
$existingTag = git tag --list $tag
|
||||
if ($existingTag) {
|
||||
throw "Tag $tag already exists."
|
||||
}
|
||||
|
||||
$artifactName = "TunnelX-$tag-standalone-compressed.exe"
|
||||
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
"tag=$tag" >> $env:GITHUB_OUTPUT
|
||||
"artifact_name=$artifactName" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Update version and changelog
|
||||
id: release_notes
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ steps.meta.outputs.version }}"
|
||||
$tag = "${{ steps.meta.outputs.tag }}"
|
||||
$date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd")
|
||||
|
||||
[xml]$project = Get-Content "AppTunnel/AppTunnel.csproj"
|
||||
$project.Project.PropertyGroup.Version = $version
|
||||
$project.Project.PropertyGroup.AssemblyVersion = "$version.0"
|
||||
$project.Project.PropertyGroup.FileVersion = "$version.0"
|
||||
$project.Save((Resolve-Path "AppTunnel/AppTunnel.csproj"))
|
||||
|
||||
$changelogPath = "CHANGELOG.md"
|
||||
$changelog = Get-Content -Raw -LiteralPath $changelogPath
|
||||
if ($changelog -notmatch '(?m)^## Unreleased\s*$') {
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
||||
$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
|
||||
|
||||
"notes_file=$notesFile" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Commit release changes
|
||||
id: release_commit
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = "${{ steps.meta.outputs.tag }}"
|
||||
|
||||
git add AppTunnel/AppTunnel.csproj CHANGELOG.md
|
||||
git commit -m "Prepare release $tag"
|
||||
$releaseSha = git rev-parse HEAD
|
||||
git tag $tag
|
||||
git push origin HEAD:${{ github.ref_name }}
|
||||
git push origin $tag
|
||||
|
||||
"sha=$releaseSha" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore AppTunnel.sln
|
||||
|
||||
- name: Build
|
||||
run: dotnet build AppTunnel.sln -c Release --no-restore
|
||||
|
||||
- name: Publish standalone executable
|
||||
run: >
|
||||
dotnet publish AppTunnel\AppTunnel.csproj
|
||||
-c Release
|
||||
-r win-x64
|
||||
--self-contained true
|
||||
-p:PublishSingleFile=true
|
||||
-p:EnableCompressionInSingleFile=true
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true
|
||||
-p:DebugType=None
|
||||
-p:DebugSymbols=false
|
||||
-o publish\TunnelX
|
||||
|
||||
- name: Package release asset
|
||||
id: package
|
||||
shell: pwsh
|
||||
run: |
|
||||
$source = "publish/TunnelX/TunnelX.exe"
|
||||
$asset = "publish/${{ steps.meta.outputs.artifact_name }}"
|
||||
$checksum = "$asset.sha256"
|
||||
|
||||
if (-not (Test-Path $source)) {
|
||||
throw "Published executable was not found at $source"
|
||||
}
|
||||
|
||||
Move-Item -LiteralPath $source -Destination $asset
|
||||
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $asset).Hash.ToLowerInvariant()
|
||||
"$hash ${{ steps.meta.outputs.artifact_name }}" | Set-Content -Encoding ASCII -LiteralPath $checksum
|
||||
|
||||
"asset=$asset" >> $env:GITHUB_OUTPUT
|
||||
"checksum=$checksum" >> $env:GITHUB_OUTPUT
|
||||
"sha256=$hash" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: TunnelX-${{ steps.meta.outputs.tag }}-win-x64
|
||||
path: |
|
||||
${{ steps.package.outputs.asset }}
|
||||
${{ steps.package.outputs.checksum }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = "${{ steps.meta.outputs.tag }}"
|
||||
$title = "TunnelX $tag"
|
||||
$runUrl = "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
$sha256 = "${{ steps.package.outputs.sha256 }}".ToUpperInvariant()
|
||||
$artifactName = "${{ steps.meta.outputs.artifact_name }}"
|
||||
|
||||
$notes = Get-Content -Raw -LiteralPath "${{ steps.release_notes.outputs.notes_file }}"
|
||||
$provenanceLines = @(
|
||||
"<!-- release-provenance:start -->",
|
||||
"## Build provenance",
|
||||
"",
|
||||
"- Built and uploaded by GitHub Actions.",
|
||||
"- Workflow: ``release``",
|
||||
"- Run: $runUrl",
|
||||
"- Commit: ``${{ steps.release_commit.outputs.sha }}``",
|
||||
"- SHA256: ``$sha256 $artifactName``",
|
||||
"<!-- release-provenance:end -->"
|
||||
)
|
||||
$notes = "$($notes.Trim())`n`n$($provenanceLines -join "`n")"
|
||||
|
||||
$notesFile = Join-Path $env:RUNNER_TEMP "final-release-notes.md"
|
||||
$notes | Set-Content -Encoding UTF8 -LiteralPath $notesFile
|
||||
|
||||
$releaseArgs = @(
|
||||
"release", "create", $tag,
|
||||
"${{ steps.package.outputs.asset }}",
|
||||
"${{ steps.package.outputs.checksum }}",
|
||||
"--title", $title,
|
||||
"--notes-file", $notesFile,
|
||||
"--latest"
|
||||
)
|
||||
|
||||
if ("${{ inputs.draft }}" -eq "true") {
|
||||
$releaseArgs += "--draft"
|
||||
}
|
||||
if ("${{ inputs.prerelease }}" -eq "true") {
|
||||
$releaseArgs += "--prerelease"
|
||||
}
|
||||
|
||||
gh @releaseArgs
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
@@ -21,19 +20,16 @@
|
||||
<PackageProjectUrl>https://github.com/MaxiFan/TunnelX</PackageProjectUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
|
||||
<NeutralLanguage>fa-IR</NeutralLanguage>
|
||||
|
||||
<!-- Version Management -->
|
||||
<Version>1.2.22</Version>
|
||||
<AssemblyVersion>1.2.22.0</AssemblyVersion>
|
||||
<FileVersion>1.2.22.0</FileVersion>
|
||||
<Version>1.2.25</Version>
|
||||
<AssemblyVersion>1.2.25.0</AssemblyVersion>
|
||||
<FileVersion>1.2.25.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- WinDivert native binaries — copied to output directory on build -->
|
||||
<ItemGroup>
|
||||
<None Include="NativeLibs\x64\WinDivert.dll">
|
||||
@@ -78,18 +74,15 @@
|
||||
<Link>wintun.dll</Link>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Embedded Persian Font -->
|
||||
<ItemGroup>
|
||||
<Resource Include="Fonts\Vazirmatn-Regular.ttf" />
|
||||
<Resource Include="app.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Post-Build: no-op for Releases folder.
|
||||
The standalone exe (built via 'dotnet publish') already embeds all native libs
|
||||
(WinDivert.dll, WinDivert64.sys, wintun.dll, sing-box.exe) and extracts them on
|
||||
first run. Nothing needs to be copied to Releases by the regular build. -->
|
||||
<Target Name="CopyVersionedRelease" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
+54
-25
@@ -6,8 +6,8 @@
|
||||
xmlns:vm="clr-namespace:AppTunnel.ViewModels"
|
||||
xmlns:model="clr-namespace:AppTunnel.Models"
|
||||
Title="TunnelX — Split Traffic Per App"
|
||||
Width="580" Height="760"
|
||||
MinWidth="540" MinHeight="680"
|
||||
Width="620" Height="760"
|
||||
MinWidth="580" MinHeight="680"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
WindowStyle="None"
|
||||
AllowsTransparency="True"
|
||||
@@ -169,30 +169,42 @@
|
||||
<TabControl Grid.Row="0" Style="{StaticResource ModernTabControl}" Margin="8,6,8,6">
|
||||
|
||||
<!-- ███ TAB 1: CONNECTION ███ -->
|
||||
<TabItem Style="{StaticResource ModernTabItem}">
|
||||
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="وضعیت اتصال VPN و کنترل اتصال">
|
||||
<TabItem.Header>
|
||||
<TextBlock Text="⚡ اتصال"/>
|
||||
<StackPanel>
|
||||
<TextBlock Text="⚡" FontSize="13" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="اتصال VPN" FontSize="10" TextAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
<views:ConnectionTabView/>
|
||||
</TabItem>
|
||||
<TabItem Style="{StaticResource ModernTabItem}">
|
||||
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="انتخاب برنامههایی که باید از تونل عبور کنند">
|
||||
<TabItem.Header>
|
||||
<TextBlock Text="📱 برنامهها"/>
|
||||
<StackPanel>
|
||||
<TextBlock Text="📱" FontSize="13" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="برنامهها" FontSize="10" TextAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
<views:AppsTabView/>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Style="{StaticResource ModernTabItem}">
|
||||
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="تنظیمات عمومی تونل و DNS">
|
||||
<TabItem.Header>
|
||||
<TextBlock Text="⚙ تنظیمات"/>
|
||||
<StackPanel>
|
||||
<TextBlock Text="⚙" FontSize="13" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="تنظیمات" FontSize="10" TextAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
<views:SettingsTabView/>
|
||||
</TabItem>
|
||||
|
||||
<!-- ███ TAB 3: ROUTING RULES ███ -->
|
||||
<TabItem Style="{StaticResource ModernTabItem}">
|
||||
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="قوانین Include و Exclude مسیرها">
|
||||
<TabItem.Header>
|
||||
<TextBlock Text="🧭 قوانین مسیر"/>
|
||||
<StackPanel>
|
||||
<TextBlock Text="🧭" FontSize="13" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="قوانین مسیر" FontSize="10" TextAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,12,0,0">
|
||||
@@ -424,9 +436,12 @@
|
||||
</TabItem>
|
||||
|
||||
<!-- ███ TAB 5: TRAFFIC MONITOR ███ -->
|
||||
<TabItem Style="{StaticResource ModernTabItem}">
|
||||
<TabItem Style="{StaticResource ModernTabItem}" ToolTip="نمایش ترافیک، تاریخچه و آمار اتصال">
|
||||
<TabItem.Header>
|
||||
<TextBlock Text="📊 ترافیک"/>
|
||||
<StackPanel>
|
||||
<TextBlock Text="📊" FontSize="13" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="ترافیک/تاریخچه" FontSize="9.5" TextAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,12,0,0">
|
||||
@@ -473,7 +488,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="3" HorizontalAlignment="Center">
|
||||
<TextBlock Text="📡 مستقیم" FontSize="11"
|
||||
<TextBlock Text="📡 خارج تونل" FontSize="11"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding DirectTraffic}" FontSize="14"
|
||||
@@ -535,7 +550,7 @@
|
||||
<Border Style="{StaticResource CardPanel}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource SectionHeader}"
|
||||
Text="ترافیک به تفکیک برنامه"/>
|
||||
Text="مصرف تونل به تفکیک برنامه"/>
|
||||
<Grid Margin="0,6,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
@@ -544,7 +559,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Background="#11FFFFFF" CornerRadius="8" Padding="10,7">
|
||||
<StackPanel Orientation="Horizontal" FlowDirection="LeftToRight">
|
||||
<TextBlock Text="برنامهها: " FontSize="11"
|
||||
<TextBlock Text="اپهای تونل: " FontSize="11"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBlock Text="{Binding AppTrafficTotal}" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource AccentBrush}"/>
|
||||
@@ -718,17 +733,31 @@
|
||||
Padding="10,5"
|
||||
FontSize="10"
|
||||
ToolTip="راهنما و عیبیابی"/>
|
||||
<TextBlock Text="ساخته شده توسط Maxi"
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="TunnelX نرمافزاری آزاد و رایگان برای مدیریت تونل و Split Tunneling است؛ ساخته شده توسط MaxFan."
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
FontSize="10" Grid.Column="1"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Left" FlowDirection="LeftToRight">
|
||||
<TextBlock Text="TunnelX" Foreground="{StaticResource TextSecondaryBrush}"
|
||||
FontSize="10" VerticalAlignment="Center"/>
|
||||
<TextBlock Text=" • " Foreground="{StaticResource TextSecondaryBrush}"
|
||||
FontSize="10" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding AppVersion}" Foreground="{StaticResource TextSecondaryBrush}"
|
||||
FontSize="10" VerticalAlignment="Center"/>
|
||||
FontSize="10"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,0"/>
|
||||
<StackPanel Grid.Column="2"
|
||||
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 پروژه"
|
||||
Command="{Binding OpenGitHubCommand}"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Padding="10,5"
|
||||
FontSize="10"
|
||||
ToolTip="باز کردن صفحه GitHub پروژه TunnelX"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media;
|
||||
using AppTunnel.Models;
|
||||
using AppTunnel.ViewModels;
|
||||
using AppTunnel.Helpers;
|
||||
using AppTunnel.Services;
|
||||
@@ -16,6 +17,8 @@ public partial class MainWindow : Window
|
||||
private CancellationTokenSource _loadCts = new();
|
||||
private System.Windows.Forms.NotifyIcon? _trayIcon;
|
||||
private bool _isRealExit;
|
||||
private ConnectionState _lastNotifiedConnectionState = ConnectionState.Disconnected;
|
||||
private bool _updateNotificationShown;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
@@ -69,8 +72,25 @@ public partial class MainWindow : Window
|
||||
statusItem.Text = $"وضعیت: {_viewModel.StatusText}";
|
||||
});
|
||||
}
|
||||
else if (args.PropertyName == nameof(MainViewModel.ConnectionState))
|
||||
{
|
||||
Dispatcher.BeginInvoke(NotifyConnectionStateChanged);
|
||||
}
|
||||
else if (args.PropertyName == nameof(MainViewModel.IsUpdateAvailable))
|
||||
{
|
||||
Dispatcher.BeginInvoke(NotifyUpdateAvailable);
|
||||
}
|
||||
};
|
||||
|
||||
var updateItem = new System.Windows.Forms.ToolStripMenuItem("بررسی بروزرسانی");
|
||||
updateItem.Click += (_, _) =>
|
||||
{
|
||||
BringToForeground();
|
||||
if (_viewModel.CheckForUpdatesCommand.CanExecute(null))
|
||||
_viewModel.CheckForUpdatesCommand.Execute(null);
|
||||
};
|
||||
menu.Items.Add(updateItem);
|
||||
|
||||
menu.Items.Add(new System.Windows.Forms.ToolStripSeparator());
|
||||
|
||||
var exitItem = new System.Windows.Forms.ToolStripMenuItem("خروج از برنامه");
|
||||
@@ -130,12 +150,75 @@ public partial class MainWindow : Window
|
||||
_trayIcon.Visible = true;
|
||||
_trayIcon.ShowBalloonTip(
|
||||
2000,
|
||||
"TunnelX",
|
||||
"برنامه در System Tray فعال است. برای نمایش دوبار کلیک کنید.",
|
||||
"TunnelX در پسزمینه فعال است",
|
||||
"برای باز کردن پنجره، روی آیکن کنار ساعت دوبار کلیک کنید.",
|
||||
System.Windows.Forms.ToolTipIcon.Info);
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyConnectionStateChanged()
|
||||
{
|
||||
if (_trayIcon == null) return;
|
||||
|
||||
var state = _viewModel.ConnectionState;
|
||||
if (state == _lastNotifiedConnectionState) return;
|
||||
_lastNotifiedConnectionState = state;
|
||||
|
||||
switch (state)
|
||||
{
|
||||
case ConnectionState.Connected:
|
||||
ShowTrayNotification("تونل فعال شد", GetConnectedTrayMessage(),
|
||||
System.Windows.Forms.ToolTipIcon.Info);
|
||||
break;
|
||||
case ConnectionState.Disconnected:
|
||||
ShowTrayNotification("تونل خاموش شد", "ارتباط امن متوقف شده و ترافیک دیگر از TunnelX عبور نمیکند.",
|
||||
System.Windows.Forms.ToolTipIcon.Info);
|
||||
break;
|
||||
case ConnectionState.Error:
|
||||
ShowTrayNotification("اتصال برقرار نشد", GetErrorTrayMessage(),
|
||||
System.Windows.Forms.ToolTipIcon.Warning);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyUpdateAvailable()
|
||||
{
|
||||
if (_trayIcon == null || _updateNotificationShown || !_viewModel.IsUpdateAvailable)
|
||||
return;
|
||||
|
||||
_updateNotificationShown = true;
|
||||
ShowTrayNotification("نسخه جدید آماده است",
|
||||
"از منوی System Tray یا بخش بروزرسانی، صفحه دانلود TunnelX را باز کنید.",
|
||||
System.Windows.Forms.ToolTipIcon.Info);
|
||||
}
|
||||
|
||||
private string GetConnectedTrayMessage()
|
||||
{
|
||||
var profileName = _viewModel.SelectedProfileName;
|
||||
if (!string.IsNullOrWhiteSpace(profileName))
|
||||
return $"پروفایل «{profileName}» فعال است و ترافیک انتخابشده از تونل عبور میکند.";
|
||||
|
||||
return "ترافیک انتخابشده از TunnelX عبور میکند.";
|
||||
}
|
||||
|
||||
private string GetErrorTrayMessage()
|
||||
{
|
||||
var status = _viewModel.StatusText?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(status) || status == "خطا")
|
||||
return "جزئیات خطا را در پنجره برنامه یا لاگها بررسی کنید.";
|
||||
|
||||
return status.StartsWith("خطا", StringComparison.Ordinal)
|
||||
? status
|
||||
: $"جزئیات: {status}";
|
||||
}
|
||||
|
||||
private void ShowTrayNotification(string title, string message, System.Windows.Forms.ToolTipIcon icon)
|
||||
{
|
||||
if (_trayIcon == null) return;
|
||||
_trayIcon.Visible = true;
|
||||
_trayIcon.ShowBalloonTip(3500, title, message, icon);
|
||||
}
|
||||
|
||||
private void OnLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Force window to foreground — borderless/transparent windows sometimes
|
||||
|
||||
@@ -28,7 +28,7 @@ public class ConnectionProfile : INotifyPropertyChanged
|
||||
private List<string> _excludedDestinations = new();
|
||||
private TunnelType _tunnelType = TunnelType.L2tpIpsec;
|
||||
private string _v2RayConfig = "";
|
||||
private int _socks5Port = 1080;
|
||||
private int _mixedProxyPort = 1080;
|
||||
private bool _autoTuneMtu = true;
|
||||
private bool _enableDnsOptimization = true;
|
||||
private bool _enableGameMode = false;
|
||||
@@ -110,10 +110,11 @@ public class ConnectionProfile : INotifyPropertyChanged
|
||||
set => SetField(ref _v2RayConfig, value);
|
||||
}
|
||||
|
||||
public int Socks5Port
|
||||
[JsonPropertyName("socks5Port")]
|
||||
public int MixedProxyPort
|
||||
{
|
||||
get => _socks5Port;
|
||||
set => SetField(ref _socks5Port, value);
|
||||
get => _mixedProxyPort;
|
||||
set => SetField(ref _mixedProxyPort, value);
|
||||
}
|
||||
|
||||
public bool AutoTuneMtu
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace AppTunnel.Models;
|
||||
|
||||
/// <summary>
|
||||
/// L2TP/IPsec server connection configuration.
|
||||
/// Server connection configuration (L2TP/IPsec, V2Ray).
|
||||
/// </summary>
|
||||
public class ServerConfig
|
||||
{
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
<!-- Tab header bar with bottom border -->
|
||||
<Border Grid.Row="0" Background="{StaticResource SurfaceBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="0,0,0,1"
|
||||
Padding="4,0">
|
||||
Padding="6,6,6,4">
|
||||
<TabPanel IsItemsHost="True" HorizontalAlignment="Center"/>
|
||||
</Border>
|
||||
<!-- Tab content -->
|
||||
@@ -282,16 +282,22 @@
|
||||
<Style x:Key="ModernTabItem" TargetType="TabItem">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Padding" Value="14,10"/>
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="FontWeight" Value="Normal"/>
|
||||
<Setter Property="Padding" Value="6,6"/>
|
||||
<Setter Property="Width" Value="104"/>
|
||||
<Setter Property="MinHeight" Value="50"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border x:Name="border"
|
||||
Background="Transparent"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Margin="2,0">
|
||||
Margin="2,0,2,2">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
@@ -300,29 +306,33 @@
|
||||
<ContentPresenter Grid.Row="0" ContentSource="Header"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,0,4"/>
|
||||
Margin="0,0,0,5"/>
|
||||
<!-- Bottom indicator line -->
|
||||
<Border x:Name="indicator" Grid.Row="1"
|
||||
Height="2.5" CornerRadius="1.5"
|
||||
Height="3" CornerRadius="1.5"
|
||||
Background="Transparent"
|
||||
HorizontalAlignment="Stretch"
|
||||
Margin="6,0"/>
|
||||
Margin="10,0"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter TargetName="border" Property="Background" Value="#1FE8803A"/>
|
||||
<Setter TargetName="border" Property="BorderBrush" Value="{StaticResource PrimaryBrush}"/>
|
||||
<Setter TargetName="indicator" Property="Background" Value="{StaticResource PrimaryBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="border" Property="Background" Value="#0AFFFFFF"/>
|
||||
<Setter TargetName="border" Property="Background" Value="#10FFFFFF"/>
|
||||
</Trigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsSelected" Value="True"/>
|
||||
<Condition Property="IsMouseOver" Value="True"/>
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="border" Property="Background" Value="#0AFFFFFF"/>
|
||||
<Setter TargetName="border" Property="Background" Value="#29E8803A"/>
|
||||
<Setter TargetName="border" Property="BorderBrush" Value="{StaticResource PrimaryBrush}"/>
|
||||
</MultiTrigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
|
||||
@@ -5,6 +5,7 @@ public static class AppInfo
|
||||
public const string AppName = "TunnelX";
|
||||
public const string CreatorName = "MaxFan";
|
||||
public const string GitHubUrl = "https://github.com/MaxiFan/TunnelX";
|
||||
public const string LatestReleaseUrl = GitHubUrl + "/releases/latest";
|
||||
public const string LicenseName = "GPL-3.0-or-later";
|
||||
public const string PayPalEmail = "gallafan@gmail.com";
|
||||
public const string PayPalDonateUrl = "https://www.paypal.com/donate/?business=gallafan%40gmail.com¤cy_code=USD";
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AppTunnel.Services;
|
||||
|
||||
public sealed record GitHubReleaseInfo(
|
||||
Version Version,
|
||||
string TagName,
|
||||
string Name,
|
||||
string Url,
|
||||
bool IsPrerelease);
|
||||
|
||||
public static class GitHubReleaseChecker
|
||||
{
|
||||
private const string LatestReleaseApi =
|
||||
"https://api.github.com/repos/MaxiFan/TunnelX/releases/latest";
|
||||
|
||||
public static async Task<GitHubReleaseInfo?> GetLatestReleaseAsync(CancellationToken ct)
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("TunnelX");
|
||||
http.Timeout = TimeSpan.FromSeconds(8);
|
||||
|
||||
using var response = await http.GetAsync(LatestReleaseApi, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
|
||||
var root = json.RootElement;
|
||||
|
||||
var tag = root.TryGetProperty("tag_name", out var tagElement)
|
||||
? tagElement.GetString() ?? ""
|
||||
: "";
|
||||
if (!TryParseVersion(tag, out var version))
|
||||
return null;
|
||||
|
||||
var name = root.TryGetProperty("name", out var nameElement)
|
||||
? nameElement.GetString() ?? tag
|
||||
: tag;
|
||||
var url = root.TryGetProperty("html_url", out var urlElement)
|
||||
? urlElement.GetString() ?? AppInfo.LatestReleaseUrl
|
||||
: AppInfo.LatestReleaseUrl;
|
||||
var prerelease = root.TryGetProperty("prerelease", out var preElement) &&
|
||||
preElement.ValueKind == JsonValueKind.True;
|
||||
|
||||
return new GitHubReleaseInfo(version, tag, name, url, prerelease);
|
||||
}
|
||||
|
||||
public static bool TryParseVersion(string value, out Version version)
|
||||
{
|
||||
value = (value ?? "").Trim().TrimStart('v', 'V');
|
||||
return Version.TryParse(value, out version!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AppTunnel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Mixed SOCKS5/HTTP CONNECT proxy that forces every outgoing connection to
|
||||
/// use the VPN adapter by binding the outbound socket to the VPN local IP.
|
||||
/// Protocol auto-detection on the first byte: 0x05 → SOCKS5, 'C' → HTTP CONNECT.
|
||||
/// </summary>
|
||||
internal sealed class MixedProxyServer
|
||||
{
|
||||
private readonly int _listenPort;
|
||||
private TcpListener? _listener;
|
||||
private CancellationTokenSource? _cts;
|
||||
private IPEndPoint? _bindEndpoint;
|
||||
private long _connCount;
|
||||
private long _connActive;
|
||||
private Action<IPAddress>? _ensureRoute;
|
||||
|
||||
public MixedProxyServer(int listenPort = 1080)
|
||||
{
|
||||
_listenPort = listenPort;
|
||||
}
|
||||
|
||||
public bool IsRunning => _listener != null;
|
||||
public long TotalConnections => Interlocked.Read(ref _connCount);
|
||||
public long ActiveConnections => Interlocked.Read(ref _connActive);
|
||||
|
||||
public void Start(string vpnLocalIp, Action<IPAddress>? ensureRoute = null)
|
||||
{
|
||||
if (_listener != null) return;
|
||||
if (!IPAddress.TryParse(vpnLocalIp, out var bindIp))
|
||||
{
|
||||
Logger.Error($"[MIXED] Invalid VPN local IP '{vpnLocalIp}', server not started");
|
||||
return;
|
||||
}
|
||||
_bindEndpoint = new IPEndPoint(bindIp, 0);
|
||||
_ensureRoute = ensureRoute;
|
||||
|
||||
try
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, _listenPort);
|
||||
_listener.Start();
|
||||
_cts = new CancellationTokenSource();
|
||||
Logger.Info($"[MIXED] Listening on 127.0.0.1:{_listenPort}, outbound bind={vpnLocalIp}");
|
||||
_ = Task.Run(() => AcceptLoop(_cts.Token));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"[MIXED] Failed to start listener on port {_listenPort}: {ex.Message}");
|
||||
_listener = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try { _cts?.Cancel(); } catch { }
|
||||
try { _listener?.Stop(); } catch { }
|
||||
_listener = null;
|
||||
_cts = null;
|
||||
}
|
||||
|
||||
private async Task AcceptLoop(CancellationToken ct)
|
||||
{
|
||||
var listener = _listener!;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try
|
||||
{
|
||||
client = await listener.AcceptTcpClientAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"[MIXED] Accept error: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
_ = Task.Run(() => HandleClientAsync(client, ct));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(TcpClient client, CancellationToken ct)
|
||||
{
|
||||
long connId = Interlocked.Increment(ref _connCount);
|
||||
Interlocked.Increment(ref _connActive);
|
||||
try
|
||||
{
|
||||
client.NoDelay = true;
|
||||
using var stream = client.GetStream();
|
||||
stream.ReadTimeout = 15000;
|
||||
stream.WriteTimeout = 15000;
|
||||
|
||||
// Peek first byte to decide protocol
|
||||
var firstByte = new byte[1];
|
||||
int peeked = await stream.ReadAsync(firstByte.AsMemory(0, 1), ct);
|
||||
if (peeked == 0) return;
|
||||
|
||||
if (firstByte[0] == 0x05)
|
||||
{
|
||||
await HandleSocks5Async(stream, firstByte, connId, ct);
|
||||
}
|
||||
else if (firstByte[0] == (byte)'C' || firstByte[0] == (byte)'c')
|
||||
{
|
||||
await HandleHttpConnectAsync(stream, firstByte, connId, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Warning($"[MIXED #{connId}] Unknown first byte 0x{firstByte[0]:X2}, closing");
|
||||
try { client.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"[MIXED #{connId}] error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { client.Dispose(); } catch { }
|
||||
Interlocked.Decrement(ref _connActive);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SOCKS5 handler (existing logic preserved)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
private async Task HandleSocks5Async(NetworkStream stream, byte[] firstByte, long connId, CancellationToken ct)
|
||||
{
|
||||
TcpClient? upstream = null;
|
||||
try
|
||||
{
|
||||
// We already read the first byte (0x05). Now read NMETHODS + methods.
|
||||
var hdr = new byte[1];
|
||||
if (!await ReadExactAsync(stream, hdr, 0, 1, ct)) return;
|
||||
int nMethods = hdr[0];
|
||||
if (nMethods <= 0 || nMethods > 32) return;
|
||||
var methods = new byte[nMethods];
|
||||
if (!await ReadExactAsync(stream, methods, 0, nMethods, ct)) return;
|
||||
|
||||
// No-auth only
|
||||
if (!methods.Contains((byte)0x00))
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { 0x05, 0xFF }, ct);
|
||||
return;
|
||||
}
|
||||
await stream.WriteAsync(new byte[] { 0x05, 0x00 }, ct);
|
||||
|
||||
// Request
|
||||
var req = new byte[4];
|
||||
if (!await ReadExactAsync(stream, req, 0, 4, ct)) return;
|
||||
if (req[0] != 0x05 || req[1] != 0x01)
|
||||
{
|
||||
await WriteSocks5ReplyAsync(stream, 0x07, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
byte atyp = req[3];
|
||||
string host;
|
||||
IPAddress? remoteIp = null;
|
||||
switch (atyp)
|
||||
{
|
||||
case 0x01:
|
||||
{
|
||||
var addr = new byte[4];
|
||||
if (!await ReadExactAsync(stream, addr, 0, 4, ct)) return;
|
||||
remoteIp = new IPAddress(addr);
|
||||
host = remoteIp.ToString();
|
||||
break;
|
||||
}
|
||||
case 0x03:
|
||||
{
|
||||
var lenBuf = new byte[1];
|
||||
if (!await ReadExactAsync(stream, lenBuf, 0, 1, ct)) return;
|
||||
int dlen = lenBuf[0];
|
||||
var dbuf = new byte[dlen];
|
||||
if (!await ReadExactAsync(stream, dbuf, 0, dlen, ct)) return;
|
||||
host = Encoding.ASCII.GetString(dbuf);
|
||||
break;
|
||||
}
|
||||
case 0x04:
|
||||
{
|
||||
var addr = new byte[16];
|
||||
if (!await ReadExactAsync(stream, addr, 0, 16, ct)) return;
|
||||
remoteIp = new IPAddress(addr);
|
||||
host = remoteIp.ToString();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
await WriteSocks5ReplyAsync(stream, 0x08, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var portBuf = new byte[2];
|
||||
if (!await ReadExactAsync(stream, portBuf, 0, 2, ct)) return;
|
||||
int port = (portBuf[0] << 8) | portBuf[1];
|
||||
|
||||
if (remoteIp == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
remoteIp = await DnsResolverCache.ResolveFirstIpv4Async(host, ct);
|
||||
if (remoteIp == null)
|
||||
{
|
||||
Logger.Warning($"[SOCKS5 #{connId}] DNS for '{host}' returned no IPv4");
|
||||
await WriteSocks5ReplyAsync(stream, 0x04, ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception dnsEx)
|
||||
{
|
||||
Logger.Warning($"[SOCKS5 #{connId}] DNS resolve '{host}' failed: {dnsEx.Message}");
|
||||
await WriteSocks5ReplyAsync(stream, 0x04, ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try { _ensureRoute?.Invoke(remoteIp); } catch { }
|
||||
|
||||
upstream = await DialUpstreamAsync(remoteIp, port, connId, ct);
|
||||
if (upstream == null)
|
||||
{
|
||||
await WriteSocks5ReplyAsync(stream, 0x05, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.Info($"[SOCKS5 #{connId}] CONNECT → {host}:{port} (via {_bindEndpoint!.Address})");
|
||||
await WriteSocks5ReplyAsync(stream, 0x00, ct);
|
||||
await RelayAsync(stream, upstream.GetStream(), ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { upstream?.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// HTTP CONNECT handler
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
private async Task HandleHttpConnectAsync(NetworkStream stream, byte[] firstByte, long connId, CancellationToken ct)
|
||||
{
|
||||
TcpClient? upstream = null;
|
||||
try
|
||||
{
|
||||
// firstByte[0] is 'C' or 'c'. Read the rest of the first line.
|
||||
var sb = new StringBuilder(Encoding.ASCII.GetString(firstByte));
|
||||
var lineBuf = new byte[1];
|
||||
// Read until \r\n
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
int n = await stream.ReadAsync(lineBuf.AsMemory(0, 1), ct);
|
||||
if (n == 0) return;
|
||||
char c = (char)lineBuf[0];
|
||||
if (c == '\n')
|
||||
{
|
||||
string line = sb.ToString().TrimEnd('\r');
|
||||
if (string.IsNullOrEmpty(line)) continue; // skip empty lines before request
|
||||
break;
|
||||
}
|
||||
sb.Append(c);
|
||||
if (sb.Length > 4096) return; // line too long
|
||||
}
|
||||
|
||||
string firstLine = sb.ToString().TrimEnd('\r');
|
||||
var parts = firstLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 3 || !parts[0].Equals("CONNECT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await WriteHttpErrorAsync(stream, "400 Bad Request", ct);
|
||||
return;
|
||||
}
|
||||
var target = parts[1];
|
||||
if (!parts[2].StartsWith("HTTP/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await WriteHttpErrorAsync(stream, "400 Bad Request", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read remaining headers until empty line
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var headerSb = new StringBuilder();
|
||||
int emptyLineCount = 0;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
int n = await stream.ReadAsync(lineBuf.AsMemory(0, 1), ct);
|
||||
if (n == 0) return;
|
||||
char c = (char)lineBuf[0];
|
||||
if (c == '\n')
|
||||
{
|
||||
string hline = headerSb.ToString().TrimEnd('\r');
|
||||
if (string.IsNullOrEmpty(hline))
|
||||
{
|
||||
emptyLineCount++;
|
||||
if (emptyLineCount >= 1) break;
|
||||
}
|
||||
else
|
||||
{
|
||||
emptyLineCount = 0;
|
||||
var colonIdx = hline.IndexOf(':');
|
||||
if (colonIdx > 0)
|
||||
{
|
||||
var key = hline[..colonIdx].Trim();
|
||||
var val = hline[(colonIdx + 1)..].Trim();
|
||||
headers[key] = val;
|
||||
}
|
||||
}
|
||||
headerSb.Clear();
|
||||
if (headerSb.Length > 8192) return; // headers too long
|
||||
}
|
||||
else
|
||||
{
|
||||
headerSb.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse target host:port
|
||||
string host;
|
||||
int port;
|
||||
var targetParts = target.Split(':', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (targetParts.Length == 2 && int.TryParse(targetParts[1], out port))
|
||||
{
|
||||
host = targetParts[0];
|
||||
}
|
||||
else
|
||||
{
|
||||
host = target;
|
||||
port = 443;
|
||||
}
|
||||
|
||||
IPAddress? remoteIp = null;
|
||||
if (IPAddress.TryParse(host, out var parsedIp))
|
||||
{
|
||||
remoteIp = parsedIp;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
remoteIp = await DnsResolverCache.ResolveFirstIpv4Async(host, ct);
|
||||
if (remoteIp == null)
|
||||
{
|
||||
Logger.Warning($"[HTTP #{connId}] DNS for '{host}' returned no IPv4");
|
||||
await WriteHttpErrorAsync(stream, "502 Bad Gateway", ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception dnsEx)
|
||||
{
|
||||
Logger.Warning($"[HTTP #{connId}] DNS resolve '{host}' failed: {dnsEx.Message}");
|
||||
await WriteHttpErrorAsync(stream, "502 Bad Gateway", ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try { _ensureRoute?.Invoke(remoteIp); } catch { }
|
||||
|
||||
upstream = await DialUpstreamAsync(remoteIp, port, connId, ct);
|
||||
if (upstream == null)
|
||||
{
|
||||
await WriteHttpErrorAsync(stream, "502 Bad Gateway", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.Info($"[HTTP #{connId}] CONNECT → {host}:{port} (via {_bindEndpoint!.Address})");
|
||||
await WriteHttpAsync(stream, "HTTP/1.1 200 Connection established\r\n\r\n", ct);
|
||||
await RelayAsync(stream, upstream.GetStream(), ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { upstream?.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Shared helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
private async Task<TcpClient?> DialUpstreamAsync(IPAddress remoteIp, int port, long connId, CancellationToken ct)
|
||||
{
|
||||
const int maxAttempts = 2;
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
var client = new TcpClient(AddressFamily.InterNetwork);
|
||||
client.NoDelay = true;
|
||||
try
|
||||
{
|
||||
client.Client.Bind(_bindEndpoint!);
|
||||
}
|
||||
catch (Exception bex)
|
||||
{
|
||||
Logger.Warning($"[MIXED #{connId}] bind to VPN IP failed: {bex.Message}");
|
||||
client.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var dialCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
dialCts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
await client.ConnectAsync(remoteIp, port, dialCts.Token);
|
||||
return client;
|
||||
}
|
||||
catch (Exception dex) when (
|
||||
attempt < maxAttempts &&
|
||||
dex is SocketException sex &&
|
||||
(sex.SocketErrorCode == SocketError.NetworkUnreachable ||
|
||||
sex.SocketErrorCode == SocketError.HostUnreachable))
|
||||
{
|
||||
Logger.Info($"[MIXED #{connId}] connect {remoteIp}:{port} unreachable, retrying...");
|
||||
client.Dispose();
|
||||
await Task.Delay(600, ct);
|
||||
}
|
||||
catch (Exception dex)
|
||||
{
|
||||
Logger.Warning($"[MIXED #{connId}] connect {remoteIp}:{port} failed: {dex.Message}");
|
||||
client.Dispose();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task RelayAsync(NetworkStream clientStream, NetworkStream upstreamStream, CancellationToken ct)
|
||||
{
|
||||
var t1 = PumpAsync(clientStream, upstreamStream, ct);
|
||||
var t2 = PumpAsync(upstreamStream, clientStream, ct);
|
||||
await Task.WhenAny(t1, t2);
|
||||
try { clientStream.Socket?.Shutdown(SocketShutdown.Both); } catch { }
|
||||
try { upstreamStream.Socket?.Shutdown(SocketShutdown.Both); } catch { }
|
||||
await Task.WhenAll(t1, t2);
|
||||
}
|
||||
|
||||
private static async Task WriteSocks5ReplyAsync(NetworkStream s, byte rep, CancellationToken ct)
|
||||
{
|
||||
var buf = new byte[] { 0x05, rep, 0x00, 0x01, 0, 0, 0, 0, 0, 0 };
|
||||
try { await s.WriteAsync(buf, ct); } catch { }
|
||||
}
|
||||
|
||||
private static async Task WriteHttpErrorAsync(NetworkStream s, string status, CancellationToken ct, string extraHeaders = "")
|
||||
{
|
||||
var msg = $"HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n{extraHeaders}\r\n";
|
||||
try { await s.WriteAsync(Encoding.UTF8.GetBytes(msg), ct); } catch { }
|
||||
}
|
||||
|
||||
private static async Task WriteHttpAsync(NetworkStream s, string text, CancellationToken ct)
|
||||
{
|
||||
try { await s.WriteAsync(Encoding.UTF8.GetBytes(text), ct); } catch { }
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadExactAsync(NetworkStream s, byte[] buf, int offset, int count, CancellationToken ct)
|
||||
{
|
||||
int read = 0;
|
||||
while (read < count)
|
||||
{
|
||||
int n = await s.ReadAsync(buf.AsMemory(offset + read, count - read), ct);
|
||||
if (n <= 0) return false;
|
||||
read += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task PumpAsync(NetworkStream src, NetworkStream dst, CancellationToken ct)
|
||||
{
|
||||
var buf = new byte[16384];
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
int n = await src.ReadAsync(buf, ct);
|
||||
if (n <= 0) break;
|
||||
await dst.WriteAsync(buf.AsMemory(0, n), ct);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AppTunnel.Models;
|
||||
|
||||
namespace AppTunnel.Services;
|
||||
@@ -27,6 +28,7 @@ public class ProfileService
|
||||
private static readonly string ExcludesFile = Path.Combine(ProfileDir, "excludes.json");
|
||||
private static readonly string IncludesFile = Path.Combine(ProfileDir, "includes.json");
|
||||
private static readonly string TunnelAppsFile = Path.Combine(ProfileDir, "tunnelapps.json");
|
||||
private static readonly string AppSettingsFile = Path.Combine(ProfileDir, "appsettings.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -51,6 +53,45 @@ public class ProfileService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load global application settings from disk.
|
||||
/// </summary>
|
||||
public AppSettings LoadAppSettings()
|
||||
{
|
||||
if (!File.Exists(AppSettingsFile))
|
||||
return new AppSettings();
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(AppSettingsFile, Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<AppSettings>(json, JsonOptions) ?? new AppSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AppSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save global application settings to disk.
|
||||
/// </summary>
|
||||
public void SaveAppSettings(AppSettings settings)
|
||||
{
|
||||
Directory.CreateDirectory(ProfileDir);
|
||||
var json = JsonSerializer.Serialize(settings, JsonOptions);
|
||||
File.WriteAllText(AppSettingsFile, json, Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global application settings (startup + auto-connect preferences).
|
||||
/// </summary>
|
||||
public class AppSettings
|
||||
{
|
||||
public bool StartWithWindows { get; set; } = false;
|
||||
public bool AutoConnectOnStartup { get; set; } = false;
|
||||
public string? LastActiveProfileId { get; set; } = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load all saved profiles from disk.
|
||||
/// </summary>
|
||||
@@ -77,7 +118,7 @@ public class ProfileService
|
||||
PreSharedKey = DecryptString(s.EncryptedPsk),
|
||||
TunnelType = s.TunnelType,
|
||||
V2RayConfig = s.V2RayConfig,
|
||||
Socks5Port = s.Socks5Port > 0 ? s.Socks5Port : 1080,
|
||||
MixedProxyPort = s.Socks5Port > 0 ? s.Socks5Port : 1080,
|
||||
AutoTuneMtu = s.AutoTuneMtu,
|
||||
EnableDnsOptimization = s.EnableDnsOptimization,
|
||||
EnableGameMode = s.EnableGameMode
|
||||
@@ -108,7 +149,7 @@ public class ProfileService
|
||||
EncryptedPsk = EncryptString(p.PreSharedKey),
|
||||
TunnelType = p.TunnelType,
|
||||
V2RayConfig = p.V2RayConfig,
|
||||
Socks5Port = p.Socks5Port,
|
||||
Socks5Port = p.MixedProxyPort,
|
||||
AutoTuneMtu = p.AutoTuneMtu,
|
||||
EnableDnsOptimization = p.EnableDnsOptimization,
|
||||
EnableGameMode = p.EnableGameMode
|
||||
@@ -165,6 +206,7 @@ public class ProfileService
|
||||
public string EncryptedPsk { get; set; } = "";
|
||||
public TunnelType TunnelType { get; set; } = TunnelType.L2tpIpsec;
|
||||
public string V2RayConfig { get; set; } = "";
|
||||
[JsonPropertyName("socks5Port")]
|
||||
public int Socks5Port { get; set; } = 1080;
|
||||
public bool AutoTuneMtu { get; set; } = true;
|
||||
public bool EnableDnsOptimization { get; set; } = true;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace AppTunnel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Parses proxy URLs like socks5://user:pass@host:port or http://host:port.
|
||||
/// </summary>
|
||||
public record ParsedProxyUrl(
|
||||
string Scheme,
|
||||
string Host,
|
||||
int Port,
|
||||
string? Username,
|
||||
string? Password);
|
||||
|
||||
public static class ProxyUrlParser
|
||||
{
|
||||
public static ParsedProxyUrl? Parse(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var scheme = uri.Scheme.ToLowerInvariant();
|
||||
if (scheme != "socks5" && scheme != "http") return null;
|
||||
|
||||
var host = uri.Host;
|
||||
var port = uri.Port > 0 ? uri.Port : (scheme == "http" ? 8080 : 1080);
|
||||
|
||||
string? user = null, pass = null;
|
||||
if (!string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
var parts = uri.UserInfo.Split(':', 2);
|
||||
user = Uri.UnescapeDataString(parts[0]);
|
||||
if (parts.Length == 2)
|
||||
pass = Uri.UnescapeDataString(parts[1]);
|
||||
}
|
||||
|
||||
return new ParsedProxyUrl(scheme, host, port, user, pass);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips the [::ffff:] prefix and port from an IPEndPoint string, returning the IPv4 address.
|
||||
/// </summary>
|
||||
public static string? ExtractIPv4(string? endPoint)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endPoint)) return null;
|
||||
var s = endPoint.Trim();
|
||||
// Strip [::ffff:] prefix
|
||||
const string v6Prefix = "[::ffff:";
|
||||
if (s.StartsWith(v6Prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// [::ffff:1.2.3.4]:port → 1.2.3.4
|
||||
var inner = s[v6Prefix.Length..];
|
||||
var close = inner.IndexOf(']');
|
||||
if (close < 0) return null;
|
||||
inner = inner[..close];
|
||||
return IPAddress.TryParse(inner, out _) ? inner : null;
|
||||
}
|
||||
// Plain "1.2.3.4:port" or just "1.2.3.4"
|
||||
var colon = s.IndexOf(':');
|
||||
if (colon > 0) s = s[..colon];
|
||||
return IPAddress.TryParse(s, out _) ? s : null;
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,6 @@ namespace AppTunnel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Routes traffic from selected applications through the VPN tunnel using WinDivert.
|
||||
///
|
||||
/// How it works:
|
||||
/// 1. WinDivert captures all outbound TCP/UDP packets
|
||||
/// 2. For each packet, we look up the owning process via GetExtendedTcpTable/GetExtendedUdpTable
|
||||
/// 3. If the process is in our target list, we set the outbound interface to the VPN adapter
|
||||
/// 4. The packet is re-injected with the modified interface
|
||||
///
|
||||
/// This approach is used by many commercial VPN apps for split tunneling.
|
||||
/// </summary>
|
||||
public partial class TrafficRouterService : IDisposable
|
||||
{
|
||||
@@ -155,7 +147,7 @@ public partial class TrafficRouterService : IDisposable
|
||||
/// </summary>
|
||||
public bool EnableSocks5 { get; set; } = true;
|
||||
|
||||
/// <summary>Listener port for the built-in SOCKS5 proxy.</summary>
|
||||
/// <summary>Listener port for the built-in mixed proxy (SOCKS5 + HTTP).</summary>
|
||||
public int Socks5Port { get; set; } = 1080;
|
||||
|
||||
/// <summary>
|
||||
@@ -184,7 +176,7 @@ public partial class TrafficRouterService : IDisposable
|
||||
private uint _dnsRedirectIpNbo = BitConverter.ToUInt32(new byte[] { 8, 8, 8, 8 }, 0);
|
||||
private byte[] _dnsRedirectIpBytes = new byte[] { 8, 8, 8, 8 };
|
||||
|
||||
private Socks5Server? _socks5;
|
||||
private MixedProxyServer? _mixedProxy;
|
||||
|
||||
#pragma warning disable CS0067
|
||||
public event Action<string, long, long>? TrafficUpdated;
|
||||
@@ -240,19 +232,19 @@ public partial class TrafficRouterService : IDisposable
|
||||
Interlocked.Read(ref _totalVpnBytesReceived));
|
||||
|
||||
/// <summary>
|
||||
/// Returns the sum of tunnel traffic attributed to currently-tracked apps.
|
||||
/// Returns the sum of tunnel traffic attributed to app counters during the
|
||||
/// current connection. This intentionally includes apps that were disabled
|
||||
/// later in the same session, so per-app totals remain consistent with the
|
||||
/// history and total tunnel counters.
|
||||
/// </summary>
|
||||
public (long sent, long received) GetTrackedAppsTraffic()
|
||||
{
|
||||
long sent = 0;
|
||||
long received = 0;
|
||||
foreach (var appName in _targetExecutables.Keys)
|
||||
foreach (var counter in _trafficCounters.Values)
|
||||
{
|
||||
if (_trafficCounters.TryGetValue(appName, out var counter))
|
||||
{
|
||||
sent += Interlocked.Read(ref counter.BytesSent);
|
||||
received += Interlocked.Read(ref counter.BytesReceived);
|
||||
}
|
||||
sent += Interlocked.Read(ref counter.BytesSent);
|
||||
received += Interlocked.Read(ref counter.BytesReceived);
|
||||
}
|
||||
return (sent, received);
|
||||
}
|
||||
@@ -293,9 +285,11 @@ public partial class TrafficRouterService : IDisposable
|
||||
public long LeakCount => Interlocked.Read(ref _statLeakConfirmed);
|
||||
/// <summary>
|
||||
/// Number of attempted leaks blocked locally by leak-guard.
|
||||
/// Diagnostic-only signal for policy-transition races.
|
||||
/// Diagnostic-only signal; these packets did not escape the machine.
|
||||
/// </summary>
|
||||
public long LeakBlockedCount => Interlocked.Read(ref _statLeakBlocked);
|
||||
public long LeakBlockedRecoveredCount => Interlocked.Read(ref _statLeakBlockedRecovered);
|
||||
public long LeakBlockedSuppressedCount => Interlocked.Read(ref _statLeakBlockedSuppressed);
|
||||
public long Ipv6BlockedCount => Interlocked.Read(ref _statFlowIPv6Blocked);
|
||||
public long DnsRedirectCount => Interlocked.Read(ref _redirectCount);
|
||||
public long ActiveRouteCount => _addedRoutes.Count;
|
||||
@@ -513,13 +507,11 @@ public partial class TrafficRouterService : IDisposable
|
||||
_networkOutTask = Task.Run(() => NetworkOutboundLoop(_cts.Token));
|
||||
_networkInTask = Task.Run(() => NetworkInboundLoop(_cts.Token));
|
||||
|
||||
// Optional SOCKS5 proxy: apps with native SOCKS5 support (Telegram,
|
||||
// Firefox, ...) can point at 127.0.0.1:1080 and get guaranteed VPN
|
||||
// egress without relying on host routes.
|
||||
// Optional mixed SOCKS5/HTTP proxy
|
||||
if (EnableSocks5)
|
||||
{
|
||||
_socks5 = new Socks5Server(Socks5Port);
|
||||
_socks5.Start(vpnLocalIp, EnsureHostRouteForSocks5);
|
||||
_mixedProxy = new MixedProxyServer(Socks5Port);
|
||||
_mixedProxy.Start(vpnLocalIp, EnsureHostRouteForSocks5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,13 +532,13 @@ public partial class TrafficRouterService : IDisposable
|
||||
long netOutFail = Interlocked.Read(ref _statNetOutSendFailed);
|
||||
string mode = _fullRouteEnabled ? "full-route" : "split";
|
||||
string leakState = leakConfirmed > 0 ? "LEAK-DETECTED" :
|
||||
(leakBlocked > 0 ? "BLOCKING-ATTEMPTS" : "OK");
|
||||
(leakBlocked > 0 ? "PROTECTED" : "OK");
|
||||
Logger.Info(
|
||||
$"[STATS] mode={mode} health={leakState} " +
|
||||
$"flows={flowEst}/{flowDel} targetHit={flowHit} excluded={flowExcl} ipv6Drop={ipv6Blocked} " +
|
||||
$"routes={Interlocked.Read(ref _statRoutesAdded)}({Interlocked.Read(ref _statRoutesFailed)}fail)/{_addedRoutes.Count}active " +
|
||||
$"rewriteOut={netOutRw} rewriteIn={netInRw} rewriteFail={netOutFail} nat={_natTable.Count} " +
|
||||
$"leakConfirmed={leakConfirmed} leakBlocked={leakBlocked} recovered={leakBlockedRecovered} suppressed={leakBlockedSuppressed} " +
|
||||
$"leakConfirmed={leakConfirmed} protectedBlocked={leakBlocked} recovered={leakBlockedRecovered} suppressed={leakBlockedSuppressed} " +
|
||||
$"targets={_targetExecutables.Count} blockedApps={_blockedExecutables.Count}");
|
||||
|
||||
// Loop health check — warn if any background loop has exited unexpectedly
|
||||
@@ -758,8 +750,8 @@ public partial class TrafficRouterService : IDisposable
|
||||
_statsTimer?.Dispose();
|
||||
_statsTimer = null;
|
||||
|
||||
try { _socks5?.Stop(); } catch { }
|
||||
_socks5 = null;
|
||||
try { _mixedProxy?.Stop(); } catch { }
|
||||
_mixedProxy = null;
|
||||
|
||||
// Cancel all pending delayed route removals.
|
||||
foreach (var kvp in _pendingRouteRemoval)
|
||||
|
||||
@@ -121,7 +121,7 @@ public partial class TrafficRouterService
|
||||
for (int hop = 0; hop < 8 && current > 4; hop++)
|
||||
{
|
||||
var name = GetProcessNameByPid(current);
|
||||
if (IsExecutableTargeted(name))
|
||||
if (!string.IsNullOrWhiteSpace(name) && IsExecutableTargeted(name))
|
||||
{
|
||||
_pidTargetOwnerCache[pid] = name;
|
||||
return name;
|
||||
|
||||
@@ -75,7 +75,7 @@ public partial class TrafficRouterService
|
||||
if (TryParseConnectionTuple(buffer, readLen, out var tuple))
|
||||
{
|
||||
var processName = connectionCache.GetProcessName(tuple);
|
||||
if (IsExecutableTargeted(processName))
|
||||
if (!string.IsNullOrWhiteSpace(processName) && IsExecutableTargeted(processName))
|
||||
{
|
||||
shouldRedirect = true;
|
||||
matchedProcess = processName;
|
||||
|
||||
@@ -235,11 +235,11 @@ public partial class TrafficRouterService
|
||||
|
||||
leakLogCount++;
|
||||
if (recovered)
|
||||
Logger.Warning($"[LEAK-BLOCKED] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked locally, route restored for retransmit via VPN");
|
||||
Logger.Info($"[LEAK-PROTECTED] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked locally, route restored for retransmit via VPN");
|
||||
else if (graceSuppressed)
|
||||
Logger.Info($"[LEAK-BLOCKED-TRANSITION] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked during policy transition grace");
|
||||
Logger.Info($"[LEAK-PROTECTED-TRANSITION] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked during policy transition grace");
|
||||
else
|
||||
Logger.Warning($"[LEAK-BLOCKED] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked by split policy, route not restored");
|
||||
Logger.Info($"[LEAK-PROTECTED] Packet with VPN srcIP exiting PHYSICAL ifIdx={addrBuf.IfIdx} → dst={dst} (proto={buffer[9]}) — blocked by split policy, route not restored");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@ public partial class TrafficRouterService
|
||||
}
|
||||
|
||||
isBlockedProc = !_fullRouteEnabled && IsExecutableBlocked(packetProc);
|
||||
if (!shouldRoute && !isBlockedProc && IsExecutableTargeted(packetProc))
|
||||
if (!shouldRoute && !isBlockedProc &&
|
||||
!string.IsNullOrWhiteSpace(packetProc) &&
|
||||
IsExecutableTargeted(packetProc))
|
||||
{
|
||||
shouldRoute = true;
|
||||
_ipToProcess[dstNbo] = packetProc;
|
||||
@@ -202,7 +204,7 @@ public partial class TrafficRouterService
|
||||
dnsProc = connCache.GetProcessName(tuple2);
|
||||
}
|
||||
|
||||
if (IsExecutableTargeted(dnsProc))
|
||||
if (!string.IsNullOrWhiteSpace(dnsProc) && IsExecutableTargeted(dnsProc))
|
||||
{
|
||||
uint publicDnsNbo = _dnsRedirectIpNbo;
|
||||
|
||||
@@ -275,7 +277,7 @@ public partial class TrafficRouterService
|
||||
}
|
||||
|
||||
// Check if source app is in target tunnel apps
|
||||
if (IsExecutableTargeted(procName))
|
||||
if (!string.IsNullOrWhiteSpace(procName) && IsExecutableTargeted(procName))
|
||||
{
|
||||
shouldRoute = true;
|
||||
}
|
||||
|
||||
@@ -397,6 +397,15 @@ public class V2RayTunnelProvider : ITunnelProvider
|
||||
{
|
||||
(outbound, outboundTag) = ParseShadowsocks(userConfig);
|
||||
}
|
||||
else if (userConfig.StartsWith("socks5://") ||
|
||||
userConfig.StartsWith("socks://"))
|
||||
{
|
||||
(outbound, outboundTag) = ParseSocks5(userConfig);
|
||||
}
|
||||
else if (userConfig.StartsWith("http://"))
|
||||
{
|
||||
(outbound, outboundTag) = ParseHttp(userConfig);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
@@ -748,6 +757,75 @@ public class V2RayTunnelProvider : ITunnelProvider
|
||||
return (outbound, tag);
|
||||
}
|
||||
|
||||
private static (JsonObject outbound, string tag) ParseSocks5(string uri)
|
||||
{
|
||||
var u = new Uri(uri);
|
||||
var tag = Uri.UnescapeDataString(u.Fragment.TrimStart('#').Trim());
|
||||
if (string.IsNullOrEmpty(tag)) tag = "socks5-out";
|
||||
|
||||
var outbound = new JsonObject
|
||||
{
|
||||
["type"] = "socks",
|
||||
["tag"] = tag,
|
||||
["server"] = u.Host,
|
||||
["server_port"] = u.Port > 0 ? u.Port : 1080,
|
||||
["version"] = "5"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(u.UserInfo))
|
||||
{
|
||||
var userInfo = Uri.UnescapeDataString(u.UserInfo);
|
||||
var colonIdx = userInfo.IndexOf(':');
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
outbound["users"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["username"] = userInfo[..colonIdx],
|
||||
["password"] = userInfo[(colonIdx + 1)..]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (outbound, tag);
|
||||
}
|
||||
|
||||
private static (JsonObject outbound, string tag) ParseHttp(string uri)
|
||||
{
|
||||
var u = new Uri(uri);
|
||||
var tag = Uri.UnescapeDataString(u.Fragment.TrimStart('#').Trim());
|
||||
if (string.IsNullOrEmpty(tag)) tag = "http-out";
|
||||
|
||||
var outbound = new JsonObject
|
||||
{
|
||||
["type"] = "http",
|
||||
["tag"] = tag,
|
||||
["server"] = u.Host,
|
||||
["server_port"] = u.Port > 0 ? u.Port : 3128
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(u.UserInfo))
|
||||
{
|
||||
var userInfo = Uri.UnescapeDataString(u.UserInfo);
|
||||
var colonIdx = userInfo.IndexOf(':');
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
outbound["users"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["username"] = userInfo[..colonIdx],
|
||||
["password"] = userInfo[(colonIdx + 1)..]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (outbound, tag);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
@@ -11,10 +11,6 @@ public class VpnService
|
||||
private ITunnelProvider? _activeProvider;
|
||||
private readonly ConnectionStatus _defaultStatus = new();
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the active V2Ray tunnel collapses unexpectedly.
|
||||
/// Set this before calling ConnectAsync; it is forwarded to V2RayTunnelProvider.
|
||||
/// </summary>
|
||||
public Action? OnTunnelFailed { get; set; }
|
||||
|
||||
/// <summary>Live status, forwarded from the active provider.</summary>
|
||||
@@ -42,9 +38,5 @@ public class VpnService
|
||||
await _activeProvider.DisconnectAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the active provider's network interface is still operational.
|
||||
/// Mirrors the health-check used by the connection monitor.
|
||||
/// </summary>
|
||||
public bool IsInterfaceUp() => _activeProvider?.IsInterfaceUp() ?? false;
|
||||
}
|
||||
|
||||
@@ -54,10 +54,10 @@ public partial class MainViewModel
|
||||
StatusText = "کانفیگ V2Ray را وارد کنید";
|
||||
return;
|
||||
}
|
||||
if (!ValidateSocks5Port(out var socksError))
|
||||
if (!ValidateMixedProxyPort(out var socksError))
|
||||
{
|
||||
StatusText = socksError;
|
||||
Socks5PortStatusText = socksError;
|
||||
MixedProxyPortStatusText = socksError;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ public partial class MainViewModel
|
||||
Username = Username.Trim(),
|
||||
Password = Password,
|
||||
PreSharedKey = PreSharedKey,
|
||||
TunnelType = _currentTunnelType,
|
||||
V2RayConfig = _selectedV2RayConfig,
|
||||
AutoTuneMtu = AutoTuneMtu,
|
||||
EnableDnsOptimization = IsDnsOptimizationEnabled,
|
||||
EnableGameMode = IsGameModeEnabled
|
||||
@@ -113,6 +115,7 @@ public partial class MainViewModel
|
||||
VpnIp = _vpnService.Status.VpnLocalIp;
|
||||
VpnAdapterName = ResolveInterfaceName(_vpnService.Status.VpnInterfaceIndex);
|
||||
_connectionStartTime = DateTime.Now;
|
||||
LastActiveProfileId = _selectedProfile?.Id;
|
||||
RaiseHealthStatusChanged();
|
||||
|
||||
// Start traffic routing for enabled apps
|
||||
@@ -128,7 +131,7 @@ public partial class MainViewModel
|
||||
// Load user's exclude list (domains/IPs to bypass tunnel)
|
||||
_trafficRouter.SetExcludedDestinations(ExcludedDestinations);
|
||||
_trafficRouter.SetIncludedDestinations(IncludedDestinations);
|
||||
_trafficRouter.Socks5Port = Socks5Port;
|
||||
_trafficRouter.Socks5Port = MixedProxyPort;
|
||||
_trafficRouter.EnableDnsOptimization = IsDnsOptimizationEnabled;
|
||||
_trafficRouter.EnableGameMode = IsGameModeEnabled;
|
||||
|
||||
@@ -279,9 +282,9 @@ public partial class MainViewModel
|
||||
app.BytesReceived = received;
|
||||
}
|
||||
|
||||
// Total tunnel usage: use the authoritative VPN-interface counter,
|
||||
// and also expose the attributed per-app sum so UI can show both
|
||||
// values without ambiguity.
|
||||
// Total tunnel usage: use the authoritative VPN-interface counter.
|
||||
// Every visible "usage" counter in the app is based on tunneled bytes;
|
||||
// direct/outside-tunnel bytes are kept only as a diagnostic signal.
|
||||
var (totalSent, totalReceived) = _trafficRouter.GetTotalVpnTraffic();
|
||||
long vpnTotal = totalSent + totalReceived;
|
||||
TotalTraffic = FormatBytes(vpnTotal);
|
||||
@@ -376,10 +379,10 @@ public partial class MainViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var ms = await MeasureEndpointLatencyAsync(endpoint, cts.Token);
|
||||
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} {ms} ms";
|
||||
ServerPingResult = $"{mode} {endpoint.Server}:{endpoint.Port} {ms2} ms";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -480,6 +483,16 @@ public partial class MainViewModel
|
||||
return true;
|
||||
}
|
||||
|
||||
// SOCKS5 / HTTP proxy URIs
|
||||
if (config.StartsWith("socks5://", StringComparison.OrdinalIgnoreCase) ||
|
||||
config.StartsWith("socks://", StringComparison.OrdinalIgnoreCase) ||
|
||||
config.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var uri = new Uri(config.Split('#')[0]);
|
||||
endpoint = new ProxyEndpoint(uri.Host, uri.Port > 0 ? uri.Port : 1080, false, null);
|
||||
return ValidateEndpoint(endpoint.Server, endpoint.Port, out error);
|
||||
}
|
||||
|
||||
if (config.StartsWith("{"))
|
||||
{
|
||||
var root = JsonNode.Parse(config)?.AsObject();
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Runtime.CompilerServices;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using Microsoft.Win32;
|
||||
using Application = System.Windows.Application;
|
||||
using System.Windows.Threading;
|
||||
using AppTunnel.Models;
|
||||
@@ -23,6 +24,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
private readonly DispatcherTimer _saveDebounceTimer;
|
||||
private CancellationTokenSource? _connectionCts;
|
||||
private DateTime _connectionStartTime;
|
||||
private ProfileService.AppSettings _appSettings = new();
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
@@ -65,6 +67,8 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
OpenGitHubCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.GitHubUrl));
|
||||
OpenDonateCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.PayPalDonateUrl));
|
||||
CopyDonationInfoCommand = new RelayCommand(_ => CopyDonationInfoToClipboard());
|
||||
CheckForUpdatesCommand = new RelayCommand(_ => _ = CheckForUpdatesAsync(false), _ => !IsCheckingForUpdates);
|
||||
OpenLatestReleaseCommand = new RelayCommand(_ => OpenExternalLink(LatestReleaseUrl), _ => !string.IsNullOrWhiteSpace(LatestReleaseUrl));
|
||||
|
||||
_trafficRouter.TrafficUpdated += OnTrafficUpdated;
|
||||
|
||||
@@ -88,6 +92,24 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
LoadExcludes();
|
||||
LoadIncludes();
|
||||
LoadHistory();
|
||||
LoadAppSettings();
|
||||
_ = CheckForUpdatesAsync(true);
|
||||
|
||||
// Auto-connect to last active profile if enabled
|
||||
if (_appSettings.AutoConnectOnStartup && !string.IsNullOrEmpty(_appSettings.LastActiveProfileId))
|
||||
{
|
||||
var lastProfile = Profiles.FirstOrDefault(p => p.Id == _appSettings.LastActiveProfileId);
|
||||
if (lastProfile != null)
|
||||
{
|
||||
SelectedProfile = lastProfile;
|
||||
_ = ToggleConnectionAsync().ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
Application.Current?.Dispatcher.Invoke(() =>
|
||||
StatusText = $"خطای اتصال خودکار: {t.Exception?.InnerException?.Message}");
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region Properties
|
||||
@@ -120,55 +142,55 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
set { _preSharedKey = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private int _socks5Port = 1080;
|
||||
public int Socks5Port
|
||||
private int _mixedProxyPort = 1080;
|
||||
public int MixedProxyPort
|
||||
{
|
||||
get => _socks5Port;
|
||||
get => _mixedProxyPort;
|
||||
set
|
||||
{
|
||||
var normalized = value;
|
||||
if (_socks5Port == normalized) return;
|
||||
_socks5Port = normalized;
|
||||
if (_mixedProxyPort == normalized) return;
|
||||
_mixedProxyPort = normalized;
|
||||
_trafficRouter.Socks5Port = normalized;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(Socks5PortText));
|
||||
OnPropertyChanged(nameof(Socks5Info));
|
||||
UpdateSocks5PortStatus();
|
||||
OnPropertyChanged(nameof(MixedProxyPortText));
|
||||
OnPropertyChanged(nameof(MixedProxyInfo));
|
||||
UpdateMixedProxyPortStatus();
|
||||
SaveCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
public string Socks5PortText
|
||||
public string MixedProxyPortText
|
||||
{
|
||||
get => _socks5Port.ToString();
|
||||
get => _mixedProxyPort.ToString();
|
||||
set
|
||||
{
|
||||
if (int.TryParse((value ?? "").Trim(), out var port))
|
||||
{
|
||||
if (_socks5Port != port)
|
||||
if (_mixedProxyPort != port)
|
||||
{
|
||||
_socks5Port = port;
|
||||
_mixedProxyPort = port;
|
||||
_trafficRouter.Socks5Port = port;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(Socks5Port));
|
||||
OnPropertyChanged(nameof(Socks5Info));
|
||||
UpdateSocks5PortStatus();
|
||||
OnPropertyChanged(nameof(MixedProxyPort));
|
||||
OnPropertyChanged(nameof(MixedProxyInfo));
|
||||
UpdateMixedProxyPortStatus();
|
||||
SaveCurrentState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Socks5PortStatusText = string.IsNullOrWhiteSpace(value)
|
||||
MixedProxyPortStatusText = string.IsNullOrWhiteSpace(value)
|
||||
? "پورت SOCKS5 را وارد کنید"
|
||||
: "فقط عدد مجاز است";
|
||||
}
|
||||
}
|
||||
|
||||
private string _socks5PortStatusText = "";
|
||||
public string Socks5PortStatusText
|
||||
private string _mixedProxyPortStatusText = "";
|
||||
public string MixedProxyPortStatusText
|
||||
{
|
||||
get => _socks5PortStatusText;
|
||||
set { _socks5PortStatusText = value; OnPropertyChanged(); }
|
||||
get => _mixedProxyPortStatusText;
|
||||
set { _mixedProxyPortStatusText = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private bool _autoTuneMtu = true;
|
||||
@@ -217,6 +239,46 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
? "Game Mode فعال است: Route نگهداری طولانیتر، DNS سریعتر و DSCP برای بستههای بازی اعمال میشود."
|
||||
: "Game Mode غیرفعال است: حالت متعادل برای مصرف عمومی.";
|
||||
|
||||
private bool _startWithWindows;
|
||||
public bool StartWithWindows
|
||||
{
|
||||
get => _startWithWindows;
|
||||
set
|
||||
{
|
||||
if (_startWithWindows == value) return;
|
||||
_startWithWindows = value;
|
||||
OnPropertyChanged();
|
||||
UpdateStartupRegistry(value);
|
||||
_appSettings.StartWithWindows = value;
|
||||
_profileService.SaveAppSettings(_appSettings);
|
||||
}
|
||||
}
|
||||
|
||||
private bool _autoConnectOnStartup;
|
||||
public bool AutoConnectOnStartup
|
||||
{
|
||||
get => _autoConnectOnStartup;
|
||||
set
|
||||
{
|
||||
if (_autoConnectOnStartup == value) return;
|
||||
_autoConnectOnStartup = value;
|
||||
OnPropertyChanged();
|
||||
_appSettings.AutoConnectOnStartup = value;
|
||||
_profileService.SaveAppSettings(_appSettings);
|
||||
}
|
||||
}
|
||||
|
||||
public string? LastActiveProfileId
|
||||
{
|
||||
get => _appSettings.LastActiveProfileId;
|
||||
set
|
||||
{
|
||||
if (_appSettings.LastActiveProfileId == value) return;
|
||||
_appSettings.LastActiveProfileId = value;
|
||||
_profileService.SaveAppSettings(_appSettings);
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isBusy;
|
||||
public bool IsBusy
|
||||
{
|
||||
@@ -251,6 +313,59 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
public string DonatePayPalText => $"پیپل: {AppInfo.PayPalEmail}";
|
||||
public string CryptoDonationText => AppInfo.CryptoDonationText;
|
||||
|
||||
private bool _isCheckingForUpdates;
|
||||
public bool IsCheckingForUpdates
|
||||
{
|
||||
get => _isCheckingForUpdates;
|
||||
set
|
||||
{
|
||||
if (_isCheckingForUpdates == value) return;
|
||||
_isCheckingForUpdates = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(UpdateButtonText));
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isUpdateAvailable;
|
||||
public bool IsUpdateAvailable
|
||||
{
|
||||
get => _isUpdateAvailable;
|
||||
set
|
||||
{
|
||||
if (_isUpdateAvailable == value) return;
|
||||
_isUpdateAvailable = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private string _updateStatusText = "برای بررسی نسخه جدید، دکمه بررسی بروزرسانی را بزنید.";
|
||||
public string UpdateStatusText
|
||||
{
|
||||
get => _updateStatusText;
|
||||
set
|
||||
{
|
||||
if (_updateStatusText == value) return;
|
||||
_updateStatusText = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private string _latestReleaseUrl = AppInfo.LatestReleaseUrl;
|
||||
public string LatestReleaseUrl
|
||||
{
|
||||
get => _latestReleaseUrl;
|
||||
set
|
||||
{
|
||||
if (_latestReleaseUrl == value) return;
|
||||
_latestReleaseUrl = value;
|
||||
OnPropertyChanged();
|
||||
CommandManager.InvalidateRequerySuggested();
|
||||
}
|
||||
}
|
||||
|
||||
public string UpdateButtonText => IsCheckingForUpdates ? "در حال بررسی..." : "بررسی بروزرسانی";
|
||||
|
||||
public string ConnectButtonText => _connectionState switch
|
||||
{
|
||||
ConnectionState.Disconnected => "🔌 اتصال",
|
||||
@@ -403,20 +518,20 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
? (_trafficRouter.LeakCount == 0
|
||||
? (_trafficRouter.LeakBlockedCount == 0
|
||||
? "Leak: OK"
|
||||
: $"Leak: OK (blocked {_trafficRouter.LeakBlockedCount})")
|
||||
: $"Leak: Protected {_trafficRouter.LeakBlockedCount}")
|
||||
: $"Leak: {_trafficRouter.LeakCount}")
|
||||
: "Leak: -";
|
||||
public string HeaderLeakColor => !IsConnected
|
||||
? "#6CCB5F"
|
||||
: _trafficRouter.LeakCount > 0
|
||||
? "#E05252"
|
||||
: (_trafficRouter.LeakBlockedCount > 0 ? "#E07820" : "#6CCB5F");
|
||||
: "#6CCB5F";
|
||||
|
||||
public string HealthLeakText => IsConnected
|
||||
? (_trafficRouter.LeakCount == 0
|
||||
? (_trafficRouter.LeakBlockedCount == 0
|
||||
? "0 leak"
|
||||
: $"0 leak / {_trafficRouter.LeakBlockedCount} blocked")
|
||||
: $"0 leak / {_trafficRouter.LeakBlockedCount} protected")
|
||||
: $"{_trafficRouter.LeakCount} leak")
|
||||
: "-";
|
||||
public string HealthDnsText => IsConnected
|
||||
@@ -512,7 +627,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
|
||||
public int EnabledAppsCount => TunnelApps.Count(a => a.IsEnabled);
|
||||
|
||||
public string Socks5Info => $"127.0.0.1:{_trafficRouter.Socks5Port}";
|
||||
public string MixedProxyInfo => $"127.0.0.1:{_trafficRouter.Socks5Port}";
|
||||
|
||||
// Exclude list
|
||||
public ObservableCollection<string> ExcludedDestinations { get; } = new();
|
||||
@@ -619,6 +734,8 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
public ICommand OpenGitHubCommand { get; }
|
||||
public ICommand OpenDonateCommand { get; }
|
||||
public ICommand CopyDonationInfoCommand { get; }
|
||||
public ICommand CheckForUpdatesCommand { get; }
|
||||
public ICommand OpenLatestReleaseCommand { get; }
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -658,6 +775,61 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckForUpdatesAsync(bool silent)
|
||||
{
|
||||
if (IsCheckingForUpdates) return;
|
||||
|
||||
try
|
||||
{
|
||||
IsCheckingForUpdates = true;
|
||||
if (!silent)
|
||||
UpdateStatusText = "در حال بررسی آخرین نسخه در GitHub...";
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var latest = await GitHubReleaseChecker.GetLatestReleaseAsync(cts.Token);
|
||||
if (latest == null)
|
||||
{
|
||||
if (!silent)
|
||||
UpdateStatusText = "بررسی نسخه جدید ناموفق بود. اتصال اینترنت یا GitHub را بررسی کنید.";
|
||||
Logger.Warning("[UPDATE] Latest release check failed");
|
||||
return;
|
||||
}
|
||||
|
||||
LatestReleaseUrl = latest.Url;
|
||||
var currentVersion = System.Reflection.Assembly.GetExecutingAssembly()
|
||||
.GetName().Version ?? new Version(0, 0, 0);
|
||||
var current = new Version(currentVersion.Major, currentVersion.Minor, currentVersion.Build);
|
||||
|
||||
if (latest.Version > current)
|
||||
{
|
||||
IsUpdateAvailable = true;
|
||||
UpdateStatusText = $"نسخه جدید آماده است: {latest.TagName} - برای دانلود از GitHub باز کنید.";
|
||||
Logger.Info($"[UPDATE] New version available: current={current} latest={latest.TagName}");
|
||||
return;
|
||||
}
|
||||
|
||||
IsUpdateAvailable = false;
|
||||
UpdateStatusText = $"TunnelX بهروز است. نسخه فعلی: {AppInfo.VersionText}";
|
||||
Logger.Info($"[UPDATE] App is up to date: current={current} latest={latest.TagName}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (!silent)
|
||||
UpdateStatusText = "بررسی بروزرسانی به زمان مجاز نرسید.";
|
||||
Logger.Warning("[UPDATE] Latest release check timed out");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!silent)
|
||||
UpdateStatusText = $"بررسی بروزرسانی ناموفق بود: {ex.Message}";
|
||||
Logger.Warning($"[UPDATE] Latest release check failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsCheckingForUpdates = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void PasteConfigFromClipboard()
|
||||
{
|
||||
try
|
||||
@@ -673,7 +845,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
|
||||
private void UpdateConfigDiagnostics()
|
||||
{
|
||||
if (CurrentTunnelType != TunnelType.V2Ray)
|
||||
if (CurrentTunnelType == TunnelType.L2tpIpsec)
|
||||
{
|
||||
ConfigCoreHint = "L2TP/IPsec";
|
||||
ConfigValidationText = string.IsNullOrWhiteSpace(ServerAddress)
|
||||
@@ -699,9 +871,9 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
: error;
|
||||
}
|
||||
|
||||
private bool ValidateSocks5Port(out string message)
|
||||
private bool ValidateMixedProxyPort(out string message)
|
||||
{
|
||||
var port = _socks5Port;
|
||||
var port = _mixedProxyPort;
|
||||
if (port < 1024 || port > 65535)
|
||||
{
|
||||
message = "پورت باید بین 1024 تا 65535 باشد";
|
||||
@@ -738,10 +910,10 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
return true;
|
||||
}
|
||||
|
||||
private void UpdateSocks5PortStatus()
|
||||
private void UpdateMixedProxyPortStatus()
|
||||
{
|
||||
ValidateSocks5Port(out var message);
|
||||
Socks5PortStatusText = message;
|
||||
ValidateMixedProxyPort(out var message);
|
||||
MixedProxyPortStatusText = message;
|
||||
}
|
||||
|
||||
private void TryAutoNameProfileFromConfig(string config)
|
||||
@@ -805,6 +977,49 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
OnPropertyChanged(nameof(HealthRoutesText));
|
||||
}
|
||||
|
||||
private void LoadAppSettings()
|
||||
{
|
||||
_appSettings = _profileService.LoadAppSettings();
|
||||
_startWithWindows = _appSettings.StartWithWindows;
|
||||
_autoConnectOnStartup = _appSettings.AutoConnectOnStartup;
|
||||
OnPropertyChanged(nameof(StartWithWindows));
|
||||
OnPropertyChanged(nameof(AutoConnectOnStartup));
|
||||
}
|
||||
|
||||
private static void UpdateStartupRegistry(bool enable)
|
||||
{
|
||||
const string runKey = "Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
||||
const string appName = "TunnelX";
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(runKey, writable: true);
|
||||
if (key == null) return;
|
||||
|
||||
if (enable)
|
||||
{
|
||||
var exePath = Environment.ProcessPath ??
|
||||
Process.GetCurrentProcess().MainModule?.FileName ??
|
||||
System.IO.Path.Combine(AppContext.BaseDirectory, "TunnelX.exe");
|
||||
key.SetValue(appName, $"\"{exePath}\"");
|
||||
|
||||
System.Windows.MessageBox.Show(
|
||||
"استارتآپ فعال شد.\n\n⚠️ برای کارکرد صحیح، پس از این نباید محل فایل اجرایی TunnelX را تغییر دهید.",
|
||||
"TunnelX — استارتآپ",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (key.GetValue(appName) != null)
|
||||
key.DeleteValue(appName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"[STARTUP] Registry update failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region INotifyPropertyChanged
|
||||
|
||||
@@ -111,7 +111,7 @@ public partial class MainViewModel
|
||||
_selectedProfile.PreSharedKey = PreSharedKey;
|
||||
_selectedProfile.TunnelType = _currentTunnelType;
|
||||
_selectedProfile.V2RayConfig = SelectedV2RayConfig;
|
||||
_selectedProfile.Socks5Port = Socks5Port;
|
||||
_selectedProfile.MixedProxyPort = MixedProxyPort;
|
||||
_selectedProfile.AutoTuneMtu = AutoTuneMtu;
|
||||
_selectedProfile.EnableDnsOptimization = IsDnsOptimizationEnabled;
|
||||
_selectedProfile.EnableGameMode = IsGameModeEnabled;
|
||||
@@ -145,12 +145,12 @@ public partial class MainViewModel
|
||||
Username = profile.Username;
|
||||
Password = profile.Password;
|
||||
PreSharedKey = profile.PreSharedKey;
|
||||
_socks5Port = profile.Socks5Port > 0 ? profile.Socks5Port : 1080;
|
||||
_trafficRouter.Socks5Port = _socks5Port;
|
||||
OnPropertyChanged(nameof(Socks5Port));
|
||||
OnPropertyChanged(nameof(Socks5PortText));
|
||||
OnPropertyChanged(nameof(Socks5Info));
|
||||
UpdateSocks5PortStatus();
|
||||
_mixedProxyPort = profile.MixedProxyPort > 0 ? profile.MixedProxyPort : 1080;
|
||||
_trafficRouter.Socks5Port = _mixedProxyPort;
|
||||
OnPropertyChanged(nameof(MixedProxyPort));
|
||||
OnPropertyChanged(nameof(MixedProxyPortText));
|
||||
OnPropertyChanged(nameof(MixedProxyInfo));
|
||||
UpdateMixedProxyPortStatus();
|
||||
_autoTuneMtu = profile.AutoTuneMtu;
|
||||
_isDnsOptimizationEnabled = profile.EnableDnsOptimization;
|
||||
_isGameModeEnabled = profile.EnableGameMode;
|
||||
@@ -174,7 +174,7 @@ public partial class MainViewModel
|
||||
private void CreateNewProfile()
|
||||
{
|
||||
SaveCurrentProfileState();
|
||||
var profile = new ConnectionProfile { Name = $"پروفایل {Profiles.Count + 1}", Socks5Port = Socks5Port };
|
||||
var profile = new ConnectionProfile { Name = $"پروفایل {Profiles.Count + 1}", MixedProxyPort = MixedProxyPort };
|
||||
profile.AutoTuneMtu = AutoTuneMtu;
|
||||
profile.EnableDnsOptimization = IsDnsOptimizationEnabled;
|
||||
profile.EnableGameMode = IsGameModeEnabled;
|
||||
@@ -196,7 +196,7 @@ public partial class MainViewModel
|
||||
PreSharedKey = _selectedProfile.PreSharedKey,
|
||||
TunnelType = _selectedProfile.TunnelType,
|
||||
V2RayConfig = _selectedProfile.V2RayConfig,
|
||||
Socks5Port = _selectedProfile.Socks5Port,
|
||||
MixedProxyPort = _selectedProfile.MixedProxyPort,
|
||||
AutoTuneMtu = _selectedProfile.AutoTuneMtu,
|
||||
EnableDnsOptimization = _selectedProfile.EnableDnsOptimization,
|
||||
EnableGameMode = _selectedProfile.EnableGameMode,
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
</StackPanel>
|
||||
<!-- End V2Ray fields -->
|
||||
|
||||
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,8,0,0">
|
||||
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,8,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
@@ -297,7 +297,7 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Traffic -->
|
||||
<!-- Tunnel Traffic -->
|
||||
<Border Grid.Column="4" Background="#11FFFFFF" CornerRadius="10" Padding="10,10"
|
||||
ToolTip="مجموع ترافیک ارسال و دریافت عبوری از تونل VPN (کل تونل)">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
@@ -312,23 +312,23 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Server -->
|
||||
<!-- Direct diagnostic -->
|
||||
<Border Grid.Column="6" Background="#11FFFFFF" CornerRadius="10" Padding="10,10"
|
||||
ToolTip="ترافیک مستقیم خارج از تونل: شامل برنامههای غیرفعال، IPها و دامنههای وایتلیستشده و ترافیک سیستم">
|
||||
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"
|
||||
FlowDirection="LeftToRight"/>
|
||||
<TextBlock Text="مستقیم" FontSize="9"
|
||||
<TextBlock Text="خارج تونل" FontSize="9"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Apps + SOCKS5 in one row -->
|
||||
<!-- Apps + Mixed Proxy in one row -->
|
||||
<Grid Margin="0,6,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
@@ -357,9 +357,9 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- SOCKS5 Proxy -->
|
||||
<!-- Mixed Proxy -->
|
||||
<Border Grid.Column="2" Background="#11FFFFFF" CornerRadius="10" Padding="12,10"
|
||||
ToolTip="پروکسی SOCKS5 داخلی روی 127.0.0.1:1080 — برای برنامههایی که از VPN تشخیص داده نمیشوند میتوانید پروکسی را دستی تنظیم کنید">
|
||||
ToolTip="پروکسی Mixed داخلی روی 127.0.0.1:1080 — برای برنامههایی که از VPN تشخیص داده نمیشوند میتوانید پروکسی را دستی تنظیم کنید">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
@@ -367,11 +367,11 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="🧦" FontSize="16" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Text="SOCKS5" FontSize="11"
|
||||
<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 Socks5Info}" FontSize="11" FontWeight="SemiBold"
|
||||
<TextBlock Text="{Binding MixedProxyInfo}" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource AccentBrush}"
|
||||
FlowDirection="LeftToRight"/>
|
||||
</Border>
|
||||
|
||||
@@ -102,6 +102,41 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Updates -->
|
||||
<Border Style="{StaticResource CardPanel}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Style="{StaticResource SectionHeader}" Text="بروزرسانی"/>
|
||||
<TextBlock Text="{Binding UpdateStatusText}"
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="18"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2"
|
||||
Orientation="Vertical"
|
||||
VerticalAlignment="Center"
|
||||
MinWidth="150">
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="{Binding UpdateButtonText}"
|
||||
Command="{Binding CheckForUpdatesCommand}"
|
||||
Padding="14,8"/>
|
||||
<Button Style="{StaticResource PrimaryButton}"
|
||||
Content="باز کردن صفحه انتشار"
|
||||
Command="{Binding OpenLatestReleaseCommand}"
|
||||
Padding="14,8"
|
||||
Margin="0,8,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Quick Path -->
|
||||
<Border Style="{StaticResource CardPanel}">
|
||||
<StackPanel>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<StackPanel Grid.Row="0" Grid.Column="0">
|
||||
<TextBlock Text="📜 تاریخچه اتصالات" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="سوابق اتصالات قبلی شما در این بخش نمایش داده میشود"
|
||||
<TextBlock Text="سوابق مصرف تونل در اتصالهای قبلی نمایش داده میشود"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
FontSize="11" Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
@@ -36,7 +36,7 @@
|
||||
Padding="14,10" Margin="0,12,0,0">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"
|
||||
FlowDirection="LeftToRight">
|
||||
<TextBlock Text="📊 مجموع مصرف داده: " FontSize="13"
|
||||
<TextBlock Text="📊 مجموع مصرف تونل: " FontSize="13"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
FlowDirection="RightToLeft"/>
|
||||
<TextBlock Text="{Binding TotalHistoryData}" FontSize="15"
|
||||
@@ -92,7 +92,7 @@
|
||||
<TextBlock Text="{Binding DurationText}"
|
||||
Foreground="{StaticResource SuccessBrush}"
|
||||
FontSize="11"/>
|
||||
<TextBlock Text=" • 📊 " FontSize="11"
|
||||
<TextBlock Text=" • 📊 تونل " FontSize="11"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBlock Text="{Binding TotalDataText}"
|
||||
Foreground="{StaticResource WarningBrush}"
|
||||
|
||||
@@ -19,20 +19,20 @@
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="پورت SOCKS5"
|
||||
Text="پورت پروکسی محلی (SOCKS5/HTTP)"
|
||||
FontSize="11"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="2"
|
||||
Style="{StaticResource ModernTextBox}"
|
||||
Text="{Binding Socks5PortText, UpdateSourceTrigger=PropertyChanged}"
|
||||
Text="{Binding MixedProxyPortText, UpdateSourceTrigger=PropertyChanged}"
|
||||
FlowDirection="LeftToRight"
|
||||
FontSize="12"
|
||||
Padding="8,5"
|
||||
ToolTip="پورت داخلی 127.0.0.1 برای پروکسی SOCKS5"/>
|
||||
ToolTip="پورت داخلی 127.0.0.1 برای پروکسی SOCKS5 و HTTP"/>
|
||||
<TextBlock Grid.Column="4"
|
||||
Text="{Binding Socks5PortStatusText}"
|
||||
Text="{Binding MixedProxyPortStatusText}"
|
||||
FontSize="10"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
@@ -43,6 +43,7 @@
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,6,0,0"/>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -112,6 +113,52 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Startup & Auto-Connect -->
|
||||
<Border Style="{StaticResource CardPanel}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource SectionHeader}" Text="🖥️ استارتآپ و اتصال خودکار"
|
||||
Margin="0,0,0,4"/>
|
||||
|
||||
<Grid Margin="0,2,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="اجرای خودکار هنگام روشن شدن ویندوز" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="⚠️ پس از فعال کردن، نباید محل فایل اجرایی TunnelX را تغییر دهید."
|
||||
FontSize="10" Foreground="{StaticResource WarningBrush}"
|
||||
TextWrapping="Wrap" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Column="1"
|
||||
Style="{StaticResource ToggleSwitch}"
|
||||
IsChecked="{Binding StartWithWindows, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
|
||||
<Border Background="#18FFFFFF" Height="1" Margin="0,8,0,8"/>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="اتصال خودکار به آخرین کانکشن فعال" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="اگر آخرین بار یک پروفایل متصل بوده، هنگام اجرای برنامه به آن وصل میشود."
|
||||
FontSize="10" Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Column="1"
|
||||
Style="{StaticResource ToggleSwitch}"
|
||||
IsChecked="{Binding AutoConnectOnStartup, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.2.25 - 2026-05-16
|
||||
|
||||
- Merge pull request #13 from BlacKSnowDot0/pr-clean
|
||||
- Merge pull request #16 from mohammad-parvizi-dev/main
|
||||
- Improve tab headers, theme styling, and tray notifications
|
||||
- Add startup and auto-connect app settings
|
||||
- feat(proxy): SOCKS5/HTTP via V2Ray/sing-box, add MixedProxyServer, remove standalone proxy types and local auth
|
||||
|
||||
## 1.2.24 - 2026-05-12
|
||||
|
||||
- Added README screenshots in English and Persian.
|
||||
- Added automated GitHub Actions release publishing with version bumping, changelog-based release notes, checksums, and build provenance.
|
||||
|
||||
## 1.2.23
|
||||
|
||||
- Added GitHub release checking from the Help tab.
|
||||
- Added automatic tray notification when a newer release is available.
|
||||
- Added tray notifications for connection, disconnection, and connection errors.
|
||||
- Added a tray menu action for checking updates.
|
||||
- Moved remaining future VPN-manager improvements into the public roadmap.
|
||||
|
||||
## 1.2.22
|
||||
|
||||
- Fixed Help page data binding so GitHub and donation buttons work.
|
||||
@@ -15,3 +36,5 @@
|
||||
- Added in-app GitHub and donation links.
|
||||
- Added project metadata for MaxFan and GPL-3.0-or-later licensing.
|
||||
- Improved leak logging and traffic accounting in recent internal builds.
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<div dir="rtl" align="right">
|
||||
|
||||
# TunnelX
|
||||
|
||||
فارسی | <span dir="ltr">[English](README.md)</span>
|
||||
|
||||
<span dir="ltr">TunnelX</span> یک نرمافزار آزاد و رایگان برای ویندوز است که توسط **<span dir="ltr">MaxFan</span>** ساخته شده و برای مدیریت تونل، ویپیان و <span dir="ltr">Split Tunneling</span> استفاده میشود. این برنامه میتواند ترافیک برنامههای انتخابشده، مقصدهای مشخص، یا کل سیستم را از تونل عبور دهد و همزمان مسیر عادی شبکه را برای مقصدهای محلی یا مستثنیشده حفظ کند.
|
||||
|
||||
> وضعیت پروژه: پیشانتشار. قبل از انتشار عمومی فایل اجرایی، نکات ساخت و انتشار را در <span dir="ltr">`docs/BUILD.md`</span> بررسی کنید.
|
||||
|
||||
## کاربرد برنامه
|
||||
|
||||
<span dir="ltr">TunnelX</span> برای زمانی ساخته شده که کاربر نمیخواهد تمام ترافیک سیستم از ویپیان عبور کند. با این برنامه میتوان فقط برنامههایی مثل مرورگر، تلگرام، ابزارهای توسعه یا برنامههای مشخص دیگر را وارد تونل کرد و بقیه ترافیک سیستم را روی اینترنت عادی نگه داشت. همچنین در صورت نیاز، حالت <span dir="ltr">Full-route</span> برای عبور کل سیستم از تونل در دسترس است.
|
||||
|
||||
## قابلیتها
|
||||
|
||||
- <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">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> و تاریخچه مصرف تونل
|
||||
- رابط کاربری فارسیمحور برای ویندوز
|
||||
|
||||
## تصاویر برنامه
|
||||
|
||||
| داشبورد اتصال | تنظیم پروفایل و سرور |
|
||||
| --- | --- |
|
||||
| <img src="docs/ScreenShots/Screenshot%202026-05-12%20115349.png" alt="داشبورد اتصال TunnelX"> | <img src="docs/ScreenShots/Screenshot%202026-05-12%20115544.png" alt="تنظیم پروفایل و سرور TunnelX"> |
|
||||
|
||||
| انتخاب برنامهها برای تونل | تنظیمات تونل |
|
||||
| --- | --- |
|
||||
| <img src="docs/ScreenShots/Screenshot%202026-05-12%20115646.png" alt="انتخاب برنامهها برای تونل در TunnelX"> | <img src="docs/ScreenShots/Screenshot%202026-05-12%20115718.png" alt="تنظیمات تونل در TunnelX"> |
|
||||
|
||||
## دانلود
|
||||
|
||||
فایلهای آماده اجرا از بخش <span dir="ltr">Releases</span> پروژه منتشر میشوند:
|
||||
|
||||
<span dir="ltr">[دانلود آخرین نسخه از GitHub Releases](https://github.com/MaxiFan/TunnelX/releases/latest)</span>
|
||||
|
||||
فایلهای منتشرشده توسط <span dir="ltr">GitHub Actions</span> ساخته و آپلود میشوند. برای هر فایل اجرایی <span dir="ltr">standalone</span>، فایل checksum با پسوند <span dir="ltr">`.sha256`</span> هم منتشر میشود و در متن هر <span dir="ltr">Release</span> لینک اجرای workflow قرار میگیرد.
|
||||
|
||||
نسخه پیشنهادی برای کاربران، فایل <span dir="ltr">standalone</span> و <span dir="ltr">self-contained</span> است. این نسخه به نصب جداگانه <span dir="ltr">.NET Runtime</span> نیاز ندارد.
|
||||
|
||||
## نیازمندیهای اجرا
|
||||
|
||||
- ویندوز <span dir="ltr">10/11</span>
|
||||
- ویندوز ۶۴ بیتی: <span dir="ltr">`win-x64`</span>
|
||||
- دسترسی <span dir="ltr">Administrator</span> هنگام اجرا، چون مدیریت <span dir="ltr">route</span> و <span dir="ltr">packet interception</span> به سطح دسترسی بالا نیاز دارد
|
||||
- نسخههای ۳۲ بیتی ویندوز در حال حاضر پشتیبانی نمیشوند
|
||||
|
||||
## ساخت از سورس
|
||||
|
||||
برای توسعه یا ساخت دستی، <span dir="ltr">.NET 8 SDK</span> لازم است:
|
||||
|
||||
</div>
|
||||
|
||||
```powershell
|
||||
dotnet build AppTunnel.sln -c Release
|
||||
dotnet publish AppTunnel\AppTunnel.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false
|
||||
```
|
||||
|
||||
<div dir="rtl" align="right">
|
||||
|
||||
جزئیات بیشتر در <span dir="ltr">`docs/BUILD.md`</span> آمده است. ایدهها و برنامههای آینده در <span dir="ltr">`docs/ROADMAP.md`</span> نگهداری میشوند.
|
||||
|
||||
## مجوز
|
||||
|
||||
<span dir="ltr">TunnelX</span> تحت مجوز **<span dir="ltr">GPL-3.0-or-later</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">GitHub Sponsors/Funding</span> یا فایل <span dir="ltr">`docs/DONATE.md`</span> در دسترس هستند.
|
||||
|
||||
## نکته ایمنی و سلب مسئولیت
|
||||
|
||||
<span dir="ltr">TunnelX</span> یک ابزار شبکه، تونل و مدیریت مسیر است. فقط در محیطهایی از آن استفاده کنید که اجازه استفاده از ویپیان، پروکسی، <span dir="ltr">packet capture</span> و تغییر <span dir="ltr">route</span> را دارید. این پروژه مشاوره حقوقی ارائه نمیدهد.
|
||||
|
||||
این نرمافزار همانگونه که هست ارائه میشود، بدون هیچگونه ضمانت، و نگهدارنده پروژه تعهدی برای ارائه بروزرسانی، رفع اشکال، پشتیبانی یا ادامه دسترسی دائمی ندارد.
|
||||
|
||||
</div>
|
||||
@@ -1,5 +1,7 @@
|
||||
# TunnelX
|
||||
|
||||
[فارسی](README.fa.md) | English
|
||||
|
||||
TunnelX is a free and open-source Windows split-tunneling client built by **MaxFan**. It routes selected apps, selected destinations, or the whole system through supported tunnel cores while keeping local and excluded destinations on the normal network path.
|
||||
|
||||
> Status: pre-release. Review the release notes in `docs/BUILD.md` before publishing a public artifact.
|
||||
@@ -13,12 +15,24 @@ TunnelX is a free and open-source Windows split-tunneling client built by **MaxF
|
||||
- DNS redirect, IPv6 blocking, leak guard, route diagnostics, and traffic history
|
||||
- Persian-first Windows desktop UI
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Connection dashboard | Profile and server setup |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| App split tunneling | Tunnel settings |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
## Download
|
||||
|
||||
Public downloads should be attached to GitHub Releases after release validation is complete:
|
||||
|
||||
[GitHub project](https://github.com/MaxiFan/TunnelX)
|
||||
|
||||
Release assets are built and uploaded by GitHub Actions. Each published standalone executable includes a `.sha256` checksum file, and the release notes link back to the workflow run that produced the artifact.
|
||||
|
||||
## Build
|
||||
|
||||
End-user requirements for the recommended standalone release:
|
||||
|
||||
+24
-1
@@ -21,9 +21,32 @@ dotnet publish AppTunnel\AppTunnel.csproj -c Release -r win-x64 --self-contained
|
||||
Rename the final executable with the app version:
|
||||
|
||||
```powershell
|
||||
TunnelX-v1.2.22-standalone-compressed.exe
|
||||
TunnelX-v1.2.23-standalone-compressed.exe
|
||||
```
|
||||
|
||||
## GitHub Actions Release
|
||||
|
||||
Public releases are published by `.github/workflows/release.yml`.
|
||||
|
||||
The normal release flow is:
|
||||
|
||||
1. Add user-facing changes under `## Unreleased` in `CHANGELOG.md` when there are curated release notes to publish.
|
||||
2. Run the `release` workflow from the GitHub Actions tab.
|
||||
3. Either provide an explicit version like `1.2.24`, or leave the version empty and choose `patch`, `minor`, or `major`.
|
||||
|
||||
The workflow then:
|
||||
|
||||
- updates `<Version>`, `<AssemblyVersion>`, and `<FileVersion>` in `AppTunnel/AppTunnel.csproj`;
|
||||
- moves `CHANGELOG.md` notes from `## Unreleased` into a dated version section;
|
||||
- generates release notes from recent commit subjects if `## Unreleased` is empty;
|
||||
- commits the release metadata update;
|
||||
- creates and pushes the `vMAJOR.MINOR.PATCH` tag;
|
||||
- builds and publishes the `win-x64` self-contained single-file executable;
|
||||
- attaches `TunnelX-vX.Y.Z-standalone-compressed.exe` and a `.sha256` checksum to the GitHub Release;
|
||||
- adds build provenance to the Release notes, including the GitHub Actions run URL, commit, and checksum.
|
||||
|
||||
Only repository users with write access can run the manual release workflow.
|
||||
|
||||
## 32-bit Windows
|
||||
|
||||
32-bit Windows builds are not supported at this time. Supporting `win-x86` would require a separate compatibility pass, x86-compatible native binaries for every bundled network component, and separate testing for WinDivert/Wintun, Xray/sing-box, packet interception, route management, and the standalone extraction path.
|
||||
|
||||
+35
-1
@@ -16,7 +16,7 @@ Candidate checks:
|
||||
- WinDivert and Wintun native component availability
|
||||
- extraction folder write permissions
|
||||
- route and packet interception readiness
|
||||
- GitHub release/update status
|
||||
- deeper release asset verification for future automatic update flows
|
||||
|
||||
Potential actions:
|
||||
|
||||
@@ -28,3 +28,37 @@ Potential actions:
|
||||
- avoid silent driver or system-level changes
|
||||
|
||||
For public releases, TunnelX should continue to prefer self-contained standalone EXE builds so end users do not need to install the .NET Runtime separately.
|
||||
|
||||
### Profile management
|
||||
|
||||
- import and export connection profiles
|
||||
- clone profiles with clearer naming
|
||||
- per-profile health and last-test status
|
||||
- presets for common workflows such as browser-only, messaging, development, and full VPN
|
||||
|
||||
### Split tunnel rule clarity
|
||||
|
||||
- explicit rule priority between apps, include destinations, and exclude destinations
|
||||
- wildcard and domain-suffix previews
|
||||
- conflict detection when the same destination is both included and excluded
|
||||
- show which rule caused a destination to be routed or bypassed
|
||||
|
||||
### Health dashboard
|
||||
|
||||
- user-facing states: safe, protected, needs attention, and broken
|
||||
- short explanation for the latest important network event
|
||||
- suggested action next to DNS, IPv6, route, and leak status
|
||||
|
||||
### Kill switch
|
||||
|
||||
- optional per-app kill switch when the tunnel core or TUN bridge drops
|
||||
- keep selected apps blocked until the tunnel is restored or the user disconnects
|
||||
|
||||
### Traffic accounting clarity
|
||||
|
||||
- keep tunnel, direct, per-app, DNS, and history counters aligned to one accounting model
|
||||
- expose whether counters are interface-based, rewritten-flow-based, or app-attributed
|
||||
|
||||
### Installer and prerequisite checks
|
||||
|
||||
- optional installer/bootstrapper for shortcuts, uninstall support, prerequisite checks, and future update flow
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Reference in New Issue
Block a user