initial commit

This commit is contained in:
guiling
2026-05-08 11:59:26 +08:00
commit 2b5e66c1e7
20 changed files with 1604 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
bin/
obj/
publish*/
*.pdb
*.user
.vs/
config.json
+4
View File
@@ -0,0 +1,4 @@
<Application x:Class="ClashWidget.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
</Application>
+59
View File
@@ -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);
}
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<RootNamespace>ClashWidget</RootNamespace>
<ApplicationIcon>app.ico</ApplicationIcon>
<AppHostFallbackUrl>https://dotnet.microsoft.com/download/dotnet/8.0/runtime/desktop/x64</AppHostFallbackUrl>
</PropertyGroup>
</Project>
+19
View File
@@ -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();
}
}
+27
View File
@@ -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();
}
}
+126
View File
@@ -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<AccentPolicy>(),
Data = Marshal.AllocHGlobal(Marshal.SizeOf<AccentPolicy>())
};
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 { }
}
}
+243
View File
@@ -0,0 +1,243 @@
<Window x:Class="ClashWidget.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:ClashWidget.Converters"
WindowStyle="None"
Background="#AAFFFFFF"
Topmost="True"
ResizeMode="CanResizeWithGrip"
Width="320" Height="210"
MinWidth="280" MinHeight="190"
MouseLeftButtonDown="OnMouseLeftButtonDown"
PreviewMouseLeftButtonDown="OnWindowPreviewClick">
<Window.Resources>
<converters:SpeedConverter x:Key="SpeedConverter" />
<converters:NodeMatchConverter x:Key="NodeMatch" />
</Window.Resources>
<Grid Margin="16,10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Row 0: traffic lights (left) + gear (right) -->
<Grid Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Margin="0,0,0,6">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button Width="12" Height="12" Margin="0,0,8,0"
Click="OnCloseClick" Cursor="Hand" Focusable="False">
<Button.Template>
<ControlTemplate TargetType="Button">
<Ellipse x:Name="Dot" Fill="#FF5F57" Width="12" Height="12" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Dot" Property="Fill" Value="#EE4B44" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<Button Width="12" Height="12" Margin="0,0,8,0"
Click="OnMinimizeClick" Cursor="Hand" Focusable="False">
<Button.Template>
<ControlTemplate TargetType="Button">
<Ellipse x:Name="Dot" Fill="#FFBD2E" Width="12" Height="12" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Dot" Property="Fill" Value="#E5A820" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<Button Width="12" Height="12" Margin="0,0,8,0"
Click="OnMaximizeClick" Cursor="Hand" Focusable="False">
<Button.Template>
<ControlTemplate TargetType="Button">
<Ellipse x:Name="Dot" Fill="#28C840" Width="12" Height="12" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Dot" Property="Fill" Value="#1FB530" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</StackPanel>
<Button Width="22" Height="22" HorizontalAlignment="Right"
Click="OnSettingsClick" Cursor="Hand" Focusable="False">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bg" CornerRadius="5" Background="Transparent">
<TextBlock Text="⚙" FontSize="14"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bg" Property="Background" Value="#F1F5F9" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
<!-- Row 1: Download speed -->
<TextBlock Grid.Row="1" Grid.Column="0"
Text="▼" FontSize="18" Foreground="#FF3B82F6"
VerticalAlignment="Center" Margin="0,-2,10,0" />
<TextBlock Grid.Row="1" Grid.Column="1"
Text="{Binding DownloadSpeed, Converter={StaticResource SpeedConverter}}"
FontSize="32" FontWeight="SemiBold" Foreground="#FF1E293B"
LineHeight="36" VerticalAlignment="Bottom" />
<!-- Row 2: Upload speed -->
<TextBlock Grid.Row="2" Grid.Column="0"
Text="▲" FontSize="12" Foreground="#FF10B981"
VerticalAlignment="Center" Margin="2,4,10,0" />
<TextBlock Grid.Row="2" Grid.Column="1"
Text="{Binding UploadSpeed, Converter={StaticResource SpeedConverter}}"
FontSize="14" Foreground="#FF64748B"
VerticalAlignment="Center" Margin="0,2,0,0" />
<!-- Row 3: Sparkline -->
<Border Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2"
Margin="0,6,0,2" ClipToBounds="True">
<Canvas x:Name="SparklineCanvas" Height="34" Background="Transparent" />
</Border>
<!-- Row 4: Divider -->
<Border Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="2"
Height="0.5" Background="#18000000" Margin="0,2,0,6" />
<!-- Row 5: Node + latency + dropdown trigger -->
<StackPanel Grid.Row="5" Grid.Column="0" Grid.ColumnSpan="2" Orientation="Horizontal">
<Ellipse x:Name="StatusDot" Width="8" Height="8"
VerticalAlignment="Center" Margin="2,0,8,0" />
<!-- Clickable node area -->
<Border x:Name="NodeArea" Cursor="Hand"
PreviewMouseLeftButtonDown="OnNodeAreaClick"
Background="Transparent"
Padding="0,2,6,2">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding CurrentNode}" FontSize="13"
Foreground="#FF475569" TextTrimming="CharacterEllipsis"
MaxWidth="140" VerticalAlignment="Center"
FontFamily="Segoe UI Emoji, Segoe UI, Microsoft YaHei UI"
TextOptions.TextFormattingMode="Display" />
<TextBlock Text=" ▾" FontSize="10" Foreground="#FF94A3B8"
VerticalAlignment="Center" Margin="2,0,0,0" />
</StackPanel>
</Border>
<TextBlock Text="{Binding LatencyText}" FontSize="11"
Foreground="#FF94A3B8" Margin="6,0,4,0"
VerticalAlignment="Center" />
<Button x:Name="BtnRefreshLatency" Width="22" Height="22"
Click="OnRefreshLatencyClick" Cursor="Hand" Focusable="False">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Border" CornerRadius="5"
Background="#F1F5F9" BorderBrush="#E2E8F0" BorderThickness="1">
<TextBlock Text="⚡" FontSize="11"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Border" Property="Background" Value="#E2E8F0" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</StackPanel>
<!-- Proxy dropdown popup -->
<Popup x:Name="NodePopup"
PlacementTarget="{Binding ElementName=NodeArea}"
Placement="Top" StaysOpen="True"
AllowsTransparency="True"
PopupAnimation="Slide"
Closed="OnPopupClosed"
Opened="OnPopupOpened">
<Border x:Name="PopupBorder"
Background="#FEFEFE" BorderBrush="#E2E8F0" BorderThickness="1"
CornerRadius="8" Padding="4" Margin="0,0,0,4"
MaxHeight="220" MinWidth="170"
MouseLeftButtonDown="OnPopupBorderClick">
<Border.Effect>
<DropShadowEffect BlurRadius="12" ShadowDepth="2"
Opacity="0.12" Color="Black" />
</Border.Effect>
<ListBox x:Name="NodeListBox"
ItemsSource="{Binding AvailableNodes}"
SelectionChanged="OnNodeItemClick"
BorderThickness="0" Background="Transparent"
FontSize="13" Foreground="#FF1E293B">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Ellipse Grid.Column="0" Width="6" Height="6"
Fill="#10B981" VerticalAlignment="Center"
Margin="0,0,6,0"
Visibility="Collapsed"
x:Name="SelectedDot" />
<TextBlock Grid.Column="1" Text="{Binding}"
VerticalAlignment="Center" />
</Grid>
<DataTemplate.Triggers>
<DataTrigger Value="True">
<DataTrigger.Binding>
<MultiBinding Converter="{StaticResource NodeMatch}">
<Binding Path="." />
<Binding Path="DataContext.CurrentNode"
RelativeSource="{RelativeSource AncestorType=Window}" />
</MultiBinding>
</DataTrigger.Binding>
<Setter TargetName="SelectedDot"
Property="Visibility" Value="Visible" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="ItemBorder"
Background="Transparent"
CornerRadius="5"
Padding="8,6">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ItemBorder"
Property="Background" Value="#F1F5F9" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
</Border>
</Popup>
</Grid>
</Window>
+214
View File
@@ -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);
}
}
+11
View File
@@ -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}";
}
+33
View File
@@ -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<string>? All { get; set; }
}
public class ProxyGroupResponse
{
[JsonPropertyName("proxies")]
public List<ProxyEntry> Proxies { get; set; } = new();
}
+97
View File
@@ -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)— 无需安装 .NETWindows 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
+249
View File
@@ -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<TrafficInfo?> 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<TrafficInfo>(line);
}
catch { }
return null;
}
public async Task<ProxyGroupResponse?> GetProxyGroupsAsync()
{
try
{
return await _httpClient.GetFromJsonAsync<ProxyGroupResponse>("/group");
}
catch
{
return null;
}
}
public async Task<List<ProxyEntry>?> GetProxiesAsync()
{
try
{
// /proxies returns {"proxies": {"GLOBAL": {...}, "🔰 选择节点": {...}}}
using var doc = await _httpClient.GetFromJsonAsync<JsonDocument>("/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<ProxyEntry>();
foreach (var kv in proxiesEl.EnumerateObject())
{
var entry = JsonSerializer.Deserialize<ProxyEntry>(kv.Value.GetRawText());
if (entry != null)
{
entry.Name = kv.Name;
list.Add(entry);
}
}
return list;
}
catch
{
return null;
}
}
public async Task<bool> 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<string> { "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<string>? availableNodes) ExtractCurrentNode(
ProxyGroupResponse? groupResponse, List<ProxyEntry>? proxiesList)
{
var skipValues = new HashSet<string> { "DIRECT", "REJECT", "REJECT-DROP" };
var priorityNames = new[] { "🔰 选择节点", "Proxy", "🚀 自动选择", "♻️ 自动故障转移" };
List<ProxyEntry>? 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<double> 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();
}
}
+31
View File
@@ -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<AppConfig>(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);
}
}
+99
View File
@@ -0,0 +1,99 @@
<Window x:Class="ClashWidget.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None"
Background="#AAFFFFFF"
Topmost="True"
ResizeMode="NoResize"
Width="360" Height="370"
MouseLeftButtonDown="OnMouseLeftButtonDown">
<Grid Margin="20,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Row 0: traffic light + title -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,14">
<Button Width="12" Height="12" Margin="0,0,8,0"
Click="OnCloseClick" Cursor="Hand" Focusable="False">
<Button.Template>
<ControlTemplate TargetType="Button">
<Ellipse x:Name="Dot" Fill="#FF5F57" Width="12" Height="12" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Dot" Property="Fill" Value="#EE4B44" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<TextBlock Text="Settings" FontSize="13" FontWeight="SemiBold"
Foreground="#FF1E293B" VerticalAlignment="Center" Margin="8,0,0,0" />
</StackPanel>
<!-- API Host -->
<TextBlock Grid.Row="1" Text="API Host" FontSize="11"
Foreground="#FF64748B" Margin="2,0,0,2" />
<TextBox Grid.Row="2"
Text="{Binding ApiHost, UpdateSourceTrigger=PropertyChanged}"
FontSize="13" Padding="8,6"
Background="#F8FAFC" BorderBrush="#E2E8F0"
BorderThickness="1" Margin="0,0,0,10" />
<!-- API Port -->
<TextBlock Grid.Row="3" Text="API Port" FontSize="11"
Foreground="#FF64748B" Margin="2,0,0,2" />
<TextBox Grid.Row="4"
Text="{Binding ApiPort, UpdateSourceTrigger=PropertyChanged}"
FontSize="13" Padding="8,6"
Background="#F8FAFC" BorderBrush="#E2E8F0"
BorderThickness="1" Margin="0,0,0,10" />
<!-- Secret -->
<TextBlock Grid.Row="5" Text="Secret Key" FontSize="11"
Foreground="#FF64748B" Margin="2,0,0,2" />
<TextBox Grid.Row="6"
Text="{Binding ApiSecret, UpdateSourceTrigger=PropertyChanged}"
FontSize="13" Padding="8,6"
Background="#F8FAFC" BorderBrush="#E2E8F0"
BorderThickness="1" Margin="0,0,0,12" />
<!-- Test URL -->
<TextBlock Grid.Row="7" Text="Test URL" FontSize="11"
Foreground="#FF64748B" Margin="2,0,0,2" />
<TextBox Grid.Row="8"
Text="{Binding TestUrl, UpdateSourceTrigger=PropertyChanged}"
FontSize="13" Padding="8,6"
Background="#F8FAFC" BorderBrush="#E2E8F0"
BorderThickness="1" />
<!-- Save button -->
<Button Grid.Row="9" Click="OnSaveClick" Cursor="Hand" Margin="0,14,0,0">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="SaveBorder" Background="#FF3B82F6"
CornerRadius="6" Padding="0,10">
<TextBlock Text="Save &amp; Reconnect" FontSize="13"
FontWeight="SemiBold" Foreground="White"
HorizontalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="SaveBorder" Property="Background" Value="#2563EB" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
</Window>
+93
View File
@@ -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);
}
}
+204
View File
@@ -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<SpeedPoint> SpeedHistory { get; } = new();
public ObservableCollection<string> AvailableNodes { get; } = new();
private readonly Dictionary<string, string> _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<bool>? 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<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
return true;
}
public void Dispose() => _speedTimer.Stop();
}
+71
View File
@@ -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<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.