diff --git a/App.xaml.cs b/App.xaml.cs
index 1a2cab7..a49c39f 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -413,7 +413,7 @@ await mainWindow.ViewModel.Personalize.Dialogs.ShowErrorAsync(
}
while (!_cleanupStarted && _pendingConnections.TryDequeue(out var pending))
{
- mainWindow.ShowForJumpList();
+ mainWindow.ShowFullWindow();
await mainWindow.ViewModel.ConnectFromJumpListAsync(pending.Request, pending.UseDefaultMode);
}
}
diff --git a/Helpers/ActiveAcrylicBackdrop.cs b/Helpers/ActiveAcrylicBackdrop.cs
new file mode 100644
index 0000000..b156ac5
--- /dev/null
+++ b/Helpers/ActiveAcrylicBackdrop.cs
@@ -0,0 +1,67 @@
+using Microsoft.UI.Composition;
+using Microsoft.UI.Composition.SystemBackdrops;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI.ViewManagement;
+
+namespace XrayUI.Helpers
+{
+ ///
+ /// Desktop acrylic that keeps its blur while the window is inactive and takes its light/dark
+ /// from the window's own content. The stock DesktopAcrylicBackdrop does neither: it drops to a
+ /// flat fallback color the moment its window loses activation — which is exactly how the tray
+ /// panel's slide-out begins — and it can disagree with an app theme forced against the system's.
+ ///
+ internal sealed partial class ActiveAcrylicBackdrop : SystemBackdrop
+ {
+ private DesktopAcrylicController? _controller;
+ private SystemBackdropConfiguration? _configuration;
+
+ protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot)
+ {
+ base.OnTargetConnected(connectedTarget, xamlRoot);
+ if (!DesktopAcrylicController.IsSupported()) return;
+
+ _configuration = new SystemBackdropConfiguration
+ {
+ IsInputActive = true,
+ IsHighContrast = new AccessibilitySettings().HighContrast,
+ Theme = ToBackdropTheme((xamlRoot.Content as FrameworkElement)?.ActualTheme),
+ };
+ // Base rather than Default: the more opaque kind, so text stays legible over whatever
+ // happens to be behind the panel.
+ _controller = new DesktopAcrylicController { Kind = DesktopAcrylicKind.Base };
+ _controller.SetSystemBackdropConfiguration(_configuration);
+ _controller.AddSystemBackdropTarget(connectedTarget);
+ }
+
+ protected override void OnTargetDisconnected(ICompositionSupportsSystemBackdrop disconnectedTarget)
+ {
+ base.OnTargetDisconnected(disconnectedTarget);
+ if (_controller is null) return;
+
+ _controller.RemoveSystemBackdropTarget(disconnectedTarget);
+ _controller.Dispose();
+ _controller = null;
+ _configuration = null;
+ }
+
+ // The base implementation would install the default configuration, bringing back the
+ // activation tracking this class exists to drop; only the theme is meant to follow.
+ protected override void OnDefaultSystemBackdropConfigurationChanged(ICompositionSupportsSystemBackdrop target, XamlRoot xamlRoot)
+ => SyncTheme((xamlRoot.Content as FrameworkElement)?.ActualTheme);
+
+ /// Re-reads the theme for a window whose content theme changed while hidden.
+ public void SyncTheme(ElementTheme? actualTheme)
+ {
+ if (_configuration is not null)
+ _configuration.Theme = ToBackdropTheme(actualTheme);
+ }
+
+ private static SystemBackdropTheme ToBackdropTheme(ElementTheme? theme) => theme switch
+ {
+ ElementTheme.Dark => SystemBackdropTheme.Dark,
+ ElementTheme.Light => SystemBackdropTheme.Light,
+ _ => SystemBackdropTheme.Default,
+ };
+ }
+}
diff --git a/Helpers/L.cs b/Helpers/L.cs
index 0ef17c2..6ec9367 100644
--- a/Helpers/L.cs
+++ b/Helpers/L.cs
@@ -271,6 +271,7 @@ public static class L
public static string Tray_Exit => Loc.GetString("Tray_Exit");
// Tray_TooltipRunning is parameterized ("XrayUI - {0}") — use Loc.Format at the call site.
public static string Tray_TooltipIdle => Loc.GetString("Tray_TooltipIdle");
+ public static string TrayPanel_Connected => Loc.GetString("TrayPanel_Connected");
// ── Taskbar Jump List ──────────────────────────────────────────────────
public static string JumpList_Recent => Loc.GetString("JumpList_Recent");
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 9503037..de26a7d 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -45,6 +45,11 @@ public sealed partial class MainWindow
// Set when we parked the window off-screen at startup; cleared after
// we re-center it on the first user-initiated show (tray click).
private bool _needsCenterOnFirstShow;
+ // Opened by a left click on the tray icon; created on first use and kept.
+ private Views.TrayPanelWindow? _trayPanel;
+ // When and where the click that last opened the tray panel landed; see IsSecondTrayClick.
+ private long _trayClickAt;
+ private NativePoint _trayClickPoint;
private const uint WmQueryEndSession = 0x0011;
private const uint WmEndSession = 0x0016;
@@ -69,7 +74,7 @@ public sealed partial class MainWindow
private readonly TaskCompletionSource _initialization = new(TaskCreationOptions.RunContinuationsAsynchronously);
internal Task Initialization => _initialization.Task;
- internal void ShowForJumpList()
+ internal void ShowFullWindow()
{
RestoreFromTray();
SetMiniMode(false);
@@ -85,7 +90,7 @@ public MainWindow(LaunchKind launchKind)
var xrayService = new XrayService();
var tunService = new TunService();
var startupService = new StartupService();
- var dialogService = new DialogService(() => _initialized ? Content?.XamlRoot : null);
+ var dialogService = new DialogService(() => _initialized ? GetDialogXamlRoot() : null);
var updateService = new UpdateService();
ViewModel = new MainViewModel(dialogService, settingsService, xrayService, tunService, startupService, updateService);
@@ -230,7 +235,8 @@ private void ConfigureTray()
try
{
trayIcon = new TrayIcon(TrayIconId, iconPath, ViewModel.TrayTooltip);
- trayIcon.Selected += (_, _) => RestoreFromTray();
+ trayIcon.Selected += OnTraySelected;
+ trayIcon.LeftDoubleClick += OnTrayLeftDoubleClick;
trayIcon.ContextMenu += (_, e) => e.Flyout = BuildTrayContextMenu();
trayIcon.IsVisible = true;
return trayIcon;
@@ -254,6 +260,81 @@ private void ApplyConnectionIcon(bool running)
_trayIcon?.SetIcon(path);
}
+ // A click opens the tray panel and a double-click restores the window. The panel opens on
+ // the first click, so a double-click shows it briefly before the window takes over — the
+ // price of a panel that opens on the click rather than after the double-click time.
+ private void OnTraySelected(TrayIcon sender, TrayIconEventArgs args)
+ {
+ if (IsSecondTrayClick())
+ {
+ CloseTrayPanelAndRestore();
+ return;
+ }
+
+ var panel = _trayPanel ??= CreateTrayPanel();
+
+ // Open but never activated, so light dismiss can't close it: the icon toggles it.
+ if (panel.IsOpen)
+ {
+ panel.HidePanel();
+ return;
+ }
+
+ // Clicking the icon while the panel is open deactivates it first, which light-dismisses
+ // it; the same click then arrives here and must not reopen it, or the icon could never
+ // close the panel.
+ if (Environment.TickCount64 - panel.LastHiddenAt < GetDoubleClickTime()) return;
+
+ _trayClickAt = Environment.TickCount64;
+ _ = GetCursorPos(out _trayClickPoint);
+ panel.ShowPanel(new TrayPanelViewModel(
+ ViewModel, openMainWindow: CloseTrayPanelAndRestore, exitApplication: ExitApplication));
+ }
+
+ private void OnTrayLeftDoubleClick(TrayIcon sender, TrayIconEventArgs args) => CloseTrayPanelAndRestore();
+
+ // The panel takes activation on the first click, which can leave Windows unable to see the
+ // second as part of a double-click: it may arrive as a plain click, or only as the click
+ // that light-dismisses the panel. So a double-click is recognised here instead — a second
+ // click on the icon within the double-click time of the one that opened the panel. The
+ // opening click's record is left to expire rather than cleared on use, so every report of
+ // the second click restores the window: the light dismiss comes first, when the press has
+ // just handed the foreground to the taskbar, and can only show it; the shell's own report
+ // of the click follows with the right to bring it to the front (see RestoreFromTray).
+ private bool IsSecondTrayClick()
+ {
+ if (Environment.TickCount64 - _trayClickAt > GetDoubleClickTime()) return false;
+ if (!GetCursorPos(out var point)) return false;
+ // An icon-sized neighbourhood rather than the double-click rectangle: clicks are read
+ // after the fact, by which time the pointer may have drifted a few pixels.
+ return Math.Abs(point.X - _trayClickPoint.X) <= GetSystemMetrics(SmCxSmIcon) / 2
+ && Math.Abs(point.Y - _trayClickPoint.Y) <= GetSystemMetrics(SmCySmIcon) / 2;
+ }
+
+ private void CloseTrayPanelAndRestore()
+ {
+ _trayPanel?.HidePanel();
+ RestoreFromTray();
+ }
+
+ private Views.TrayPanelWindow CreateTrayPanel()
+ {
+ // ConfigureTray resolved both before the tray icon, and with it this panel, existed.
+ var panel = new Views.TrayPanelWindow(_idleIconPath!, _runningIconPath!);
+ panel.LightDismissed += (_, _) =>
+ {
+ if (IsSecondTrayClick())
+ CloseTrayPanelAndRestore();
+ };
+ // Back in the tray once the panel is gone: give back what it held, as HideToTray does.
+ panel.Hidden += (_, _) =>
+ {
+ if (_isHiddenToTray)
+ ReleaseUiResources();
+ };
+ return panel;
+ }
+
private MenuFlyout BuildTrayContextMenu()
{
var flyout = new MenuFlyout();
@@ -301,6 +382,21 @@ private bool HideToTray()
return true;
}
+ // Dialogs live in this window's XamlRoot. Raised while the window sits in the tray or under
+ // the tray panel — a connect from the panel or a hotkey that fails, the panel's TUN
+ // elevation prompt — one would block its caller behind a window nobody is looking at, so
+ // close the panel and bring the window back first, at full size: the mini window is too
+ // small to host a dialog.
+ private XamlRoot? GetDialogXamlRoot()
+ {
+ if (_isHiddenToTray || _trayPanel?.IsOpen == true)
+ {
+ _trayPanel?.HidePanel();
+ ShowFullWindow();
+ }
+ return Content?.XamlRoot;
+ }
+
internal void RestoreFromTray()
{
_isHiddenToTray = false;
@@ -314,6 +410,13 @@ internal void RestoreFromTray()
}
Activate();
+
+ // Activate never brings the window to the front by itself: it ends in SetActiveWindow,
+ // which does not raise a background app, and once the window is visible calling it again
+ // changes nothing. In the tray we are such an app, allowed the foreground only while
+ // handling the shell's report of a click on the icon, so ask for it explicitly; refused,
+ // Windows flashes the taskbar button instead.
+ _ = this.SetForegroundWindow();
}
private void HandleHotkeyMessage(int id)
@@ -709,6 +812,26 @@ public void StopBackgroundServicesOnExit(bool fastShutdown = false)
[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
+ [DllImport("user32.dll")]
+ private static extern uint GetDoubleClickTime();
+
+ private const int SmCxSmIcon = 49;
+ private const int SmCySmIcon = 50;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct NativePoint
+ {
+ public int X;
+ public int Y;
+ }
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetCursorPos(out NativePoint point);
+
+ [DllImport("user32.dll")]
+ private static extern int GetSystemMetrics(int index);
+
[DllImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetProcessWorkingSetSize(
diff --git a/Strings/en-US/Resources.resw b/Strings/en-US/Resources.resw
index 13801a5..000a953 100644
--- a/Strings/en-US/Resources.resw
+++ b/Strings/en-US/Resources.resw
@@ -142,6 +142,30 @@
XrayUI - Idle
+
+ Connected
+
+
+ Quick Access
+
+
+ Main Window
+
+
+ Smart
+
+
+ Global
+
+
+ System Proxy
+
+
+ TUN
+
+
+ Node
+
Edit
diff --git a/Strings/zh-CN/Resources.resw b/Strings/zh-CN/Resources.resw
index 120b734..2a80ee1 100644
--- a/Strings/zh-CN/Resources.resw
+++ b/Strings/zh-CN/Resources.resw
@@ -142,6 +142,30 @@
XrayUI - 未连接
+
+ 已连接
+
+
+ 快速访问
+
+
+ 主界面
+
+
+ 智能分流
+
+
+ 全局路由
+
+
+ 系统代理
+
+
+ TUN
+
+
+ 节点
+
编辑
diff --git a/ViewModels/ControlPanelViewModel.cs b/ViewModels/ControlPanelViewModel.cs
index a0dd46d..98769f4 100644
--- a/ViewModels/ControlPanelViewModel.cs
+++ b/ViewModels/ControlPanelViewModel.cs
@@ -735,7 +735,7 @@ private void CleanupCurrentTunRoutesWithoutElevation()
/// instead of the generated config. Which slot applies follows the TUN toggle, so this
/// flips as the user switches modes.
///
- private bool IsCustomConfigActive => IsTunMode ? _useTunConfigProfile : _useProxyConfigProfile;
+ public bool IsCustomConfigActive => IsTunMode ? _useTunConfigProfile : _useProxyConfigProfile;
/// Gate for the gear-menu items a config profile takes ownership of: local
/// port, routing mode, custom rules and DNS all live in the profile once it is on, so
@@ -753,6 +753,7 @@ public void ApplyConfigProfileState(bool useTunProfile, bool useProxyProfile)
private void NotifyConfigProfileStateChanged()
{
+ OnPropertyChanged(nameof(IsCustomConfigActive));
OnPropertyChanged(nameof(IsBuiltInConfigEnabled));
OnPropertyChanged(nameof(RoutingModeText));
}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 489ef1b..892f55c 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -41,8 +41,11 @@ public partial class MainViewModel : BaseViewModel
[NotifyPropertyChangedFor(nameof(FullModeVisibility))]
public partial bool IsMiniMode { get; set; }
- public string ActiveServerName =>
- (ControlPanel.IsRunning ? _activeServer : ServerList.SelectedServer)?.Name ?? L.Main_NoSelection;
+ // The node the UI describes: the live one while connected, otherwise the list selection,
+ // whichever the start button would act on.
+ public ServerEntry? DisplayedServer => ControlPanel.IsRunning ? _activeServer : ServerList.SelectedServer;
+
+ public string ActiveServerName => DisplayedServer?.Name ?? L.Main_NoSelection;
// Tray icon tooltip. Uses (IsRunning || IsReapplying) so it stays in the "running"
// form across a node switch — IsReapplying brackets the stop→start gap (the same
@@ -433,6 +436,7 @@ private void OnServerListPropertyChanged(object? sender, PropertyChangedEventArg
if (e.PropertyName == nameof(ServerListViewModel.SelectedServer))
{
ServerDetail.SelectedServer = ServerList.SelectedServer;
+ OnPropertyChanged(nameof(DisplayedServer));
OnPropertyChanged(nameof(ActiveServerName));
OnPropertyChanged(nameof(TrayTooltip));
ControlPanel.NotifyStartStopStateChanged();
@@ -461,6 +465,7 @@ private async void OnPresetImported(object? sender, System.EventArgs e)
// logically active. Clear so the UI doesn't claim a stale Active state.
UpdateActiveServer(null);
ServerList.IsProxyRunning = ControlPanel.IsRunning;
+ OnPropertyChanged(nameof(DisplayedServer));
OnPropertyChanged(nameof(ActiveServerName));
OnPropertyChanged(nameof(TrayTooltip));
@@ -536,6 +541,7 @@ private void OnControlPanelPropertyChanged(object? sender, PropertyChangedEventA
if (isRunning)
_ = _jumpList.RefreshAsync(ServerList.Servers, ControlPanel.ActiveServerId);
ServerList.IsProxyRunning = isRunning;
+ OnPropertyChanged(nameof(DisplayedServer));
OnPropertyChanged(nameof(ActiveServerName));
OnPropertyChanged(nameof(TrayTooltip));
OnPropertyChanged(nameof(MiniIsRunning));
diff --git a/ViewModels/TrayPanelViewModel.cs b/ViewModels/TrayPanelViewModel.cs
new file mode 100644
index 0000000..65831de
--- /dev/null
+++ b/ViewModels/TrayPanelViewModel.cs
@@ -0,0 +1,258 @@
+using System;
+using System.ComponentModel;
+using System.Threading.Tasks;
+using Microsoft.UI.Dispatching;
+using XrayUI.Helpers;
+using XrayUI.Models;
+
+namespace XrayUI.ViewModels
+{
+ ///
+ /// The panel a left click on the tray icon opens: connection state, routing mode and the two
+ /// proxy switches, reachable without bringing the main window back.
+ ///
+ /// The panel is rebuilt on every open and this VM is disposed when it closes. All of its
+ /// bindings go through this object instead of straight onto ControlPanelViewModel, so every
+ /// subscription into the long-lived view models is owned here and dropped in
+ /// . Bound directly, x:Bind's change trackers would stay subscribed to
+ /// them after each panel closed, until a later change found the panel collected.
+ ///
+ public sealed partial class TrayPanelViewModel : ObservableObject, IDisposable
+ {
+ private readonly MainViewModel _main;
+ private readonly Action _openMainWindow;
+ private readonly Action _exitApplication;
+ private readonly DispatcherQueue _dispatcher;
+
+ // The node the panel describes (MainViewModel.DisplayedServer), watched for a rename or a
+ // protocol change.
+ private ServerEntry? _server;
+
+ /// Brings the main window back the way a double-click does.
+ /// Quits the app, as the tray menu's exit item does.
+ public TrayPanelViewModel(MainViewModel main, Action openMainWindow, Action exitApplication)
+ {
+ _main = main;
+ _openMainWindow = openMainWindow;
+ _exitApplication = exitApplication;
+ _dispatcher = DispatcherQueue.GetForCurrentThread();
+
+ _main.PropertyChanged += OnMainPropertyChanged;
+ Panel.PropertyChanged += OnPanelPropertyChanged;
+ TrackServer();
+ }
+
+ private ControlPanelViewModel Panel => _main.ControlPanel;
+
+ // ── Header ────────────────────────────────────────────────────────────
+
+ /// Picks the green app icon, on the same terms as the tray icon: a node switch
+ /// (IsReapplying) keeps it green rather than flashing it blue.
+ public bool ShowsRunning => _main.TrayShowsRunning;
+
+ public bool IsConnected => Panel.IsRunning;
+
+ private bool CanToggleConnection() => Panel.CanStartStop;
+
+ [RelayCommand(CanExecute = nameof(CanToggleConnection))]
+ private Task ToggleConnectionAsync() => Panel.StartStopCommand.ExecuteAsync(null);
+
+ [RelayCommand]
+ private void OpenMainWindow() => _openMainWindow();
+
+ [RelayCommand]
+ private void ExitApplication() => _exitApplication();
+
+ // ── Node and status ───────────────────────────────────────────────────
+
+ public string ServerName => _main.ActiveServerName;
+
+ public string ProtocolText => _server?.DisplayProtocol ?? string.Empty;
+
+ // The code, coloured in the view by ProtocolToBrushConverter as in the server list.
+ public string? Protocol => _server?.Protocol;
+
+ // With no node selected there is no protocol to caption the name with, so a plain
+ // label takes its place and the row keeps its shape.
+ public Visibility ProtocolVisibility => _server is null ? Visibility.Collapsed : Visibility.Visible;
+
+ public Visibility NodeLabelVisibility => _server is null ? Visibility.Visible : Visibility.Collapsed;
+
+ public string StatusText =>
+ Panel.IsReapplying ? L.ControlPanel_StatusApplying :
+ Panel.IsRunning ? L.TrayPanel_Connected :
+ L.Main_NotConnected;
+
+ public bool IsBusy => Panel.IsReapplying;
+
+ public Visibility ConnectedDotVisibility =>
+ Panel.IsRunning && !Panel.IsReapplying ? Visibility.Visible : Visibility.Collapsed;
+
+ public Visibility IdleDotVisibility =>
+ !Panel.IsRunning && !Panel.IsReapplying ? Visibility.Visible : Visibility.Collapsed;
+
+ // ── Routing ───────────────────────────────────────────────────────────
+
+ // Both false while a config profile owns routing, so neither built-in mode reads as the
+ // one in effect.
+ public bool IsSmartRouting => !Panel.IsCustomConfigActive && Panel.RoutingMode != "global";
+
+ public bool IsGlobalRouting => !Panel.IsCustomConfigActive && Panel.RoutingMode == "global";
+
+ public bool CanChangeRouting => Panel.IsBuiltInConfigEnabled;
+
+ [RelayCommand]
+ private void SelectRouting(string mode)
+ {
+ if (mode == Panel.RoutingMode || !CanChangeRouting)
+ {
+ // A ToggleButton unchecks itself when clicked again; put the real state back.
+ NotifyRoutingChanged();
+ return;
+ }
+
+ Defer(() => _ = Panel.SetRoutingModeCommand.ExecuteAsync(mode));
+ }
+
+ // ── Switches ──────────────────────────────────────────────────────────
+
+ public bool IsSystemProxyOn
+ {
+ get => Panel.IsSystemProxyEnabled;
+ set
+ {
+ if (value == Panel.IsSystemProxyEnabled || !CanToggleSystemProxy) return;
+ Defer(() => _ = Panel.SetProxyModeCommand.ExecuteAsync(value ? "system" : "manual"));
+ }
+ }
+
+ public bool CanToggleSystemProxy => Panel.IsModeToggleEnabled;
+
+ public bool IsTunOn
+ {
+ get => Panel.IsTunMode;
+ set
+ {
+ if (value == Panel.IsTunMode || !CanToggleTun) return;
+ // Unelevated, turning TUN on raises the elevation prompt; MainWindow closes the
+ // panel and brings itself back to host it.
+ Defer(() => Panel.IsTunMode = value);
+ }
+ }
+
+ public bool CanToggleTun => Panel.IsTunToggleEnabled;
+
+ // ── Change tracking ───────────────────────────────────────────────────
+
+ private void OnMainPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(MainViewModel.ActiveServerName):
+ OnPropertyChanged(nameof(ServerName));
+ break;
+ case nameof(MainViewModel.DisplayedServer):
+ TrackServer();
+ break;
+ }
+ }
+
+ private void OnPanelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(ControlPanelViewModel.IsRunning):
+ OnPropertyChanged(nameof(IsConnected));
+ NotifyStatusChanged();
+ break;
+ case nameof(ControlPanelViewModel.IsReapplying):
+ OnPropertyChanged(nameof(IsBusy));
+ NotifyStatusChanged();
+ break;
+ case nameof(ControlPanelViewModel.CanStartStop):
+ ToggleConnectionCommand.NotifyCanExecuteChanged();
+ break;
+ case nameof(ControlPanelViewModel.RoutingMode):
+ case nameof(ControlPanelViewModel.IsCustomConfigActive):
+ NotifyRoutingChanged();
+ break;
+ case nameof(ControlPanelViewModel.IsBuiltInConfigEnabled):
+ OnPropertyChanged(nameof(CanChangeRouting));
+ break;
+ case nameof(ControlPanelViewModel.IsSystemProxyEnabled):
+ OnPropertyChanged(nameof(IsSystemProxyOn));
+ break;
+ case nameof(ControlPanelViewModel.IsModeToggleEnabled):
+ OnPropertyChanged(nameof(CanToggleSystemProxy));
+ break;
+ case nameof(ControlPanelViewModel.IsTunMode):
+ OnPropertyChanged(nameof(IsTunOn));
+ break;
+ case nameof(ControlPanelViewModel.IsTunToggleEnabled):
+ OnPropertyChanged(nameof(CanToggleTun));
+ break;
+ }
+ }
+
+ private void OnServerPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ switch (e.PropertyName)
+ {
+ // ActiveServerName reads the name off this entry but is not raised on a rename.
+ case nameof(ServerEntry.Name):
+ OnPropertyChanged(nameof(ServerName));
+ break;
+ // Also what RefreshProtocolColor raises after a palette edit.
+ case nameof(ServerEntry.Protocol):
+ OnPropertyChanged(nameof(ProtocolText));
+ OnPropertyChanged(nameof(Protocol));
+ break;
+ }
+ }
+
+ private void TrackServer()
+ {
+ var server = _main.DisplayedServer;
+ if (ReferenceEquals(server, _server)) return;
+
+ if (_server is not null) _server.PropertyChanged -= OnServerPropertyChanged;
+ _server = server;
+ if (_server is not null) _server.PropertyChanged += OnServerPropertyChanged;
+
+ OnPropertyChanged(nameof(ProtocolText));
+ OnPropertyChanged(nameof(Protocol));
+ OnPropertyChanged(nameof(ProtocolVisibility));
+ OnPropertyChanged(nameof(NodeLabelVisibility));
+ }
+
+ private void NotifyStatusChanged()
+ {
+ OnPropertyChanged(nameof(ShowsRunning));
+ OnPropertyChanged(nameof(StatusText));
+ OnPropertyChanged(nameof(ConnectedDotVisibility));
+ OnPropertyChanged(nameof(IdleDotVisibility));
+ }
+
+ private void NotifyRoutingChanged()
+ {
+ OnPropertyChanged(nameof(IsSmartRouting));
+ OnPropertyChanged(nameof(IsGlobalRouting));
+ }
+
+ ///
+ /// Runs a command once the binding update that requested it has unwound. The commands
+ /// raise the very properties the requesting control is bound to — TUN even reverts its
+ /// own toggle synchronously — and deferring keeps that echo from re-entering a control
+ /// that is still raising its own change.
+ ///
+ private void Defer(DispatcherQueueHandler action) => _dispatcher.TryEnqueue(action);
+
+ public void Dispose()
+ {
+ _main.PropertyChanged -= OnMainPropertyChanged;
+ Panel.PropertyChanged -= OnPanelPropertyChanged;
+ if (_server is not null) _server.PropertyChanged -= OnServerPropertyChanged;
+ _server = null;
+ }
+ }
+}
diff --git a/Views/TrayPanelControl.xaml b/Views/TrayPanelControl.xaml
new file mode 100644
index 0000000..243307e
--- /dev/null
+++ b/Views/TrayPanelControl.xaml
@@ -0,0 +1,229 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/TrayPanelControl.xaml.cs b/Views/TrayPanelControl.xaml.cs
new file mode 100644
index 0000000..56b57be
--- /dev/null
+++ b/Views/TrayPanelControl.xaml.cs
@@ -0,0 +1,181 @@
+using System;
+using System.ComponentModel;
+using Microsoft.UI.Xaml.Automation;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.Foundation;
+using Windows.UI;
+using Windows.UI.ViewManagement;
+using Colors = Microsoft.UI.Colors;
+using XrayUI.Helpers;
+
+namespace XrayUI.Views
+{
+ public sealed partial class TrayPanelControl
+ {
+ // The connection glyph on its 24-unit grid, one point for each corner of the square.
+ // The triangle's tip counts twice, so the morph opens it into the square's right edge
+ // and the shape stays symmetric top to bottom the whole way.
+ private static readonly Point[] PlayGlyph = [new(6, 4.5), new(20, 12), new(20, 12), new(6, 19.5)];
+ private static readonly Point[] StopGlyph = [new(4, 4), new(20, 4), new(20, 20), new(4, 20)];
+ private static readonly TimeSpan GlyphMorphDuration = TimeSpan.FromMilliseconds(300);
+
+ private ImageSource? _idleIcon;
+ private ImageSource? _runningIcon;
+ private readonly UISettings _uiSettings = new();
+ private bool _isLoaded;
+ private bool _glyphShowsConnected;
+ private Storyboard? _glyphMorph;
+ private long _enabledToken;
+
+ public TrayPanelViewModel ViewModel { get; }
+
+ public TrayPanelControl(TrayPanelViewModel viewModel)
+ {
+ // Assigned before InitializeComponent so the TwoWay bindings on the switches start
+ // from the VM's values instead of pushing control defaults into it.
+ ViewModel = viewModel;
+ InitializeComponent();
+
+ ConnectionButton.Background = LocalBrush("ConnectionBackground");
+ ConnectionGlyph.Stroke = LocalBrush("ConnectionForeground");
+ UpdateConnectionAppearance();
+ SnapConnectionGlyph();
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ SetLabel(ExitButton, L.Tray_Exit);
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ if (_isLoaded) return;
+ _isLoaded = true;
+ ViewModel.PropertyChanged += OnViewModelPropertyChanged;
+ ActualThemeChanged += OnActualThemeChanged;
+ _enabledToken = ConnectionButton.RegisterPropertyChangedCallback(
+ IsEnabledProperty, (_, _) => UpdateGlyphOpacity());
+ UpdateConnectionAppearance();
+ SnapConnectionGlyph();
+ }
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ if (!_isLoaded) return;
+ _isLoaded = false;
+ ViewModel.PropertyChanged -= OnViewModelPropertyChanged;
+ ActualThemeChanged -= OnActualThemeChanged;
+ ConnectionButton.UnregisterPropertyChangedCallback(IsEnabledProperty, _enabledToken);
+ _glyphMorph?.Stop();
+ }
+
+ private void OnActualThemeChanged(FrameworkElement sender, object args) => UpdateConnectionAppearance();
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(TrayPanelViewModel.IsConnected)) return;
+ UpdateConnectionAppearance();
+ MorphConnectionGlyph();
+ }
+
+ private void UpdateConnectionAppearance()
+ {
+ var running = ViewModel.IsConnected;
+ LocalBrush("ConnectionBackground").Color = running ? DangerColor("DangerAccentButtonBackground") : Colors.Transparent;
+ LocalBrush("ButtonBackgroundPointerOver").Color = running ? DangerColor("DangerAccentButtonBackgroundPointerOver") : LocalBrush("IdleHoverBackground").Color;
+ LocalBrush("ButtonBackgroundPressed").Color = running ? DangerColor("DangerAccentButtonBackgroundPressed") : LocalBrush("IdlePressedBackground").Color;
+ LocalBrush("ButtonBackgroundDisabled").Color = running ? DangerColor("DangerAccentButtonBackgroundDisabled") : Colors.Transparent;
+ LocalBrush("ConnectionForeground").Color = running ? Colors.White : LocalBrush("IdleForeground").Color;
+ SetLabel(ConnectionButton, running ? L.ControlPanel_Stop : L.ControlPanel_Start);
+ UpdateGlyphOpacity();
+ }
+
+ private SolidColorBrush LocalBrush(string key) => (SolidColorBrush)ConnectionButton.Resources[key];
+
+ private static Color DangerColor(string key) => ((SolidColorBrush)Application.Current.Resources[key]).Color;
+
+ private void UpdateGlyphOpacity() => ConnectionGlyph.Opacity = ConnectionButton.IsEnabled ? 1 : 0.4;
+
+ private void SnapConnectionGlyph()
+ {
+ _glyphMorph?.Stop();
+ _glyphShowsConnected = ViewModel.IsConnected;
+ SetGlyphPoints(_glyphShowsConnected ? StopGlyph : PlayGlyph);
+ }
+
+ private void MorphConnectionGlyph()
+ {
+ var connected = ViewModel.IsConnected;
+ if (connected == _glyphShowsConnected) return;
+
+ // The new shape is set as the points' own values first. The storyboard only covers
+ // the way there, and FillBehavior.Stop hands the points back to those values.
+ var from = connected ? PlayGlyph : StopGlyph;
+ SnapConnectionGlyph();
+ if (!_uiSettings.AnimationsEnabled) return;
+
+ // Plays on confirmed state, including changes made outside this panel. Both
+ // directions ease out over the same 300 ms, so stopping is as quick as starting.
+ var to = connected ? StopGlyph : PlayGlyph;
+ DependencyObject[] targets = [GlyphFigure, GlyphSegment1, GlyphSegment2, GlyphSegment3];
+ _glyphMorph = new Storyboard();
+ for (var i = 0; i < to.Length; i++)
+ {
+ var point = new PointAnimationUsingKeyFrames
+ {
+ EnableDependentAnimation = true,
+ FillBehavior = FillBehavior.Stop,
+ };
+ point.KeyFrames.Add(new DiscretePointKeyFrame
+ {
+ KeyTime = KeyTime.FromTimeSpan(TimeSpan.Zero),
+ Value = from[i],
+ });
+ point.KeyFrames.Add(new SplinePointKeyFrame
+ {
+ KeyTime = KeyTime.FromTimeSpan(GlyphMorphDuration),
+ Value = to[i],
+ KeySpline = new KeySpline { ControlPoint1 = new(0.2, 0.75), ControlPoint2 = new(0.34, 0.94) },
+ });
+ Storyboard.SetTarget(point, targets[i]);
+ Storyboard.SetTargetProperty(point, i == 0 ? "StartPoint" : "Point");
+ _glyphMorph.Children.Add(point);
+ }
+ _glyphMorph.Begin();
+ }
+
+ // Point 0 starts the figure; the others end its three line segments. Reached through
+ // the x:Name fields, not Segments[i]: under AOT that indexer hands back a plain
+ // PathSegment wrapper, and a C# cast to LineSegment throws InvalidCastException.
+ private void SetGlyphPoints(Point[] points)
+ {
+ GlyphFigure.StartPoint = points[0];
+ GlyphSegment1.Point = points[1];
+ GlyphSegment2.Point = points[2];
+ GlyphSegment3.Point = points[3];
+ }
+
+ /// The tray icon pictures, decoded by the host window once it knows the DPI.
+ public void SetAppIcons(ImageSource? idle, ImageSource? running)
+ {
+ _idleIcon = idle;
+ _runningIcon = running;
+ Bindings.Update();
+ }
+
+ public ImageSource? AppIconFor(bool running) => running ? _runningIcon : _idleIcon;
+
+ /// Moves keyboard focus into the panel, so Esc and Tab work as soon as it opens.
+ /// As pointer focus, because a click opened the panel: no focus rectangle until Tab.
+ public void FocusFirstControl()
+ {
+ if (!ConnectionButton.Focus(FocusState.Pointer))
+ OpenMainButton.Focus(FocusState.Pointer);
+ }
+
+ private static void SetLabel(Button button, string text)
+ {
+ ToolTipService.SetToolTip(button, text);
+ AutomationProperties.SetName(button, text);
+ }
+ }
+}
diff --git a/Views/TrayPanelWindow.xaml b/Views/TrayPanelWindow.xaml
new file mode 100644
index 0000000..0b5cc3f
--- /dev/null
+++ b/Views/TrayPanelWindow.xaml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/TrayPanelWindow.xaml.cs b/Views/TrayPanelWindow.xaml.cs
new file mode 100644
index 0000000..9cfc7ea
--- /dev/null
+++ b/Views/TrayPanelWindow.xaml.cs
@@ -0,0 +1,364 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using Microsoft.UI.Windowing;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Windows.Foundation;
+using Windows.Graphics;
+using Windows.Graphics.Imaging;
+using WinUIEx;
+using XrayUI.Helpers;
+
+namespace XrayUI.Views
+{
+ ///
+ /// Host for the tray panel: a chrome-free, always-on-top window that rests at the bottom-right
+ /// of the primary work area and slides in from behind the taskbar, the way the Windows quick
+ /// settings do. Created once and kept; the panel inside is rebuilt on every show and dropped on
+ /// hide, so a hidden panel holds no view-model subscriptions.
+ ///
+ public sealed partial class TrayPanelWindow
+ {
+ // Gap to the work-area edges, the same the Windows flyouts keep.
+ private const double EdgeMarginDip = 12;
+ private const double SlideInMs = 260;
+ private const double SlideOutMs = 200;
+ // The header icon's size in TrayPanelControl.
+ private const double AppIconDip = 20;
+
+ private readonly IntPtr _hwnd;
+ private readonly ActiveAcrylicBackdrop _backdrop = new();
+ private readonly string _idleIconPath;
+ private readonly string _runningIconPath;
+ private ImageSource? _idleIcon;
+ private ImageSource? _runningIcon;
+ private bool _iconsRequested;
+ private TrayPanelViewModel? _viewModel;
+ // Light dismiss only counts a deactivation that follows an activation of this show: a
+ // window that never got the foreground would otherwise close the instant it opened.
+ private bool _activatedSinceShow;
+ // Just below the display's bottom edge, where the panel waits off-screen.
+ private int _parkedY;
+ // The running slide's per-frame step; see StartSlide.
+ private EventHandler