commit 3726d3690204e177d0071c2c262d8f68de7af4e8 Author: MaxFan Date: Mon May 11 16:27:14 2026 +0330 Prepare TunnelX for open-source release diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e7bf688 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +* text=auto + +*.cs text eol=crlf +*.xaml text eol=crlf +*.csproj text eol=crlf +*.sln text eol=crlf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +*.ico binary +*.png binary +*.ttf binary +*.dll binary +*.sys binary +*.exe binary +*.dat binary +*.docx binary diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1885df1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +custom: + - "https://www.paypal.com/donate/?business=gallafan%40gmail.com¤cy_code=USD" diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9f7d0ca --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,44 @@ +name: Bug report +description: Report a reproducible TunnelX problem +title: "[Bug]: " +labels: ["bug"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What happened? + validations: + required: true + - type: input + id: version + attributes: + label: TunnelX version + placeholder: v1.2.21 + validations: + required: true + - type: dropdown + id: mode + attributes: + label: Routing mode + options: + - Split route + - Full route + - App toggle + - Startup/connect/disconnect + - Other + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Include the smallest steps that trigger the issue. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Sanitized logs + description: Remove credentials, UUIDs, private keys, and private endpoints. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..74a3b3b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security reports + url: https://github.com/MaxiFan/TunnelX/security/advisories + about: Please use private advisories for sensitive reports when available. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..556ef28 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,24 @@ +name: Feature request +description: Suggest an improvement for TunnelX +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What user problem should this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What should TunnelX do? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives + description: Any other approaches you considered? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d77da3e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +## Summary + +## Tests + +## Notes + +- [ ] No private configs, credentials, logs, or release artifacts were committed. +- [ ] Documentation was updated if behavior or release packaging changed. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..131672f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,19 @@ +name: build + +on: + push: + branches: ["main"] + pull_request: + +jobs: + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + - name: Restore + run: dotnet restore AppTunnel.sln + - name: Build + run: dotnet build AppTunnel.sln -c Release --no-restore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6647e10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Build outputs +bin/ +obj/ + +# Local IDE and tooling state +.vs/ +.vscode/ +.github/copilot-instructions.md +*.user +*.suo + +# Publish and release artifacts +publish/ +Releases/ +*.msi +*.msix +*.appx +*.nupkg + +# Downloaded dependency archives and extracted tool drops +*.zip +*.7z +*.rar +sing-box-new/ +singbox-xhttp-test*.json +*-local-test*.json + +# Personal notes and exported documents +*.docx + +# Logs and temporary files +*.log +*.tmp +*.cache +TestResults/ +*.binlog + +# Local secrets and machine-specific settings +.env +.env.* diff --git a/AppTunnel.sln b/AppTunnel.sln new file mode 100644 index 0000000..166294f --- /dev/null +++ b/AppTunnel.sln @@ -0,0 +1,19 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppTunnel", "AppTunnel\AppTunnel.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/AppTunnel/App.xaml b/AppTunnel/App.xaml new file mode 100644 index 0000000..cbb9f28 --- /dev/null +++ b/AppTunnel/App.xaml @@ -0,0 +1,51 @@ + + + + + + + + + pack://application:,,,/Fonts/#Vazirmatn + pack://application:,,,/Fonts/#Vazirmatn, Segoe UI, Tahoma + + + + + + + + + + + + + + + diff --git a/AppTunnel/App.xaml.cs b/AppTunnel/App.xaml.cs new file mode 100644 index 0000000..3302a92 --- /dev/null +++ b/AppTunnel/App.xaml.cs @@ -0,0 +1,314 @@ +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Windows; +using AppTunnel.Services; +using Application = System.Windows.Application; +using MessageBox = System.Windows.MessageBox; + +namespace AppTunnel; + +public partial class App : Application +{ + private const string SingleInstanceMutexName = @"Global\TunnelX.SingleInstance"; + private const string BringToFrontEventName = @"Global\TunnelX.BringToFront"; + + private Mutex? _singleInstanceMutex; + private EventWaitHandle? _bringToFrontEvent; + private RegisteredWaitHandle? _bringToFrontRegistration; + + /// + /// Persistent data directory: %LOCALAPPDATA%\TunnelX\ + /// All user data (profiles, config, native DLLs) is stored here so that + /// the application can run from any read-only or temporary location and + /// multiple instances share the same data folder. + /// + public static readonly string AppDataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "TunnelX"); + + protected override void OnStartup(StartupEventArgs e) + { + if (!TryAcquireSingleInstance()) + { + SignalExistingInstanceToShow(); + Shutdown(0); + return; + } + + // Ensure the data directory exists before anything else. + try + { + Directory.CreateDirectory(AppDataDir); + } + catch (Exception ex) + { + // Without the data directory we cannot extract native libraries + // or persist any user data — there is nothing useful the app can + // do, so report a clear error and exit instead of crashing later + // with a confusing P/Invoke failure. + MessageBox.Show( + $"TunnelX cannot create its data directory:\n{AppDataDir}\n\n{ex.Message}\n\nPlease check that you have write permission to %LOCALAPPDATA%.", + "TunnelX — خطای راه‌اندازی", + MessageBoxButton.OK, + MessageBoxImage.Error); + Shutdown(1); + return; + } + + // Extract WinDivert/wintun native files from embedded resources into + // AppDataDir. Must happen BEFORE the NativeLibrary resolver is + // registered and before any DllImport call is made. + EnsureNativeLibsExtracted(); + + // Register a resolver so that DllImport("WinDivert.dll") and + // DllImport("wintun.dll") load from AppDataDir rather than relying on + // the default search order (which would only find them next to the exe). + NativeLibrary.SetDllImportResolver(Assembly.GetExecutingAssembly(), (name, asm, searchPath) => + { + var candidate = Path.Combine(AppDataDir, name); + if (File.Exists(candidate) && NativeLibrary.TryLoad(candidate, out var handle)) + return handle; + // Fall back to default resolution (regular build side-by-side DLLs). + return IntPtr.Zero; + }); + + base.OnStartup(e); + + var mainWindow = new MainWindow(); + MainWindow = mainWindow; + mainWindow.Show(); + + // Clean up any stale VPN connection left by a previous crash/kill. + CleanupStaleVpn(); + + // Safety net: if the process is killed, try to disconnect VPN. + AppDomain.CurrentDomain.ProcessExit += (_, _) => + { + try { CleanupStaleVpn(); } catch { } + }; + + // Global exception handlers for debugging + AppDomain.CurrentDomain.UnhandledException += (sender, args) => + { + var ex = args.ExceptionObject as Exception; + Logger.Error("Unhandled exception in AppDomain", ex); + try { CleanupStaleVpn(); } catch { } + MessageBox.Show( + $"Critical error occurred:\n{ex?.Message}\n\nCheck Debug Log for details.", + "TunnelX - Fatal Error", + MessageBoxButton.OK, + MessageBoxImage.Error); + }; + + DispatcherUnhandledException += (sender, args) => + { + Logger.Error("Unhandled exception in Dispatcher", args.Exception); + MessageBox.Show( + $"UI error occurred:\n{args.Exception.Message}\n\nCheck Debug Log for details.", + "TunnelX - Error", + MessageBoxButton.OK, + MessageBoxImage.Error); + args.Handled = true; // Prevent crash + }; + + Logger.Info("TunnelX application started"); + } + + protected override void OnExit(ExitEventArgs e) + { + Logger.Info("TunnelX application exiting"); + + try { _bringToFrontRegistration?.Unregister(null); } catch { } + try { _bringToFrontEvent?.Dispose(); } catch { } + try + { + _singleInstanceMutex?.ReleaseMutex(); + _singleInstanceMutex?.Dispose(); + } + catch { } + + base.OnExit(e); + } + + private bool TryAcquireSingleInstance() + { + try + { + _singleInstanceMutex = new Mutex(true, SingleInstanceMutexName, out bool createdNew); + if (!createdNew) + return false; + + _bringToFrontEvent = new EventWaitHandle(false, EventResetMode.AutoReset, BringToFrontEventName); + _bringToFrontRegistration = ThreadPool.RegisterWaitForSingleObject( + _bringToFrontEvent, + (_, _) => Dispatcher.BeginInvoke(new Action(BringMainWindowToFront)), + null, + Timeout.Infinite, + false); + + return true; + } + catch + { + return true; + } + } + + private static void SignalExistingInstanceToShow() + { + try + { + using var evt = EventWaitHandle.OpenExisting(BringToFrontEventName); + evt.Set(); + } + catch { } + } + + private void BringMainWindowToFront() + { + if (MainWindow is MainWindow win) + { + win.BringToForeground(); + return; + } + + if (Application.Current.MainWindow is MainWindow fallback) + fallback.BringToForeground(); + } + + /// + /// Disconnects and removes the VPN connection if it exists. + /// Called on startup (to clean up after crash) and on fatal errors. + /// Also removes duplicates like "TunnelX 2", "TunnelX 3" that Windows + /// sometimes creates when the original wasn't fully removed. + /// + private static void CleanupStaleVpn() + { + // ── 0. Kill any stray sing-box process left over from a crash ────────── + try + { + var killSingBox = new ProcessStartInfo + { + FileName = "taskkill", + Arguments = "/F /IM sing-box.exe", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using var kp = Process.Start(killSingBox); + kp?.WaitForExit(3000); + } + catch { } + + const string vpnName = "TunnelX"; + try + { + // Disconnect if active + var disconnectPsi = new ProcessStartInfo + { + FileName = "rasdial", + Arguments = $"\"{vpnName}\" /disconnect", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using var dp = Process.Start(disconnectPsi); + dp?.WaitForExit(5000); + + // Find and remove all VPN connections matching "TunnelX" or "TunnelX N" + var findPsi = new ProcessStartInfo + { + FileName = "powershell", + Arguments = "-NoProfile -Command \"Get-VpnConnection -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^TunnelX' } | Select-Object -ExpandProperty Name\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using var fp = Process.Start(findPsi); + var output = fp?.StandardOutput.ReadToEnd() ?? ""; + fp?.WaitForExit(8000); + + var names = output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(n => n.Length > 0).ToList(); + + foreach (var name in names) + { + // Disconnect each variant + var dPsi = new ProcessStartInfo + { + FileName = "rasdial", + Arguments = $"\"{name}\" /disconnect", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using var ddp = Process.Start(dPsi); + ddp?.WaitForExit(3000); + + // Remove VPN profile + var safeName = name.Replace("'", "''"); + var rmPsi = new ProcessStartInfo + { + FileName = "powershell", + Arguments = $"-NoProfile -Command \"Remove-VpnConnection -Name '{safeName}' -Force -ErrorAction SilentlyContinue\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using var rp = Process.Start(rmPsi); + rp?.WaitForExit(5000); + } + + if (names.Count > 0) + Logger.Info($"[CLEANUP] Removed {names.Count} stale VPN profile(s): {string.Join(", ", names)}"); + } + catch (Exception ex) + { + Logger.Warning($"[CLEANUP] Stale VPN cleanup failed: {ex.Message}"); + } + } + + /// + /// Extracts WinDivert.dll, WinDivert64.sys and wintun.dll from embedded + /// assembly resources into . + /// For regular (non-single-file) builds the DLLs live next to the exe and + /// are not embedded, so GetManifestResourceStream returns null — no-op. + /// Each file is re-extracted only when it is missing or has a different + /// size (i.e. a new version was just deployed). + /// + private static void EnsureNativeLibsExtracted() + { + var asm = Assembly.GetExecutingAssembly(); + + foreach (var name in new[] { "WinDivert.dll", "WinDivert64.sys", "wintun.dll" }) + { + try + { + using var stream = asm.GetManifestResourceStream(name); + if (stream == null) continue; // not embedded → regular build, skip + + var destPath = Path.Combine(AppDataDir, name); + + // Skip if already extracted with the same size. + if (File.Exists(destPath) && new FileInfo(destPath).Length == stream.Length) + continue; + + using var fs = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None); + stream.CopyTo(fs); + Logger.Info($"[NATIVE] Extracted {name} → {destPath}"); + } + catch (Exception ex) + { + Logger.Warning($"[NATIVE] Could not extract {name}: {ex.Message}"); + } + } + } +} diff --git a/AppTunnel/AppTunnel.csproj b/AppTunnel/AppTunnel.csproj new file mode 100644 index 0000000..5f4ad50 --- /dev/null +++ b/AppTunnel/AppTunnel.csproj @@ -0,0 +1,95 @@ + + + + WinExe + net8.0-windows + enable + enable + true + true + TunnelX + AppTunnel + app.manifest + app.ico + x64 + MaxFan + MaxFan + TunnelX + Free and open-source Windows split-tunneling client for app-based and full-route VPN workflows. + Copyright © MaxFan + https://github.com/MaxiFan/TunnelX + https://github.com/MaxiFan/TunnelX + GPL-3.0-or-later + fa-IR + + + 1.2.21 + 1.2.21.0 + 1.2.21.0 + + + + + + + + + + + + PreserveNewest + WinDivert.dll + + + PreserveNewest + WinDivert64.sys + + + PreserveNewest + sing-box.exe + + + PreserveNewest + xray.exe + + + + sing-box.exe + + + xray.exe + + + + WinDivert.dll + + + WinDivert64.sys + + + wintun.dll + + + PreserveNewest + wintun.dll + + + + + + + + + + + + + + diff --git a/AppTunnel/AppTunnel.sln b/AppTunnel/AppTunnel.sln new file mode 100644 index 0000000..35d569e --- /dev/null +++ b/AppTunnel/AppTunnel.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppTunnel", "AppTunnel.csproj", "{0059C582-210C-49D2-18B2-D776F1A1BBB3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0059C582-210C-49D2-18B2-D776F1A1BBB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0059C582-210C-49D2-18B2-D776F1A1BBB3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0059C582-210C-49D2-18B2-D776F1A1BBB3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0059C582-210C-49D2-18B2-D776F1A1BBB3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {3712AD0A-C14A-4C96-9AF8-B5472E4173EB} + EndGlobalSection +EndGlobal diff --git a/AppTunnel/Converters/Converters.cs b/AppTunnel/Converters/Converters.cs new file mode 100644 index 0000000..5e12621 --- /dev/null +++ b/AppTunnel/Converters/Converters.cs @@ -0,0 +1,120 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; +using System.Windows.Media; +using Color = System.Windows.Media.Color; +using ColorConverter = System.Windows.Media.ColorConverter; +using FlowDirection = System.Windows.FlowDirection; + +namespace AppTunnel.Converters; + +public class BoolToColorConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool b && b) + return new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4CAF50")); + return new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9E9E9E")); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +public class StringToColorConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string hex) + { + try + { + var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); + brush.Freeze(); + return brush; + } + catch (FormatException) { } + } + var fallback = new SolidColorBrush(Colors.Gray); + fallback.Freeze(); + return fallback; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +public class InverseBoolConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; +} + +public class BoolToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b && b ? Visibility.Visible : Visibility.Collapsed; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => value is Visibility v && v == Visibility.Visible; +} + +public class InverseBoolToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b && b ? Visibility.Collapsed : Visibility.Visible; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// +/// Returns Visible when the bound enum value's string name matches ConverterParameter. +/// Usage: Visibility="{Binding SomeEnum, Converter={StaticResource EnumToVis}, ConverterParameter=MyValue}" +/// +public class EnumToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value?.ToString() == parameter?.ToString() ? Visibility.Visible : Visibility.Collapsed; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// +/// تشخیص خودکار جهت متن بر اساس محتوا +/// اگر متن شامل حروف فارسی یا عربی باشد: RightToLeft +/// اگر متن شامل حروف انگلیسی باشد: LeftToRight +/// +public class TextToFlowDirectionConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string text && !string.IsNullOrWhiteSpace(text)) + { + // بررسی اولین کاراکتر معنادار + foreach (char c in text) + { + if (char.IsWhiteSpace(c) || char.IsPunctuation(c) || char.IsDigit(c)) + continue; + + // محدوده Unicode برای فارسی و عربی: 0x0600-0x06FF + if (c >= 0x0600 && c <= 0x06FF) + return FlowDirection.RightToLeft; + + // محدوده Unicode برای حروف لاتین: A-Z, a-z + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) + return FlowDirection.LeftToRight; + } + } + + // پیش‌فرض: راست به چپ (برای اپلیکیشن فارسی) + return FlowDirection.RightToLeft; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/AppTunnel/Fonts/Vazirmatn-Regular.ttf b/AppTunnel/Fonts/Vazirmatn-Regular.ttf new file mode 100644 index 0000000..64e4a81 Binary files /dev/null and b/AppTunnel/Fonts/Vazirmatn-Regular.ttf differ diff --git a/AppTunnel/Helpers/DialogService.cs b/AppTunnel/Helpers/DialogService.cs new file mode 100644 index 0000000..d90acfe --- /dev/null +++ b/AppTunnel/Helpers/DialogService.cs @@ -0,0 +1,75 @@ +using System.Windows; +using AppTunnel.Views; +using Application = System.Windows.Application; + +namespace AppTunnel.Helpers; + +/// +/// Service for showing modern styled dialogs matching the app's UI theme. +/// Replaces Windows MessageBox with Persian-friendly, styled dialogs. +/// +public static class DialogService +{ + /// + /// Show a confirmation dialog and return user's choice + /// + /// پیام فارسی + /// عنوان (پیش‌فرض: "تاییدیه") + /// پنجره والد (پیش‌فرض: MainWindow) + /// true اگر کاربر "بله" را انتخاب کند + public static bool Confirm(string message, string title = "تاییدیه", Window? owner = null) + { + return ModernDialog.ShowConfirm(message, title, owner); + } + + /// + /// Show an information message + /// + public static void Info(string message, string title = "اطلاعات", Window? owner = null) + { + ModernDialog.ShowInfo(message, title, owner); + } + + /// + /// Show a success message + /// + public static void Success(string message, string title = "موفقیت", Window? owner = null) + { + ModernDialog.ShowSuccess(message, title, owner); + } + + /// + /// Show an error message + /// + public static void Error(string message, string title = "خطا", Window? owner = null) + { + ModernDialog.ShowError(message, title, owner); + } + + /// + /// Show a warning message + /// + public static void Warning(string message, string title = "هشدار", Window? owner = null) + { + ModernDialog.ShowWarning(message, title, owner); + } + + /// + /// Show a clipboard copy notification (toast, no dialog) + /// + public static void ShowCopied(string itemName = "متن") + { + ShowToast($"{itemName} کپی شد", "✅"); + } + + /// + /// Show a small auto-dismiss toast notification for 3 seconds + /// + public static void ShowToast(string message, string icon = "✅") + { + if (Application.Current.MainWindow is MainWindow mainWindow) + { + mainWindow.ShowToast(message, icon); + } + } +} diff --git a/AppTunnel/Helpers/TextHelper.cs b/AppTunnel/Helpers/TextHelper.cs new file mode 100644 index 0000000..97df39d --- /dev/null +++ b/AppTunnel/Helpers/TextHelper.cs @@ -0,0 +1,87 @@ +using FlowDirection = System.Windows.FlowDirection; + +namespace AppTunnel.Helpers; + +/// +/// Helper methods for text direction detection and Persian text handling +/// +public static class TextHelper +{ + /// + /// تشخیص خودکار جهت متن بر اساس محتوای آن + /// + /// متن ورودی + /// FlowDirection.RightToLeft برای فارسی/عربی، FlowDirection.LeftToRight برای انگلیسی + public static FlowDirection DetectFlowDirection(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return FlowDirection.RightToLeft; // پیش‌فرض برای اپلیکیشن فارسی + + // بررسی اولین کاراکتر معنادار + foreach (char c in text) + { + if (char.IsWhiteSpace(c) || char.IsPunctuation(c) || char.IsDigit(c)) + continue; + + // محدوده Unicode برای فارسی و عربی: 0x0600-0x06FF + if (c >= 0x0600 && c <= 0x06FF) + return FlowDirection.RightToLeft; + + // محدوده Unicode برای حروف لاتین + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) + return FlowDirection.LeftToRight; + } + + return FlowDirection.RightToLeft; + } + + /// + /// بررسی اینکه آیا متن شامل حروف فارسی است یا خیر + /// + public static bool IsPersianText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + + foreach (char c in text) + { + // محدوده Unicode فارسی: 0x0600-0x06FF + if (c >= 0x0600 && c <= 0x06FF) + return true; + } + + return false; + } + + /// + /// بررسی اینکه آیا متن شامل حروف انگلیسی است یا خیر + /// + public static bool IsEnglishText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + + foreach (char c in text) + { + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) + return true; + } + + return false; + } + + /// + /// نرمال‌سازی فاصله‌های فارسی (تبدیل ZWNJ، نیم‌فاصله و ...) + /// + public static string NormalizePersianSpaces(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + // تبدیل نیم‌فاصله عربی به فاصله معمولی + text = text.Replace('\u200C', ' '); // ZWNJ + text = text.Replace('\u00A0', ' '); // Non-breaking space + + return text; + } +} diff --git a/AppTunnel/LogWindow.xaml b/AppTunnel/LogWindow.xaml new file mode 100644 index 0000000..a80e26b --- /dev/null +++ b/AppTunnel/LogWindow.xaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +