mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-23 01:44:26 +08:00
fix.修ci,修融合桌面,修启动器
This commit is contained in:
@@ -184,13 +184,23 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
|
|
||||||
var completedTask = await readyOrTimeoutOrExit;
|
var completedTask = await readyOrTimeoutOrExit;
|
||||||
|
|
||||||
// 检查是否是进程先退出(异常情况)
|
// Host process exited before reporting Ready.
|
||||||
if (completedTask == processExitTask)
|
if (completedTask == processExitTask)
|
||||||
{
|
{
|
||||||
var exitCode = hostProcess.ExitCode;
|
var exitCode = hostProcess.ExitCode;
|
||||||
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited unexpectedly with code: {exitCode}");
|
Console.Error.WriteLine($"[LauncherFlowCoordinator] Host process exited before Ready. ExitCode={exitCode}.");
|
||||||
|
|
||||||
// 关闭 Splash 窗口
|
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(() =>
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -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)
|
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;
|
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
|
||||||
|
|
||||||
var hostProcess = Process.Start(processStartInfo);
|
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
|
return (new LauncherResult
|
||||||
{
|
{
|
||||||
Success = true,
|
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 LauncherIpcClient? _launcherIpcClient;
|
||||||
private LoadingStateManager? _loadingStateManager;
|
private LoadingStateManager? _loadingStateManager;
|
||||||
private LoadingStateReporter? _loadingStateReporter;
|
private LoadingStateReporter? _loadingStateReporter;
|
||||||
|
private bool _singleInstanceReleased;
|
||||||
|
private int _forcedExitScheduled;
|
||||||
|
|
||||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||||
@@ -290,16 +292,20 @@ public partial class App : Application
|
|||||||
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面...");
|
ReportStartupProgress(StartupStage.InitializingUI, 60, "正在初始化界面...");
|
||||||
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||||
},
|
},
|
||||||
() =>
|
OnDesktopLifetimeExit,
|
||||||
{
|
|
||||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
|
||||||
PerformExitCleanup();
|
|
||||||
},
|
|
||||||
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
|
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
|
||||||
StartWeatherLocationRefreshIfNeeded);
|
StartWeatherLocationRefreshIfNeeded);
|
||||||
_desktopShellHost.Initialize(this);
|
_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)
|
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
||||||
@@ -659,71 +665,103 @@ public partial class App : Application
|
|||||||
|
|
||||||
private void ActivateMainWindow()
|
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)
|
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, DispatcherPriority.Send);
|
}, 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()
|
private void EnsureTransparentOverlayWindow()
|
||||||
{
|
{
|
||||||
if (_transparentOverlayWindow is null)
|
if (_transparentOverlayWindow is null)
|
||||||
@@ -885,6 +923,57 @@ public partial class App : Application
|
|||||||
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
|
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()
|
private void PerformExitCleanup()
|
||||||
{
|
{
|
||||||
if (_exitCleanupCompleted)
|
if (_exitCleanupCompleted)
|
||||||
@@ -935,6 +1024,22 @@ public partial class App : Application
|
|||||||
disposableRegistry.Dispose();
|
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();
|
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||||
StudyAnalyticsServiceFactory.DisposeSharedService();
|
StudyAnalyticsServiceFactory.DisposeSharedService();
|
||||||
DisposeTrayIcon();
|
DisposeTrayIcon();
|
||||||
@@ -1154,11 +1259,9 @@ public partial class App : Application
|
|||||||
"DesktopShell",
|
"DesktopShell",
|
||||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||||
|
|
||||||
// 检查三指滑动功能是否启用
|
|
||||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
if (appSnapshot.EnableThreeFingerSwipe)
|
if (appSnapshot.EnableThreeFingerSwipe && appSnapshot.EnableFusedDesktop)
|
||||||
{
|
{
|
||||||
// 显示透明覆盖层窗口
|
|
||||||
EnsureTransparentOverlayWindow();
|
EnsureTransparentOverlayWindow();
|
||||||
_transparentOverlayWindow?.Show();
|
_transparentOverlayWindow?.Show();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using LanMountainDesktop.Models;
|
|||||||
using LanMountainDesktop.Plugins;
|
using LanMountainDesktop.Plugins;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
namespace LanMountainDesktop;
|
namespace LanMountainDesktop;
|
||||||
|
|
||||||
@@ -32,11 +33,26 @@ public sealed class Program
|
|||||||
AppLogger.Warn(
|
AppLogger.Warn(
|
||||||
"Startup",
|
"Startup",
|
||||||
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
|
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
|
||||||
|
Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
|
var activationAcknowledged = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
|
||||||
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,8 +117,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
|
|
||||||
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
||||||
{
|
{
|
||||||
// 已存在,可能只更新位置或尺寸
|
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
|
||||||
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
||||||
|
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
|
||||||
if (existingWindow.IsVisible == false)
|
if (existingWindow.IsVisible == false)
|
||||||
{
|
{
|
||||||
existingWindow.Show();
|
existingWindow.Show();
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ namespace LanMountainDesktop.Services;
|
|||||||
|
|
||||||
public sealed class SingleInstanceService : IDisposable
|
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 Mutex _mutex;
|
||||||
private readonly string _pipeName;
|
private readonly string _pipeName;
|
||||||
private readonly CancellationTokenSource _listenCts = new();
|
private readonly CancellationTokenSource _listenCts = new();
|
||||||
@@ -56,13 +60,24 @@ public sealed class SingleInstanceService : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"SingleInstance",
|
||||||
|
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
|
||||||
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
|
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
|
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
return TryNotifyPrimaryInstance(timeout, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryNotifyPrimaryInstance(TimeSpan timeout, out string? failureReason)
|
||||||
{
|
{
|
||||||
if (_ownsMutex || _disposed)
|
if (_ownsMutex || _disposed)
|
||||||
{
|
{
|
||||||
|
failureReason = _ownsMutex
|
||||||
|
? "current_instance_is_primary"
|
||||||
|
: "single_instance_service_disposed";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,16 +86,38 @@ public sealed class SingleInstanceService : IDisposable
|
|||||||
using var client = new NamedPipeClientStream(
|
using var client = new NamedPipeClientStream(
|
||||||
serverName: ".",
|
serverName: ".",
|
||||||
pipeName: _pipeName,
|
pipeName: _pipeName,
|
||||||
direction: PipeDirection.Out,
|
direction: PipeDirection.InOut,
|
||||||
options: PipeOptions.Asynchronous);
|
options: PipeOptions.Asynchronous);
|
||||||
|
|
||||||
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
|
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
|
||||||
client.WriteByte(1);
|
client.WriteByte(ActivationRequestCode);
|
||||||
client.Flush();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
failureReason = "primary_activation_handshake_exception";
|
||||||
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
|
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -128,14 +165,40 @@ public sealed class SingleInstanceService : IDisposable
|
|||||||
{
|
{
|
||||||
using var server = new NamedPipeServerStream(
|
using var server = new NamedPipeServerStream(
|
||||||
_pipeName,
|
_pipeName,
|
||||||
PipeDirection.In,
|
PipeDirection.InOut,
|
||||||
1,
|
1,
|
||||||
PipeTransmissionMode.Byte,
|
PipeTransmissionMode.Byte,
|
||||||
PipeOptions.Asynchronous);
|
PipeOptions.Asynchronous);
|
||||||
|
|
||||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
|
var buffer = new byte[1];
|
||||||
onActivationRequested();
|
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)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ public partial class DesktopWidgetWindow : Window
|
|||||||
ComponentContainer.Child = componentContent;
|
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)
|
protected override void OnOpened(EventArgs e)
|
||||||
{
|
{
|
||||||
base.OnOpened(e);
|
base.OnOpened(e);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ namespace LanMountainDesktop.Views;
|
|||||||
public partial class TransparentOverlayWindow : Window
|
public partial class TransparentOverlayWindow : Window
|
||||||
{
|
{
|
||||||
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||||
|
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
||||||
|
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
|
||||||
|
|
||||||
// 滑动状态
|
// 滑动状态
|
||||||
private bool _isSwipeActive;
|
private bool _isSwipeActive;
|
||||||
@@ -77,6 +79,11 @@ public partial class TransparentOverlayWindow : Window
|
|||||||
_weatherDataService = facade.Weather.GetWeatherInfoService();
|
_weatherDataService = facade.Weather.GetWeatherInfoService();
|
||||||
_timeZoneService = facade.Region.GetTimeZoneService();
|
_timeZoneService = facade.Region.GetTimeZoneService();
|
||||||
_settingsFacade = facade;
|
_settingsFacade = facade;
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
_bottomMostService.SetupBottomMost(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
@@ -84,6 +91,7 @@ public partial class TransparentOverlayWindow : Window
|
|||||||
public void SaveLayoutAndHide()
|
public void SaveLayoutAndHide()
|
||||||
{
|
{
|
||||||
SaveLayout();
|
SaveLayout();
|
||||||
|
_regionPassthroughService.ClearInteractiveRegions(this);
|
||||||
Hide();
|
Hide();
|
||||||
|
|
||||||
// Remove all components so that next time we open it builds fresh from snapshot
|
// Remove all components so that next time we open it builds fresh from snapshot
|
||||||
@@ -131,6 +139,11 @@ public partial class TransparentOverlayWindow : Window
|
|||||||
RenderAllComponents();
|
RenderAllComponents();
|
||||||
|
|
||||||
AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components.");
|
AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components.");
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
_bottomMostService.SendToBottom(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -185,7 +198,25 @@ public partial class TransparentOverlayWindow : Window
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void UpdateInteractiveRegions()
|
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>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user