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