commit 2b5e66c1e7d5a8074a0d3f55d2f068d1f6f6c5c5 Author: guiling Date: Fri May 8 11:59:26 2026 +0800 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0524b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +publish*/ +*.pdb +*.user +.vs/ +config.json diff --git a/App.xaml b/App.xaml new file mode 100644 index 0000000..7714f15 --- /dev/null +++ b/App.xaml @@ -0,0 +1,4 @@ + + diff --git a/App.xaml.cs b/App.xaml.cs new file mode 100644 index 0000000..a304378 --- /dev/null +++ b/App.xaml.cs @@ -0,0 +1,59 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Windows; + +namespace ClashWidget; + +public partial class App : Application +{ + private static Mutex? _mutex; + + protected override void OnStartup(StartupEventArgs e) + { + // Kill any existing ClashWidget instances + var current = Process.GetCurrentProcess(); + foreach (var p in Process.GetProcessesByName("ClashWidget")) + { + if (p.Id != current.Id) + { + try { p.Kill(); p.WaitForExit(2000); } catch { } + } + } + + // Single-instance mutex + _mutex = new Mutex(true, "ClashWidget_SingleInstance", out bool createdNew); + if (!createdNew) + { + MessageBox.Show("ClashWidget is already running.", "Info", + MessageBoxButton.OK, MessageBoxImage.Information); + Shutdown(); + return; + } + + DispatcherUnhandledException += (s, args) => + { + MessageBox.Show($"Error:\n{args.Exception.Message}", + "Error", MessageBoxButton.OK, MessageBoxImage.Error); + args.Handled = true; + }; + + try + { + var window = new MainWindow(); + window.Show(); + } + catch (Exception ex) + { + MessageBox.Show($"Startup failed:\n{ex.Message}", + "Startup Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + protected override void OnExit(ExitEventArgs e) + { + _mutex?.ReleaseMutex(); + _mutex?.Dispose(); + base.OnExit(e); + } +} diff --git a/ClashWidget.csproj b/ClashWidget.csproj new file mode 100644 index 0000000..c4e17f2 --- /dev/null +++ b/ClashWidget.csproj @@ -0,0 +1,17 @@ + + + + WinExe + net8.0-windows + win-x64 + x64 + enable + enable + true + true + ClashWidget + app.ico + https://dotnet.microsoft.com/download/dotnet/8.0/runtime/desktop/x64 + + + diff --git a/Converters/NodeMatchConverter.cs b/Converters/NodeMatchConverter.cs new file mode 100644 index 0000000..419ec5c --- /dev/null +++ b/Converters/NodeMatchConverter.cs @@ -0,0 +1,19 @@ +using System.Globalization; +using System.Windows.Data; + +namespace ClashWidget.Converters; + +public class NodeMatchConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Length >= 2 && values[0] is string a && values[1] is string b) + return a == b; + return false; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/Converters/SpeedConverter.cs b/Converters/SpeedConverter.cs new file mode 100644 index 0000000..3762488 --- /dev/null +++ b/Converters/SpeedConverter.cs @@ -0,0 +1,27 @@ +using System.Globalization; +using System.Windows.Data; + +namespace ClashWidget.Converters; + +public class SpeedConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not double bytesPerSec) + return "—"; + + return bytesPerSec switch + { + < 0 => "—", + < 1024 => $"{bytesPerSec:F0} B/s", + < 1024 * 1024 => $"{bytesPerSec / 1024:F1} KB/s", + < 1024 * 1024 * 1024 => $"{bytesPerSec / (1024 * 1024):F1} MB/s", + _ => $"{bytesPerSec / (1024 * 1024 * 1024):F2} GB/s" + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/Helpers/NativeMethods.cs b/Helpers/NativeMethods.cs new file mode 100644 index 0000000..4f873a0 --- /dev/null +++ b/Helpers/NativeMethods.cs @@ -0,0 +1,126 @@ +using System; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; + +namespace ClashWidget.Helpers; + +internal static class NativeMethods +{ + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, + int X, int Y, int cx, int cy, uint uFlags); + + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, + ref int attrValue, int attrSize); + + [DllImport("dwmapi.dll")] + private static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, + ref MARGINS margins); + + [DllImport("user32.dll")] + private static extern int SetWindowCompositionAttribute(IntPtr hwnd, + ref WindowCompositionAttributeData data); + + [DllImport("user32.dll")] + private static extern int GetWindowLong(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll")] + private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); + + private static readonly IntPtr HWND_TOPMOST = new(-1); + private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOSIZE = 0x0001; + private const uint SWP_NOACTIVATE = 0x0010; + private const uint SWP_SHOWWINDOW = 0x0040; + private const int GWL_EXSTYLE = -20; + private const int WS_EX_TOOLWINDOW = 0x00000080; + private const int WS_EX_APPWINDOW = 0x00040000; + private const int DWMWA_SYSTEMBACKDROP_TYPE = 38; + + [StructLayout(LayoutKind.Sequential)] + private struct MARGINS { public int Left, Right, Top, Bottom; } + + private enum AccentState { ACCENT_DISABLED = 0, ACCENT_ENABLE_ACRYLICBLURBEHIND = 4 } + + [StructLayout(LayoutKind.Sequential)] + private struct AccentPolicy + { + public AccentState AccentState; + public int AccentFlags; + public int GradientColor; + public int AnimationId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WindowCompositionAttributeData + { + public WindowCompositionAttribute Attribute; + public IntPtr Data; + public int SizeOfData; + } + + private enum WindowCompositionAttribute { WCA_ACCENT_POLICY = 19 } + + public static void ApplyAcrylic(Window window) + { + var hwnd = new WindowInteropHelper(window).EnsureHandle(); + + // Extend DWM frame into entire client area + var margins = new MARGINS { Left = -1, Right = -1, Top = -1, Bottom = -1 }; + DwmExtendFrameIntoClientArea(hwnd, ref margins); + + // Try Win11 modern API first + int backdrop = 3; // DWMSBT_TRANSIENTWINDOW = Acrylic + int hr = DwmSetWindowAttribute(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, ref backdrop, sizeof(int)); + + if (hr != 0) + { + // Fallback: Win10 acrylic + try + { + var accent = new AccentPolicy + { + AccentState = AccentState.ACCENT_ENABLE_ACRYLICBLURBEHIND, + AccentFlags = 2, + GradientColor = 0x01FFFFFF + }; + var data = new WindowCompositionAttributeData + { + Attribute = WindowCompositionAttribute.WCA_ACCENT_POLICY, + SizeOfData = Marshal.SizeOf(), + Data = Marshal.AllocHGlobal(Marshal.SizeOf()) + }; + Marshal.StructureToPtr(accent, data.Data, false); + SetWindowCompositionAttribute(hwnd, ref data); + Marshal.FreeHGlobal(data.Data); + } + catch { } + } + } + + public static void SetTopmost(Window window) + { + try + { + var hwnd = new WindowInteropHelper(window).EnsureHandle(); + SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW); + } + catch { } + } + + public static void HideFromTaskbar(Window window) + { + try + { + var hwnd = new WindowInteropHelper(window).EnsureHandle(); + int exStyle = GetWindowLong(hwnd, GWL_EXSTYLE); + exStyle |= WS_EX_TOOLWINDOW; + exStyle &= ~WS_EX_APPWINDOW; + SetWindowLong(hwnd, GWL_EXSTYLE, exStyle); + } + catch { } + } +} diff --git a/MainWindow.xaml b/MainWindow.xaml new file mode 100644 index 0000000..3c85f81 --- /dev/null +++ b/MainWindow.xaml @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs new file mode 100644 index 0000000..05f649a --- /dev/null +++ b/MainWindow.xaml.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Input; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Shapes; +using ClashWidget.Helpers; +using ClashWidget.ViewModels; + +namespace ClashWidget; + +public partial class MainWindow : Window +{ + private readonly MainViewModel _viewModel; + private Polyline? _downloadLine; + private Polyline? _uploadLine; + private bool _isMaximized; + + public MainWindow() + { + InitializeComponent(); + + _viewModel = new MainViewModel(); + DataContext = _viewModel; + + Opacity = 0; + Loaded += OnLoaded; + _viewModel.PropertyChanged += OnViewModelPropertyChanged; + _viewModel.SpeedHistory.CollectionChanged += OnSpeedHistoryChanged; + _viewModel.DropdownToggled += OnDropdownToggled; + + CreateSparklines(); + UpdateStatusDot(); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + try + { + var hwnd = new WindowInteropHelper(this).Handle; + if (hwnd != IntPtr.Zero) + { + var source = HwndSource.FromHwnd(hwnd); + if (source?.CompositionTarget != null) + source.CompositionTarget.BackgroundColor = Colors.Transparent; + } + NativeMethods.ApplyAcrylic(this); + NativeMethods.SetTopmost(this); + NativeMethods.HideFromTaskbar(this); + } + catch { } + + var workArea = SystemParameters.WorkArea; + Left = workArea.Right - Width - 20; + Top = workArea.Top + 20; + + var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(250)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } + }; + BeginAnimation(OpacityProperty, fadeIn); + + _viewModel.Start(); + } + + private void CreateSparklines() + { + _downloadLine = new Polyline + { + Stroke = new SolidColorBrush(Color.FromRgb(0x3B, 0x82, 0xF6)), + StrokeThickness = 1.5, StrokeLineJoin = PenLineJoin.Round + }; + _uploadLine = new Polyline + { + Stroke = new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)), + StrokeThickness = 1, StrokeLineJoin = PenLineJoin.Round + }; + SparklineCanvas.Children.Add(_downloadLine); + SparklineCanvas.Children.Add(_uploadLine); + } + + private void OnSpeedHistoryChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + Dispatcher.BeginInvoke(new Action(DrawSparklines)); + } + + private void DrawSparklines() + { + if (_downloadLine == null || _uploadLine == null) return; + var history = _viewModel.SpeedHistory.ToList(); + if (history.Count < 2) return; + double cw = SparklineCanvas.ActualWidth, ch = SparklineCanvas.ActualHeight; + if (cw < 10 || ch < 10) return; + double maxVal = Math.Max(history.Max(p => p.DownloadBps), history.Max(p => p.UploadBps)); + if (maxVal < 1) maxVal = 1; + double xStep = cw / (history.Count - 1); + var dl = new PointCollection(); + var ul = new PointCollection(); + for (int i = 0; i < history.Count; i++) + { + double x = i * xStep; + dl.Add(new Point(x, ch - (history[i].DownloadBps / maxVal * ch))); + ul.Add(new Point(x, ch - (history[i].UploadBps / maxVal * ch))); + } + _downloadLine.Points = dl; + _uploadLine.Points = ul; + } + + private void OnWindowPreviewClick(object sender, MouseButtonEventArgs e) + { + if (NodePopup.IsOpen) + { + // Close popup if click is outside the popup + var pos = e.GetPosition(PopupBorder); + if (pos.X < 0 || pos.Y < 0 || pos.X > PopupBorder.ActualWidth || pos.Y > PopupBorder.ActualHeight) + { + NodePopup.IsOpen = false; + } + } + } + + private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + try { DragMove(); } catch { } + } + + private void OnCloseClick(object sender, RoutedEventArgs e) => FadeOutAndClose(); + private void OnMinimizeClick(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized; + + private void OnMaximizeClick(object sender, RoutedEventArgs e) + { + _isMaximized = !_isMaximized; + WindowState = _isMaximized ? WindowState.Maximized : WindowState.Normal; + } + + private void OnNodeAreaClick(object sender, MouseButtonEventArgs e) + { + e.Handled = true; + NodePopup.IsOpen = true; + } + + private void OnPopupOpened(object? sender, EventArgs e) + { + _viewModel.IsDropdownOpen = true; + } + + private void OnPopupClosed(object? sender, EventArgs e) + { + _viewModel.IsDropdownOpen = false; + } + + private void OnDropdownToggled(bool open) + { + Dispatcher.BeginInvoke(new Action(() => NodePopup.IsOpen = open)); + } + + private void OnPopupBorderClick(object sender, MouseButtonEventArgs e) + { + e.Handled = true; // prevent bubbling to window drag + } + + private async void OnNodeItemClick(object sender, System.Windows.Controls.SelectionChangedEventArgs e) + { + if (NodeListBox.SelectedItem is string nodeName && !string.IsNullOrEmpty(nodeName)) + { + await _viewModel.SwitchNodeAsync(nodeName); + NodePopup.IsOpen = false; + } + } + + private void OnSettingsClick(object sender, RoutedEventArgs e) + { + var settings = new SettingsWindow(this); + settings.Owner = this; + settings.ShowDialog(); + if (settings.Saved) _viewModel.Reconnect(); + } + + private async void OnRefreshLatencyClick(object sender, RoutedEventArgs e) + { + BtnRefreshLatency.IsEnabled = false; + try { await _viewModel.RefreshLatencyAsync(); } finally { BtnRefreshLatency.IsEnabled = true; } + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(MainViewModel.IsConnected)) UpdateStatusDot(); + } + + private void UpdateStatusDot() + { + StatusDot.Fill = new SolidColorBrush(_viewModel.IsConnected ? Colors.LimeGreen : Colors.Tomato); + } + + private void FadeOutAndClose() + { + var fadeOut = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(150)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn } + }; + fadeOut.Completed += (_, _) => Close(); + BeginAnimation(OpacityProperty, fadeOut); + } + + protected override void OnClosed(EventArgs e) + { + _viewModel.Dispose(); + base.OnClosed(e); + } +} diff --git a/Models/AppConfig.cs b/Models/AppConfig.cs new file mode 100644 index 0000000..4a4857e --- /dev/null +++ b/Models/AppConfig.cs @@ -0,0 +1,11 @@ +namespace ClashWidget.Models; + +public class AppConfig +{ + public string ApiHost { get; set; } = "0.0.0.0"; + public int ApiPort { get; set; } = 9090; + public string ApiSecret { get; set; } = "123456"; + public string TestUrl { get; set; } = "https://www.gstatic.com/generate_204"; + + public string ApiBaseUrl => $"http://{ApiHost}:{ApiPort}"; +} diff --git a/Models/TrafficData.cs b/Models/TrafficData.cs new file mode 100644 index 0000000..d6c9ef1 --- /dev/null +++ b/Models/TrafficData.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace ClashWidget.Models; + +public class TrafficInfo +{ + [JsonPropertyName("upTotal")] + public long UpTotal { get; set; } + + [JsonPropertyName("downTotal")] + public long DownTotal { get; set; } +} + +public class ProxyEntry +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("now")] + public string? Now { get; set; } + + [JsonPropertyName("all")] + public List? All { get; set; } +} + +public class ProxyGroupResponse +{ + [JsonPropertyName("proxies")] + public List Proxies { get; set; } = new(); +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f52f604 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# ClashWidget + +> 精美的 Windows 桌面悬浮窗,实时显示 Clash Meta 代理速度和当前节点,支持一键切换。 + +基于 WPF (.NET 8) 打造,毛玻璃质感、原生窗口效果、流线型速度曲线。 + +## ✨ 功能 + +- **实时速度显示** — 300ms 刷新率,下载 ▼ / 上传 ▲ 直观展示,自适应 B/KB/MB/GB 单位 +- **速度曲线图** — 60 秒历史 Sparkline,蓝色下载 + 绿色上传 +- **节点一键切换** — 点击当前节点弹出下拉菜单,选择即切,切完自动断开旧连接 +- **延迟测速** — 启动自动测,⚡ 按钮手动刷新 +- **自定义设置** — API 地址、端口、密钥、测速 URL 均可配置 +- **macOS 风格窗口控件** — 左上角红绿灯(关闭 / 最小化 / 最大化) +- **毛玻璃背景** — 系统级丙烯酸模糊 + Win11 原生圆角 +- **始终置顶** — 悬浮不遮挡,可拖拽移动 +- **隐藏任务栏图标** — 纯桌面悬浮小部件 +- **单文件部署** — 发布版为单个 EXE,即开即用 + +## 📦 快速开始 + +### 下载运行 + +从 [Releases](../../releases) 下载最新版本: + +- `ClashWidget.exe`(完整版,155 MB)— 无需安装 .NET,Windows 10 / 11 x64 双击运行 +- `ClashWidget-lite.exe`(轻量版,~230 KB)— 需安装 [.NET 8 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/8.0) + +### 开发编译 + +```bash +# 要求:.NET 8 SDK +cd ClashWidget +dotnet run + +# 发布单文件 +dotnet publish -c Release -p:PublishSingleFile=true -p:SelfContained=true -o publish +``` + +## ⚙️ 配置 + +首次启动会在 EXE 同级目录生成 `config.json`: + +```json +{ + "ApiHost": "0.0.0.0", + "ApiPort": 9090, + "ApiSecret": "123456", + "TestUrl": "https://www.gstatic.com/generate_204" +} +``` + +| 字段 | 说明 | 默认值 | +|------|------|--------| +| `ApiHost` | Clash API 地址 | `0.0.0.0` | +| `ApiPort` | Clash API 端口 | `9090` | +| `ApiSecret` | API 密钥 | `123456` | +| `TestUrl` | 延迟测速地址 | `https://www.gstatic.com/generate_204` | + +也可通过悬浮窗右上角 ⚙ 设置按钮直接修改。 + +> 注意:`ApiHost` 需与 Clash 配置中 `external-controller` 的地址一致,`ApiSecret` 与 `secret` 一致。 + +## 🏗️ 项目结构 + +``` +ClashWidget/ +├── App.xaml(.cs) # 应用程序入口,单实例控制 +├── MainWindow.xaml(.cs) # 主悬浮窗(速度、曲线、节点) +├── SettingsWindow.xaml(.cs) # 设置窗口 +├── Models/ +│ ├── AppConfig.cs # 配置数据模型 +│ └── TrafficData.cs # API 响应模型 +├── Services/ +│ ├── ClashApiService.cs # Clash REST API 客户端 +│ └── ConfigService.cs # 配置持久化 +├── ViewModels/ +│ ├── MainViewModel.cs # 主窗口 ViewModel +│ └── SettingsViewModel.cs # 设置窗口 ViewModel +├── Converters/ +│ ├── SpeedConverter.cs # 速度值 → 文本 +│ └── NodeMatchConverter.cs # 节点选中标记 +└── Helpers/ + └── NativeMethods.cs # P/Invoke(毛玻璃、置顶等) +``` + +## 🔧 技术栈 + +- **框架**:WPF + .NET 8 +- **UI**:毛玻璃(`SetWindowCompositionAttribute`)、Win11 Acrylic Backdrop +- **数据**:Clash Meta REST API(`/traffic`、`/proxies`、`/connections`) +- **认证**:Bearer Token + URL Query Token +- **部署**:单文件 Publish(`PublishSingleFile` + `IncludeNativeLibrariesForSelfExtract`) + +## 📄 开源协议 + +MIT License diff --git a/Services/ClashApiService.cs b/Services/ClashApiService.cs new file mode 100644 index 0000000..6973ff0 --- /dev/null +++ b/Services/ClashApiService.cs @@ -0,0 +1,249 @@ +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using ClashWidget.Models; + +namespace ClashWidget.Services; + +public class ClashApiService +{ + private readonly HttpClient _httpClient; + private readonly string _testUrl; + + private readonly string _tokenQuery; + + public ClashApiService(AppConfig config) + { + _testUrl = config.TestUrl; + _tokenQuery = $"?token={Uri.EscapeDataString(config.ApiSecret)}"; + _httpClient = new HttpClient + { + BaseAddress = new Uri(config.ApiBaseUrl), + Timeout = TimeSpan.FromSeconds(5) + }; + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", config.ApiSecret); + } + + private string WithToken(string path) => $"{path}{_tokenQuery}"; + + public async Task GetTrafficAsync() + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/traffic"); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + using var response = await _httpClient.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, cts.Token); + + if (!response.IsSuccessStatusCode) return null; + + using var stream = await response.Content.ReadAsStreamAsync(cts.Token); + using var reader = new StreamReader(stream); + var line = await reader.ReadLineAsync(cts.Token); + + if (line != null) + return JsonSerializer.Deserialize(line); + } + catch { } + return null; + } + + public async Task GetProxyGroupsAsync() + { + try + { + return await _httpClient.GetFromJsonAsync("/group"); + } + catch + { + return null; + } + } + + public async Task?> GetProxiesAsync() + { + try + { + // /proxies returns {"proxies": {"GLOBAL": {...}, "🔰 选择节点": {...}}} + using var doc = await _httpClient.GetFromJsonAsync("/proxies"); + if (doc == null) return null; + + var root = doc.RootElement; + if (!root.TryGetProperty("proxies", out var proxiesEl) || proxiesEl.ValueKind != JsonValueKind.Object) + return null; + + var list = new List(); + foreach (var kv in proxiesEl.EnumerateObject()) + { + var entry = JsonSerializer.Deserialize(kv.Value.GetRawText()); + if (entry != null) + { + entry.Name = kv.Name; + list.Add(entry); + } + } + return list; + } + catch + { + return null; + } + } + + public async Task SwitchProxyAsync(string groupName, string proxyName) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + + // First: ensure parent groups route through this proxy group + var proxies = await GetProxiesAsync(); + if (proxies != null) + { + var skip = new HashSet { "DIRECT", "REJECT", "REJECT-DROP" }; + foreach (var g in proxies) + { + if (g.Type == "Selector" && g.All != null + && g.All.Contains(groupName) + && g.Now != null && skip.Contains(g.Now)) + { + var parentPayload = JsonSerializer.Serialize(new { name = groupName }); + using var parentContent = new StringContent(parentPayload, + System.Text.Encoding.UTF8, "application/json"); + using var parentReq = new HttpRequestMessage(HttpMethod.Put, + WithToken($"/proxies/{Uri.EscapeDataString(g.Name)}")) + { Content = parentContent }; + await _httpClient.SendAsync(parentReq, cts.Token); + } + } + } + + // Then: switch the actual proxy within the group + var payload = JsonSerializer.Serialize(new { name = proxyName }); + using var content = new StringContent(payload, + System.Text.Encoding.UTF8, "application/json"); + using var request = new HttpRequestMessage(HttpMethod.Put, + WithToken($"/proxies/{Uri.EscapeDataString(groupName)}")) + { Content = content }; + using var response = await _httpClient.SendAsync(request, cts.Token); + + if (response.IsSuccessStatusCode) + { + // Force close all connections to reconnect through the new proxy + try + { + using var delReq = new HttpRequestMessage(HttpMethod.Delete, + WithToken("/connections")); + await _httpClient.SendAsync(delReq, cts.Token); + } + catch { } + } + + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + public (string? nodeName, string? groupName, List? availableNodes) ExtractCurrentNode( + ProxyGroupResponse? groupResponse, List? proxiesList) + { + var skipValues = new HashSet { "DIRECT", "REJECT", "REJECT-DROP" }; + var priorityNames = new[] { "🔰 选择节点", "Proxy", "🚀 自动选择", "♻️ 自动故障转移" }; + + List? entries = proxiesList ?? groupResponse?.Proxies; + if (entries == null) return (null, null, null); + + foreach (var name in priorityNames) + { + var match = entries.FirstOrDefault(p => + p.Name == name && p.Now != null && !skipValues.Contains(p.Now)); + if (match?.Now != null) + return (FixEmojiFlags(match.Now), match.Name, match.All); + } + + var selector = entries.FirstOrDefault(p => + p.Type == "Selector" && p.Now != null && !skipValues.Contains(p.Now)); + if (selector?.Now != null) + return (FixEmojiFlags(selector.Now), selector.Name, selector.All); + + return (null, null, null); + } + + public async Task MeasureLatencyAsync() + { + try + { + using var testClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var sw = Stopwatch.StartNew(); + var response = await testClient.GetAsync(_testUrl); + sw.Stop(); + + return response.IsSuccessStatusCode ? sw.Elapsed.TotalMilliseconds : -1; + } + catch + { + return -1; + } + } + + public static string FixEmojiFlags(string text) + { + if (string.IsNullOrEmpty(text)) return text; + + var result = new System.Text.StringBuilder(); + int i = 0; + int pairStart = -1; + + while (i < text.Length) + { + char c = text[i]; + + if (char.IsHighSurrogate(c) && i + 1 < text.Length) + { + int codePoint = char.ConvertToUtf32(c, text[i + 1]); + + if (codePoint >= 0x1F1E6 && codePoint <= 0x1F1FF) + { + if (pairStart < 0) + { + pairStart = result.Length; + result.Append((char)('A' + (codePoint - 0x1F1E6))); + } + else + { + result.Append((char)('A' + (codePoint - 0x1F1E6))); + var countryCode = result.ToString(pairStart, 2); + result.Length = pairStart; + result.Append('['); + result.Append(countryCode); + result.Append(']'); + pairStart = -1; + } + i += 2; + continue; + } + else + { + if (pairStart >= 0) pairStart = -1; + result.Append(c); + result.Append(text[i + 1]); + i += 2; + continue; + } + } + + if (pairStart >= 0) pairStart = -1; + result.Append(c); + i++; + } + + return result.ToString(); + } +} diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs new file mode 100644 index 0000000..7cb5684 --- /dev/null +++ b/Services/ConfigService.cs @@ -0,0 +1,31 @@ +using System.IO; +using System.Text.Json; +using ClashWidget.Models; + +namespace ClashWidget.Services; + +public static class ConfigService +{ + private static readonly string ConfigPath = Path.Combine( + AppContext.BaseDirectory, "config.json"); + + public static AppConfig Load() + { + try + { + if (File.Exists(ConfigPath)) + { + var json = File.ReadAllText(ConfigPath); + return JsonSerializer.Deserialize(json) ?? new AppConfig(); + } + } + catch { } + return new AppConfig(); + } + + public static void Save(AppConfig config) + { + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(ConfigPath, json); + } +} diff --git a/SettingsWindow.xaml b/SettingsWindow.xaml new file mode 100644 index 0000000..2d053ce --- /dev/null +++ b/SettingsWindow.xaml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SettingsWindow.xaml.cs b/SettingsWindow.xaml.cs new file mode 100644 index 0000000..083f6dd --- /dev/null +++ b/SettingsWindow.xaml.cs @@ -0,0 +1,93 @@ +using System; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Animation; +using ClashWidget.Helpers; +using ClashWidget.Services; +using ClashWidget.ViewModels; + +namespace ClashWidget; + +public partial class SettingsWindow : Window +{ + private readonly SettingsViewModel _viewModel; + private readonly Window _owner; + public bool Saved { get; private set; } + + public SettingsWindow(Window owner) + { + InitializeComponent(); + + _owner = owner; + _viewModel = new SettingsViewModel(); + DataContext = _viewModel; + + Opacity = 0; + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + try + { + var hwnd = new WindowInteropHelper(this).Handle; + if (hwnd != IntPtr.Zero) + { + var source = HwndSource.FromHwnd(hwnd); + if (source?.CompositionTarget != null) + source.CompositionTarget.BackgroundColor = Colors.Transparent; + } + NativeMethods.ApplyAcrylic(this); + NativeMethods.SetTopmost(this); + NativeMethods.HideFromTaskbar(this); + } + catch { } + + PositionWindow(); + + var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } + }; + BeginAnimation(OpacityProperty, fadeIn); + } + + private void PositionWindow() + { + double left = _owner.Left - Width - 12; + if (left < 0) left = _owner.Left + _owner.ActualWidth + 12; + + var workArea = SystemParameters.WorkArea; + double top = _owner.Top; + if (top < workArea.Top) top = workArea.Top + 10; + if (top + Height > workArea.Bottom) top = workArea.Bottom - Height - 10; + + Left = left; + Top = top; + } + + private void OnMouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + try { DragMove(); } catch { } + } + + private void OnCloseClick(object sender, RoutedEventArgs e) => FadeOutAndClose(); + + private void OnSaveClick(object sender, RoutedEventArgs e) + { + ConfigService.Save(_viewModel.GetConfig()); + Saved = true; + FadeOutAndClose(); + } + + private void FadeOutAndClose() + { + var fadeOut = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(150)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn } + }; + fadeOut.Completed += (_, _) => Close(); + BeginAnimation(OpacityProperty, fadeOut); + } +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..c5db9d7 --- /dev/null +++ b/ViewModels/MainViewModel.cs @@ -0,0 +1,204 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Threading; +using ClashWidget.Models; +using ClashWidget.Services; + +namespace ClashWidget.ViewModels; + +public struct SpeedPoint +{ + public double DownloadBps { get; set; } + public double UploadBps { get; set; } +} + +public class MainViewModel : INotifyPropertyChanged, IDisposable +{ + private ClashApiService _apiService; + private readonly DispatcherTimer _speedTimer; + private TrafficInfo? _lastTraffic; + private DateTime _lastPollTime; + private DateTime _lastNodeFetchTime = DateTime.MinValue; + private bool _isPolling; + private string? _groupName; + private const int MaxHistoryPoints = 200; + private static readonly TimeSpan NodeFetchInterval = TimeSpan.FromSeconds(3); + + public MainViewModel() + { + var config = ConfigService.Load(); + _apiService = new ClashApiService(config); + _speedTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; + _speedTimer.Tick += OnSpeedTimerTick; + } + + public ObservableCollection SpeedHistory { get; } = new(); + public ObservableCollection AvailableNodes { get; } = new(); + private readonly Dictionary _nodeRawNames = new(); + + private double _downloadSpeed; + public double DownloadSpeed + { + get => _downloadSpeed; + set => SetProperty(ref _downloadSpeed, value); + } + + private double _uploadSpeed; + public double UploadSpeed + { + get => _uploadSpeed; + set => SetProperty(ref _uploadSpeed, value); + } + + private string _currentNode = "Connecting..."; + public string CurrentNode + { + get => _currentNode; + set => SetProperty(ref _currentNode, value); + } + + private bool _isConnected; + public bool IsConnected + { + get => _isConnected; + set => SetProperty(ref _isConnected, value); + } + + private string _latencyText = ""; + public string LatencyText + { + get => _latencyText; + set => SetProperty(ref _latencyText, value); + } + + private bool _isDropdownOpen; + public bool IsDropdownOpen + { + get => _isDropdownOpen; + set + { + if (SetProperty(ref _isDropdownOpen, value)) + DropdownToggled?.Invoke(value); + } + } + + public event Action? DropdownToggled; + + public async void Start() + { + _speedTimer.Start(); + LatencyText = "..."; + var ms = await _apiService.MeasureLatencyAsync(); + LatencyText = ms > 0 ? $"{ms:F0}ms" : "— ms"; + } + + public void Reconnect() + { + _speedTimer.Stop(); + var config = ConfigService.Load(); + _apiService = new ClashApiService(config); + _lastTraffic = null; + _lastNodeFetchTime = DateTime.MinValue; + SpeedHistory.Clear(); + DownloadSpeed = 0; + UploadSpeed = 0; + CurrentNode = "Connecting..."; + IsConnected = false; + LatencyText = "..."; + _speedTimer.Start(); + } + + public async Task RefreshLatencyAsync() + { + var ms = await _apiService.MeasureLatencyAsync(); + LatencyText = ms > 0 ? $"{ms:F0}ms" : "— ms"; + } + + public async Task SwitchNodeAsync(string nodeName) + { + if (_groupName == null) return; + var rawName = _nodeRawNames.GetValueOrDefault(nodeName, nodeName); + await _apiService.SwitchProxyAsync(_groupName, rawName); + IsDropdownOpen = false; + } + + private async void OnSpeedTimerTick(object? sender, EventArgs e) + { + if (_isPolling) return; + _isPolling = true; + + try + { + var traffic = await _apiService.GetTrafficAsync(); + + if (traffic == null) + { + IsConnected = false; + DownloadSpeed = 0; + UploadSpeed = 0; + CurrentNode = "Disconnected"; + return; + } + + IsConnected = true; + var now = DateTime.UtcNow; + + if (_lastTraffic != null) + { + var elapsed = (now - _lastPollTime).TotalSeconds; + if (elapsed > 0) + { + DownloadSpeed = Math.Max(0, (traffic.DownTotal - _lastTraffic.DownTotal) / elapsed); + UploadSpeed = Math.Max(0, (traffic.UpTotal - _lastTraffic.UpTotal) / elapsed); + + SpeedHistory.Add(new SpeedPoint { DownloadBps = DownloadSpeed, UploadBps = UploadSpeed }); + while (SpeedHistory.Count > MaxHistoryPoints) + SpeedHistory.RemoveAt(0); + } + } + + _lastTraffic = traffic; + _lastPollTime = now; + + if (now - _lastNodeFetchTime >= NodeFetchInterval) + { + _lastNodeFetchTime = now; + var groups = await _apiService.GetProxyGroupsAsync(); + var proxies = await _apiService.GetProxiesAsync(); + var (nodeName, groupName, available) = _apiService.ExtractCurrentNode(groups, proxies); + + if (nodeName != null) CurrentNode = nodeName; + _groupName = groupName; + + if (available != null) + { + AvailableNodes.Clear(); + _nodeRawNames.Clear(); + foreach (var raw in available) + { + var display = ClashApiService.FixEmojiFlags(raw); + AvailableNodes.Add(display); + _nodeRawNames[display] = raw; + } + } + } + } + finally + { + _isPolling = false; + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + return true; + } + + public void Dispose() => _speedTimer.Stop(); +} diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..4ecf374 --- /dev/null +++ b/ViewModels/SettingsViewModel.cs @@ -0,0 +1,71 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using ClashWidget.Models; +using ClashWidget.Services; + +namespace ClashWidget.ViewModels; + +public class SettingsViewModel : INotifyPropertyChanged +{ + private readonly AppConfig _config; + + public SettingsViewModel() + { + _config = ConfigService.Load(); + _apiHost = _config.ApiHost; + _apiPort = _config.ApiPort.ToString(); + _apiSecret = _config.ApiSecret; + _testUrl = _config.TestUrl; + } + + private string _apiHost; + public string ApiHost + { + get => _apiHost; + set => SetProperty(ref _apiHost, value); + } + + private string _apiPort; + public string ApiPort + { + get => _apiPort; + set => SetProperty(ref _apiPort, value); + } + + private string _apiSecret; + public string ApiSecret + { + get => _apiSecret; + set => SetProperty(ref _apiSecret, value); + } + + private string _testUrl; + public string TestUrl + { + get => _testUrl; + set => SetProperty(ref _testUrl, value); + } + + public AppConfig GetConfig() + { + int.TryParse(ApiPort, out int port); + if (port <= 0 || port > 65535) port = 9090; + + return new AppConfig + { + ApiHost = ApiHost.Trim(), + ApiPort = port, + ApiSecret = ApiSecret.Trim(), + TestUrl = TestUrl.Trim() + }; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void SetProperty(ref T field, T value, [CallerMemberName] string? name = null) + { + if (EqualityComparer.Default.Equals(field, value)) return; + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } +} diff --git a/app.ico b/app.ico new file mode 100644 index 0000000..b23ba2b Binary files /dev/null and b/app.ico differ diff --git a/msbuild.binlog b/msbuild.binlog new file mode 100644 index 0000000..4c54f11 Binary files /dev/null and b/msbuild.binlog differ