Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
67 changes: 67 additions & 0 deletions Helpers/ActiveAcrylicBackdrop.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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);

/// <summary>Re-reads the theme for a window whose content theme changed while hidden.</summary>
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,
};
}
}
1 change: 1 addition & 0 deletions Helpers/L.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
129 changes: 126 additions & 3 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Comment on lines +324 to +327

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate restoration on an actual second left-click

When the panel loses activation within the double-click interval while the pointer remains over the tray icon, this handler treats any light dismiss as a second click because IsSecondTrayClick checks only elapsed time and cursor position. For example, right-clicking the icon to open its context menu or quickly pressing Alt+Tab after opening the panel restores the full main window and steals focus even though no second left-click occurred. Track an actual tray-button event rather than inferring one from every deactivation.

Useful? React with 👍 / 👎.

};
// 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();
Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 24 additions & 0 deletions Strings/en-US/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,30 @@
<data name="Tray_TooltipIdle" xml:space="preserve">
<value>XrayUI - Idle</value>
</data>
<data name="TrayPanel_Connected" xml:space="preserve">
<value>Connected</value>
</data>
<data name="TrayPanel_Title.Text" xml:space="preserve">
<value>Quick Access</value>
</data>
<data name="TrayPanel_OpenMain.Content" xml:space="preserve">
<value>Main Window</value>
</data>
<data name="TrayPanel_RoutingSmart.Content" xml:space="preserve">
<value>Smart</value>
</data>
<data name="TrayPanel_RoutingGlobal.Content" xml:space="preserve">
<value>Global</value>
</data>
<data name="TrayPanel_SystemProxyLabel.Text" xml:space="preserve">
<value>System Proxy</value>
</data>
<data name="TrayPanel_TunLabel.Text" xml:space="preserve">
<value>TUN</value>
</data>
<data name="TrayPanel_NodeLabel.Text" xml:space="preserve">
<value>Node</value>
</data>
<data name="ServerList_Edit" xml:space="preserve">
<value>Edit</value>
</data>
Expand Down
24 changes: 24 additions & 0 deletions Strings/zh-CN/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,30 @@
<data name="Tray_TooltipIdle" xml:space="preserve">
<value>XrayUI - 未连接</value>
</data>
<data name="TrayPanel_Connected" xml:space="preserve">
<value>已连接</value>
</data>
<data name="TrayPanel_Title.Text" xml:space="preserve">
<value>快速访问</value>
</data>
<data name="TrayPanel_OpenMain.Content" xml:space="preserve">
<value>主界面</value>
</data>
<data name="TrayPanel_RoutingSmart.Content" xml:space="preserve">
<value>智能分流</value>
</data>
<data name="TrayPanel_RoutingGlobal.Content" xml:space="preserve">
<value>全局路由</value>
</data>
<data name="TrayPanel_SystemProxyLabel.Text" xml:space="preserve">
<value>系统代理</value>
</data>
<data name="TrayPanel_TunLabel.Text" xml:space="preserve">
<value>TUN</value>
</data>
<data name="TrayPanel_NodeLabel.Text" xml:space="preserve">
<value>节点</value>
</data>
<data name="ServerList_Edit" xml:space="preserve">
<value>编辑</value>
</data>
Expand Down
3 changes: 2 additions & 1 deletion ViewModels/ControlPanelViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
private bool IsCustomConfigActive => IsTunMode ? _useTunConfigProfile : _useProxyConfigProfile;
public bool IsCustomConfigActive => IsTunMode ? _useTunConfigProfile : _useProxyConfigProfile;

/// <summary>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
Expand All @@ -753,6 +753,7 @@ public void ApplyConfigProfileState(bool useTunProfile, bool useProxyProfile)

private void NotifyConfigProfileStateChanged()
{
OnPropertyChanged(nameof(IsCustomConfigActive));
OnPropertyChanged(nameof(IsBuiltInConfigEnabled));
OnPropertyChanged(nameof(RoutingModeText));
}
Expand Down
10 changes: 8 additions & 2 deletions ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading