mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
fix.修ci,修融合桌面,修启动器
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
102
LanMountainDesktop.Tests/SingleInstanceServiceTests.cs
Normal file
102
LanMountainDesktop.Tests/SingleInstanceServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user