fix.修ci,修融合桌面,修启动器

This commit is contained in:
lincube
2026-04-19 17:02:53 +08:00
parent 8e21364eed
commit 6343164b24
9 changed files with 569 additions and 78 deletions

View File

@@ -184,13 +184,23 @@ internal sealed class LauncherFlowCoordinator
var completedTask = await readyOrTimeoutOrExit;
// 检查是否是进程先退出(异常情况)
// Host process exited before reporting Ready.
if (completedTask == processExitTask)
{
var exitCode = hostProcess.ExitCode;
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited unexpectedly with code: {exitCode}");
// 关闭 Splash 窗口
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited before Ready. ExitCode={exitCode}.");
var recoveryResult = await TryRecoverFromEarlyHostExitAsync(
exitCode,
hostReadyTcs,
splashWindow,
loadingDetailsWindow).ConfigureAwait(false);
if (recoveryResult is not null)
{
return recoveryResult;
}
// Close Splash window for unrecoverable early exits.
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
@@ -205,7 +215,7 @@ internal sealed class LauncherFlowCoordinator
Console.Error.WriteLine($"[LauncherFlowCoordinator] Error closing splash window: {ex.Message}");
}
});
return new LauncherResult
{
Success = false,
@@ -288,6 +298,133 @@ internal sealed class LauncherFlowCoordinator
}
}
private async Task<LauncherResult?> TryRecoverFromEarlyHostExitAsync(
int exitCode,
TaskCompletionSource hostReadyTcs,
SplashWindow splashWindow,
LoadingDetailsWindow? loadingDetailsWindow)
{
if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
{
Console.WriteLine("[LauncherFlowCoordinator] Host redirected activation to an existing primary instance.");
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activated_existing_instance",
Message = "Detected existing running instance and activation was acknowledged."
};
}
if (exitCode is not HostExitCodes.SecondaryActivationFailed and not HostExitCodes.RestartLockNotAcquired)
{
return null;
}
Console.Error.WriteLine(
$"[LauncherFlowCoordinator] Activation handshake failed with exit code {exitCode}. Retrying explicit activation once...");
var (retryLaunchResult, retryProcess) = await LaunchHostWithIpcAsync(splashWindow).ConfigureAwait(false);
if (!retryLaunchResult.Success)
{
return retryLaunchResult;
}
if (retryProcess is null)
{
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "activation_retry_start_failed",
Message = "Explicit activation retry failed because no host process was created."
};
}
Console.WriteLine($"[LauncherFlowCoordinator] Explicit activation retry started. RetryPid={retryProcess.Id}.");
var retryExitTask = retryProcess.WaitForExitAsync();
var retryCompleted = await Task.WhenAny(
hostReadyTcs.Task,
retryExitTask,
Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);
if (retryCompleted == hostReadyTcs.Task)
{
Console.WriteLine("[LauncherFlowCoordinator] Host reported Ready after explicit activation retry.");
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activation_retry_ready",
Message = "Explicit activation retry succeeded and host reported Ready."
};
}
if (retryCompleted == retryExitTask)
{
var retryExitCode = retryProcess.ExitCode;
if (retryExitCode == HostExitCodes.SecondaryActivationSucceeded)
{
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activation_retry_redirected",
Message = "Explicit activation retry redirected to the existing primary instance."
};
}
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "activation_retry_failed",
Message = $"Explicit activation retry failed. ExitCode={retryExitCode}. 请结束残留后台进程后重试。"
};
}
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "activation_retry_timeout",
Message = "Explicit activation retry timed out before host became ready. 请结束残留后台进程后重试。"
};
}
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close splash window: {ex.Message}");
}
try
{
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
{
loadingDetailsWindow.Close();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[LauncherFlowCoordinator] Failed to close loading details window: {ex.Message}");
}
});
}
private async Task<(LauncherResult Result, Process? Process)> LaunchHostWithIpcAsync(SplashWindow? splashWindow = null, string? customHostPath = null)
{
// 优先使用自定义路径(调试模式选择的路径)
@@ -377,6 +514,9 @@ internal sealed class LauncherFlowCoordinator
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
var hostProcess = Process.Start(processStartInfo);
Console.WriteLine(
$"[LauncherFlowCoordinator] Host launch requested. Path='{hostPath}'; WorkingDir='{hostWorkingDir}'; " +
$"Pid={(hostProcess is null ? -1 : hostProcess.Id)}; Args='{processStartInfo.Arguments}'.");
return (new LauncherResult
{
Success = true,

View File

@@ -0,0 +1,18 @@
namespace LanMountainDesktop.Shared.Contracts.Launcher;
/// <summary>
/// Standardized host process exit codes consumed by the launcher.
/// </summary>
public static class HostExitCodes
{
public const int Success = 0;
// Secondary instance activated the existing primary instance successfully.
public const int SecondaryActivationSucceeded = 12;
// Secondary instance failed to activate the existing primary instance.
public const int SecondaryActivationFailed = 13;
// Restart relaunch couldn't acquire the single-instance lock in time.
public const int RestartLockNotAcquired = 14;
}

View File

@@ -0,0 +1,102 @@
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class SingleInstanceServiceTests
{
[Fact]
public async Task TryNotifyPrimaryInstance_ReturnsTrue_WhenPrimaryAcknowledges()
{
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
using var primary = CreateService(mutexName, pipeName);
using var secondary = CreateSecondaryService(mutexName, pipeName);
Assert.True(primary.IsPrimaryInstance);
MarkAsSecondaryForTest(secondary);
var activated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
primary.StartActivationListener(() => activated.TrySetResult());
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
Assert.True(acknowledged);
Assert.Null(failureReason);
var completed = await Task.WhenAny(activated.Task, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.Same(activated.Task, completed);
}
[Fact]
public void TryNotifyPrimaryInstance_ReturnsFalse_WhenListenerIsNotRunning()
{
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
using var primary = CreateService(mutexName, pipeName);
using var secondary = CreateSecondaryService(mutexName, pipeName);
Assert.True(primary.IsPrimaryInstance);
MarkAsSecondaryForTest(secondary);
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromMilliseconds(300), out var failureReason);
Assert.False(acknowledged);
Assert.False(string.IsNullOrWhiteSpace(failureReason));
}
private static SingleInstanceService CreateService(string mutexName, string pipeName)
{
var ctor = typeof(SingleInstanceService).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
[typeof(string), typeof(string)],
modifiers: null);
Assert.NotNull(ctor);
return (SingleInstanceService)ctor!.Invoke([mutexName, pipeName]);
}
private static SingleInstanceService CreateSecondaryService(string mutexName, string pipeName)
{
SingleInstanceService? created = null;
Exception? creationError = null;
var thread = new Thread(() =>
{
try
{
created = CreateService(mutexName, pipeName);
}
catch (Exception ex)
{
creationError = ex;
}
});
thread.IsBackground = true;
thread.Start();
thread.Join();
if (creationError is not null)
{
throw new InvalidOperationException("Failed to create secondary SingleInstanceService.", creationError);
}
Assert.NotNull(created);
return created!;
}
private static void MarkAsSecondaryForTest(SingleInstanceService service)
{
var ownsMutexField = typeof(SingleInstanceService).GetField(
"_ownsMutex",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(ownsMutexField);
ownsMutexField!.SetValue(service, false);
Assert.False(service.IsPrimaryInstance);
}
}

View File

@@ -77,6 +77,8 @@ public partial class App : Application
private LauncherIpcClient? _launcherIpcClient;
private LoadingStateManager? _loadingStateManager;
private LoadingStateReporter? _loadingStateReporter;
private bool _singleInstanceReleased;
private int _forcedExitScheduled;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
@@ -290,16 +292,20 @@ public partial class App : Application
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面...");
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
},
() =>
{
AppLogger.Info("App", "Desktop lifetime exit triggered.");
PerformExitCleanup();
},
OnDesktopLifetimeExit,
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
StartWeatherLocationRefreshIfNeeded);
_desktopShellHost.Initialize(this);
}
private void OnDesktopLifetimeExit()
{
AppLogger.Info("App", "Desktop lifetime exit triggered.");
PerformExitCleanup();
ReleaseSingleInstanceAfterExit("DesktopLifetimeExit");
ScheduleForcedProcessTermination("DesktopLifetimeExit");
}
private void OnTrayExitClick(object? sender, EventArgs e)
{
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
@@ -659,70 +665,102 @@ public partial class App : Application
private void ActivateMainWindow()
{
RestoreOrCreateMainWindow(showSingleInstanceNotice: true, source: "SingleInstance");
AppLogger.Info("SingleInstance", $"Activation callback received. Pid={Environment.ProcessId}.");
try
{
var restored = Dispatcher.UIThread.CheckAccess()
? RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance")
: Dispatcher.UIThread.InvokeAsync(
() => RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance"),
DispatcherPriority.Send).GetAwaiter().GetResult();
if (!restored)
{
throw new InvalidOperationException("Main window restore failed in activation callback.");
}
AppLogger.Info("SingleInstance", "Activation callback completed successfully.");
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Activation callback failed while restoring the desktop shell.", ex);
throw;
}
}
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
{
Dispatcher.UIThread.Post(() =>
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
return;
}
try
{
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Hide();
}
var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation();
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
if (mainWindow.WindowState == WindowState.Minimized)
{
mainWindow.WindowState = WindowState.Normal;
}
if (mainWindow.WindowState != WindowState.FullScreen)
{
mainWindow.WindowState = WindowState.FullScreen;
}
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
Dispatcher.UIThread.Post(() =>
{
mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background);
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
if (showSingleInstanceNotice)
{
mainWindow.ShowSingleInstanceNotice();
}
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
}
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
}, DispatcherPriority.Send);
}
private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
AppLogger.Warn("DesktopShell", $"Restore skipped because desktop lifetime is unavailable. Source='{source}'.");
return false;
}
try
{
AppLogger.Info("DesktopShell", $"Restoring desktop shell started. Source='{source}'.");
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Hide();
}
var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation();
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
if (mainWindow.WindowState == WindowState.Minimized)
{
mainWindow.WindowState = WindowState.Normal;
}
if (mainWindow.WindowState != WindowState.FullScreen)
{
mainWindow.WindowState = WindowState.FullScreen;
}
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
Dispatcher.UIThread.Post(() =>
{
mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background);
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
if (showSingleInstanceNotice)
{
mainWindow.ShowSingleInstanceNotice();
}
return true;
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
return false;
}
}
private void EnsureTransparentOverlayWindow()
{
@@ -885,6 +923,57 @@ public partial class App : Application
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
}
private void ReleaseSingleInstanceAfterExit(string source)
{
if (_singleInstanceReleased)
{
return;
}
_singleInstanceReleased = true;
var singleInstance = CurrentSingleInstanceService;
CurrentSingleInstanceService = null;
if (singleInstance is null)
{
AppLogger.Info("SingleInstance", $"No single-instance handle to release. Source='{source}'.");
return;
}
try
{
singleInstance.Dispose();
AppLogger.Info("SingleInstance", $"Released single-instance handle. Source='{source}'.");
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", $"Failed to release single-instance handle. Source='{source}'.", ex);
}
}
private void ScheduleForcedProcessTermination(string source)
{
if (Interlocked.Exchange(ref _forcedExitScheduled, 1) != 0)
{
return;
}
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(8)).ConfigureAwait(false);
AppLogger.Warn(
"DesktopShell",
$"Process did not terminate after desktop exit cleanup. Forcing process exit. Source='{source}'; ShutdownIntent='{_shutdownIntent}'.");
Environment.Exit(0);
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", $"Forced process termination scheduler failed. Source='{source}'.", ex);
}
});
}
private void PerformExitCleanup()
{
if (_exitCleanupCompleted)
@@ -935,6 +1024,22 @@ public partial class App : Application
disposableRegistry.Dispose();
}
if (_transparentOverlayWindow is not null)
{
try
{
_transparentOverlayWindow.Close();
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", "Failed to close transparent overlay during exit cleanup.", ex);
}
finally
{
_transparentOverlayWindow = null;
}
}
AudioRecorderServiceFactory.DisposeSharedServices();
StudyAnalyticsServiceFactory.DisposeSharedService();
DisposeTrayIcon();
@@ -1154,11 +1259,9 @@ public partial class App : Application
"DesktopShell",
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
// 检查三指滑动功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (appSnapshot.EnableThreeFingerSwipe)
if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop)
{
// 显示透明覆盖层窗口
EnsureTransparentOverlayWindow();
_transparentOverlayWindow?.Show();
}

View File

@@ -9,6 +9,7 @@ using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop;
@@ -32,11 +33,26 @@ public sealed class Program
AppLogger.Warn(
"Startup",
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
return;
}
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
var activationAcknowledged = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
if (activationAcknowledged)
{
AppLogger.Info(
"Startup",
$"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}.");
Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded;
}
else
{
AppLogger.Warn(
"Startup",
$"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}.");
Environment.ExitCode = HostExitCodes.SecondaryActivationFailed;
}
return;
}

View File

@@ -117,8 +117,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
{
// 已存在,可能只更新位置或尺寸
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
if (existingWindow.IsVisible == false)
{
existingWindow.Show();

View File

@@ -9,6 +9,10 @@ namespace LanMountainDesktop.Services;
public sealed class SingleInstanceService : IDisposable
{
private const byte ActivationRequestCode = 0x41; // 'A'
private const byte ActivationAckCode = 0x4B; // 'K'
private const byte ActivationNackCode = 0x4E; // 'N'
private readonly Mutex _mutex;
private readonly string _pipeName;
private readonly CancellationTokenSource _listenCts = new();
@@ -56,13 +60,24 @@ public sealed class SingleInstanceService : IDisposable
return;
}
AppLogger.Info(
"SingleInstance",
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
}
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
{
return TryNotifyPrimaryInstance(timeout, out _);
}
public bool TryNotifyPrimaryInstance(TimeSpan timeout, out string? failureReason)
{
if (_ownsMutex || _disposed)
{
failureReason = _ownsMutex
? "current_instance_is_primary"
: "single_instance_service_disposed";
return false;
}
@@ -71,16 +86,38 @@ public sealed class SingleInstanceService : IDisposable
using var client = new NamedPipeClientStream(
serverName: ".",
pipeName: _pipeName,
direction: PipeDirection.Out,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
client.WriteByte(1);
client.WriteByte(ActivationRequestCode);
client.Flush();
var ack = client.ReadByte();
var acknowledged = ack == ActivationAckCode;
if (!acknowledged)
{
failureReason = ack switch
{
ActivationNackCode => "primary_rejected_activation",
-1 => "ack_not_received",
_ => $"unexpected_ack_code_{ack}"
};
AppLogger.Warn(
"SingleInstance",
$"Primary activation handshake failed. AckCode={ack}; Reason='{failureReason}'; Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
return false;
}
failureReason = null;
AppLogger.Info(
"SingleInstance",
$"Primary activation acknowledged. Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
return true;
}
catch (Exception ex)
{
failureReason = "primary_activation_handshake_exception";
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
return false;
}
@@ -128,14 +165,40 @@ public sealed class SingleInstanceService : IDisposable
{
using var server = new NamedPipeServerStream(
_pipeName,
PipeDirection.In,
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
onActivationRequested();
var buffer = new byte[1];
var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
var isActivationRequest = readBytes == 1 && buffer[0] == ActivationRequestCode;
var ackCode = ActivationAckCode;
if (!isActivationRequest)
{
ackCode = ActivationNackCode;
AppLogger.Warn(
"SingleInstance",
$"Received malformed activation request. ReadBytes={readBytes}; Value={(readBytes == 1 ? buffer[0] : -1)}; Pipe='{_pipeName}'.");
}
else
{
try
{
onActivationRequested();
}
catch (Exception ex)
{
ackCode = ActivationNackCode;
AppLogger.Warn("SingleInstance", "Activation callback failed.", ex);
}
}
var ackBuffer = new[] { ackCode };
await server.WriteAsync(ackBuffer, cancellationToken).ConfigureAwait(false);
await server.FlushAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{

View File

@@ -25,6 +25,23 @@ public partial class DesktopWidgetWindow : Window
ComponentContainer.Child = componentContent;
}
public void UpdateComponentLayout(double width, double height)
{
ComponentContainer.Width = width;
ComponentContainer.Height = height;
if (ComponentContainer.Child is Control child)
{
child.Width = width;
child.Height = height;
}
if (OperatingSystem.IsWindows() && IsVisible)
{
Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render);
}
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);

View File

@@ -23,6 +23,8 @@ namespace LanMountainDesktop.Views;
public partial class TransparentOverlayWindow : Window
{
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
// 滑动状态
private bool _isSwipeActive;
@@ -77,6 +79,11 @@ public partial class TransparentOverlayWindow : Window
_weatherDataService = facade.Weather.GetWeatherInfoService();
_timeZoneService = facade.Region.GetTimeZoneService();
_settingsFacade = facade;
if (OperatingSystem.IsWindows())
{
_bottomMostService.SetupBottomMost(this);
}
}
private readonly ISettingsFacadeService _settingsFacade;
@@ -84,6 +91,7 @@ public partial class TransparentOverlayWindow : Window
public void SaveLayoutAndHide()
{
SaveLayout();
_regionPassthroughService.ClearInteractiveRegions(this);
Hide();
// Remove all components so that next time we open it builds fresh from snapshot
@@ -131,6 +139,11 @@ public partial class TransparentOverlayWindow : Window
RenderAllComponents();
AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components.");
if (OperatingSystem.IsWindows())
{
_bottomMostService.SendToBottom(this);
}
}
/// <summary>
@@ -185,7 +198,25 @@ public partial class TransparentOverlayWindow : Window
/// </summary>
private void UpdateInteractiveRegions()
{
// 编辑模式下不再需要底层穿透功能计算,这里留空或移除
_interactiveRegions.Clear();
foreach (var host in _componentHosts.Values)
{
var left = Canvas.GetLeft(host);
var top = Canvas.GetTop(host);
var width = host.Width > 0 ? host.Width : host.Bounds.Width;
var height = host.Height > 0 ? host.Height : host.Bounds.Height;
if (width <= 0 || height <= 0)
{
continue;
}
// 稍微向外扩一圈,确保拖拽和右下角缩放手柄也能命中。
_interactiveRegions.Add(new Rect(left - 12, top - 12, width + 24, height + 24));
}
_regionPassthroughService.SetInteractiveRegions(this, _interactiveRegions);
}
/// <summary>