diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index 4a3a97c..f524c7c 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -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 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, diff --git a/LanMountainDesktop.Shared.Contracts/Launcher/HostExitCodes.cs b/LanMountainDesktop.Shared.Contracts/Launcher/HostExitCodes.cs new file mode 100644 index 0000000..f0f78fe --- /dev/null +++ b/LanMountainDesktop.Shared.Contracts/Launcher/HostExitCodes.cs @@ -0,0 +1,18 @@ +namespace LanMountainDesktop.Shared.Contracts.Launcher; + +/// +/// Standardized host process exit codes consumed by the launcher. +/// +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; +} diff --git a/LanMountainDesktop.Tests/SingleInstanceServiceTests.cs b/LanMountainDesktop.Tests/SingleInstanceServiceTests.cs new file mode 100644 index 0000000..305b727 --- /dev/null +++ b/LanMountainDesktop.Tests/SingleInstanceServiceTests.cs @@ -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); + } +} diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index a615549..2056362 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -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(SettingsScope.App); - if (appSnapshot.EnableThreeFingerSwipe) + if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop) { - // 显示透明覆盖层窗口 EnsureTransparentOverlayWindow(); _transparentOverlayWindow?.Show(); } diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index 5b41667..c477e68 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -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; } diff --git a/LanMountainDesktop/Services/FusedDesktopManagerService.cs b/LanMountainDesktop/Services/FusedDesktopManagerService.cs index 3810525..789d511 100644 --- a/LanMountainDesktop/Services/FusedDesktopManagerService.cs +++ b/LanMountainDesktop/Services/FusedDesktopManagerService.cs @@ -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(); diff --git a/LanMountainDesktop/Services/SingleInstanceService.cs b/LanMountainDesktop/Services/SingleInstanceService.cs index f6df3f5..48c6913 100644 --- a/LanMountainDesktop/Services/SingleInstanceService.cs +++ b/LanMountainDesktop/Services/SingleInstanceService.cs @@ -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) { diff --git a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs index 6a3ef6c..0820935 100644 --- a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs +++ b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs @@ -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); diff --git a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs index 6fb2d10..db01659 100644 --- a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs +++ b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs @@ -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); + } } /// @@ -185,7 +198,25 @@ public partial class TransparentOverlayWindow : Window /// 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); } ///