initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
publish*/
|
||||||
|
*.pdb
|
||||||
|
*.user
|
||||||
|
.vs/
|
||||||
|
config.json
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}";
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Reference in New Issue
Block a user