diff --git a/LanMountainDesktop.PluginsInstallHelper/Program.cs b/LanMountainDesktop.PluginsInstallHelper/Program.cs index 04677f1..3f04ac8 100644 --- a/LanMountainDesktop.PluginsInstallHelper/Program.cs +++ b/LanMountainDesktop.PluginsInstallHelper/Program.cs @@ -6,6 +6,13 @@ using LanMountainDesktop.PluginSdk; internal static class Program { + private static readonly TimeSpan[] RetryDelays = + [ + TimeSpan.FromMilliseconds(120), + TimeSpan.FromMilliseconds(250), + TimeSpan.FromMilliseconds(500) + ]; + private static async Task Main(string[] args) { var result = new HelperResult(); @@ -35,10 +42,12 @@ internal static class Program var manifest = ReadManifestFromPackage(fullSourcePath); Directory.CreateDirectory(fullPluginsDirectory); - RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id); - var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id)); - File.Copy(fullSourcePath, destinationPath, overwrite: true); + var stagingPath = destinationPath + ".incoming"; + DeleteFileWithRetry(stagingPath); + CopyWithRetry(fullSourcePath, stagingPath, overwrite: true); + RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath); + MoveWithOverwriteRetry(stagingPath, destinationPath); result = new HelperResult { @@ -123,7 +132,7 @@ internal static class Program return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}"); } - private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId) + private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath) { var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName)); foreach (var existingPackagePath in Directory @@ -133,13 +142,19 @@ internal static class Program { try { + if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) || + string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase)) + { + continue; + } + var existingManifest = ReadManifestFromPackage(existingPackagePath); if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase)) { continue; } - File.Delete(existingPackagePath); + DeleteFileWithRetry(existingPackagePath); } catch { @@ -148,6 +163,56 @@ internal static class Program } } + private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite) + { + Retry(() => File.Copy(sourcePath, destinationPath, overwrite)); + } + + private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath) + { + Retry(() => File.Move(sourcePath, destinationPath, overwrite: true)); + } + + private static void DeleteFileWithRetry(string filePath) + { + Retry(() => + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + }); + } + + private static void Retry(Action action) + { + Exception? lastException = null; + + for (var attempt = 0; attempt <= RetryDelays.Length; attempt++) + { + try + { + action(); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + lastException = ex; + if (attempt >= RetryDelays.Length) + { + break; + } + + Thread.Sleep(RetryDelays[attempt]); + } + } + + if (lastException is not null) + { + throw lastException; + } + } + private static string BuildInstalledPackageFileName(string pluginId) { var invalidChars = Path.GetInvalidFileNameChars(); diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 88d65a1..0b791f7 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -271,6 +271,10 @@ public partial class App : Application mainWindow.Activate(); mainWindow.Topmost = true; mainWindow.Topmost = false; + if (mainWindow is MainWindow lanMountainMainWindow) + { + lanMountainMainWindow.ShowSingleInstanceNotice(); + } } catch (Exception ex) { diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 6b0b1af..9a6ed2e 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -743,7 +743,10 @@ "placement.fit": "Fit", "placement.stretch": "Stretch", "placement.center": "Center", - "placement.tile": "Tile" -} + "placement.tile": "Tile", + "single_instance.notice.title": "App already open", + "single_instance.notice.description": "LanMountainDesktop is already running. Switched back to the active desktop.", + "single_instance.notice.button": "Got it" + } diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index cf8075a..ef8a059 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -743,7 +743,10 @@ "placement.fit": "适应", "placement.stretch": "拉伸", "placement.center": "居中", - "placement.tile": "平铺" -} + "placement.tile": "平铺", + "single_instance.notice.title": "应用已打开", + "single_instance.notice.description": "阑山桌面已经在运行,已为你切换到当前正在使用的桌面。", + "single_instance.notice.button": "知道了" + } diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index 1a4b4f8..2291b0f 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -1,8 +1,10 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; using Avalonia; using Avalonia.WebView.Desktop; using LanMountainDesktop.Services; -using System; -using System.Threading.Tasks; namespace LanMountainDesktop; @@ -17,12 +19,11 @@ sealed class Program AppLogger.Initialize(); RegisterGlobalExceptionLogging(); - using var singleInstance = SingleInstanceService.CreateDefault(); + using var singleInstance = AcquireSingleInstance(args); if (!singleInstance.IsPrimaryInstance) { AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running."); - var notified = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2)); - ShowAlreadyRunningNotice(notified); + _ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2)); return; } @@ -72,6 +73,42 @@ sealed class Program return builder; } + private static SingleInstanceService AcquireSingleInstance(string[] args) + { + var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args); + var singleInstance = SingleInstanceService.CreateDefault(); + if (singleInstance.IsPrimaryInstance || restartParentProcessId is null) + { + return singleInstance; + } + + AppLogger.Info( + "Startup", + $"Restart relaunch detected. Waiting for previous instance pid={restartParentProcessId.Value} to exit before re-acquiring the single-instance lock."); + singleInstance.Dispose(); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(12); + WaitForRestartParentExit(restartParentProcessId.Value, deadline); + + while (DateTime.UtcNow < deadline) + { + var retryInstance = SingleInstanceService.CreateDefault(); + if (retryInstance.IsPrimaryInstance) + { + AppLogger.Info("Startup", "Restart relaunch acquired the single-instance lock."); + return retryInstance; + } + + retryInstance.Dispose(); + Thread.Sleep(150); + } + + AppLogger.Warn( + "Startup", + $"Restart relaunch timed out while waiting for the single-instance lock. pid={restartParentProcessId.Value}."); + return SingleInstanceService.CreateDefault(); + } + private static string LoadConfiguredRenderMode() { try @@ -85,14 +122,25 @@ sealed class Program } } - private static void ShowAlreadyRunningNotice(bool notifiedPrimaryInstance) + private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc) { - const string caption = "LanMountainDesktop"; - var message = notifiedPrimaryInstance - ? "应用已打开,不需要多开了。\r\n\r\n已为你切换到正在运行的阑山桌面。" - : "应用已打开,不需要多开了。\r\n\r\n请切换到正在运行的阑山桌面。"; - - WindowsNativeDialogService.ShowInformation(caption, message); + try + { + using var process = Process.GetProcessById(processId); + var remaining = deadlineUtc - DateTime.UtcNow; + if (remaining > TimeSpan.Zero) + { + process.WaitForExit((int)Math.Ceiling(remaining.TotalMilliseconds)); + } + } + catch (ArgumentException) + { + // The previous process already exited before we started waiting. + } + catch (Exception ex) + { + AppLogger.Warn("Startup", $"Failed while waiting for restart parent pid={processId} to exit.", ex); + } } private static void RegisterGlobalExceptionLogging() diff --git a/LanMountainDesktop/Services/AppRestartService.cs b/LanMountainDesktop/Services/AppRestartService.cs index f9318df..f82c010 100644 --- a/LanMountainDesktop/Services/AppRestartService.cs +++ b/LanMountainDesktop/Services/AppRestartService.cs @@ -9,6 +9,8 @@ namespace LanMountainDesktop.Services; public static class AppRestartService { + private const string RestartParentPidArgumentPrefix = "--restart-parent-pid="; + public static bool TryRestartApplication() { return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest( @@ -75,6 +77,21 @@ public static class AppRestartService return null; } + public static int? TryGetRestartParentProcessId(IReadOnlyList commandLineArgs) + { + ArgumentNullException.ThrowIfNull(commandLineArgs); + + foreach (var argument in commandLineArgs) + { + if (TryParseRestartParentProcessId(argument, out var processId)) + { + return processId; + } + } + + return null; + } + private static ProcessStartInfo CreateExecutableStartInfo( string executablePath, string? entryAssemblyPath, @@ -88,6 +105,7 @@ public static class AppRestartService }; AppendArguments(startInfo, commandLineArgs); + AppendRestartParentProcessArgument(startInfo); return startInfo; } @@ -110,6 +128,7 @@ public static class AppRestartService startInfo.ArgumentList.Add(entryAssemblyPath); AppendArguments(startInfo, commandLineArgs); + AppendRestartParentProcessArgument(startInfo); return startInfo; } @@ -117,10 +136,34 @@ public static class AppRestartService { for (var i = 1; i < commandLineArgs.Count; i++) { + if (TryParseRestartParentProcessId(commandLineArgs[i], out _)) + { + continue; + } + startInfo.ArgumentList.Add(commandLineArgs[i]); } } + private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo) + { + startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}"); + } + + private static bool TryParseRestartParentProcessId(string? argument, out int processId) + { + processId = 0; + if (string.IsNullOrWhiteSpace(argument) || + !argument.StartsWith(RestartParentPidArgumentPrefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return int.TryParse( + argument[RestartParentPidArgumentPrefix.Length..], + out processId) && processId > 0; + } + private static string? NormalizeExistingPath(string? path) { if (string.IsNullOrWhiteSpace(path)) diff --git a/LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs b/LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs new file mode 100644 index 0000000..9720f50 --- /dev/null +++ b/LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs @@ -0,0 +1,59 @@ +using System; +using Avalonia.Interactivity; +using Avalonia.Threading; + +namespace LanMountainDesktop.Views; + +public partial class MainWindow +{ + private readonly DispatcherTimer _singleInstanceNoticeTimer = new() + { + Interval = TimeSpan.FromSeconds(6) + }; + + internal void ShowSingleInstanceNotice() + { + if (Dispatcher.UIThread.CheckAccess()) + { + ShowSingleInstanceNoticeCore(); + return; + } + + Dispatcher.UIThread.Post(ShowSingleInstanceNoticeCore, DispatcherPriority.Send); + } + + private void ShowSingleInstanceNoticeCore() + { + SingleInstanceNoticeTitleTextBlock.Text = L( + "single_instance.notice.title", + "App already open"); + SingleInstanceNoticeDescriptionTextBlock.Text = L( + "single_instance.notice.description", + "LanMountainDesktop is already running. Switched back to the active desktop."); + SingleInstanceNoticeButtonTextBlock.Text = L( + "single_instance.notice.button", + "Got it"); + SingleInstanceNoticeDock.IsVisible = true; + + _singleInstanceNoticeTimer.Stop(); + _singleInstanceNoticeTimer.Tick -= OnSingleInstanceNoticeTimerTick; + _singleInstanceNoticeTimer.Tick += OnSingleInstanceNoticeTimerTick; + _singleInstanceNoticeTimer.Start(); + } + + private void OnSingleInstanceNoticeButtonClick(object? sender, RoutedEventArgs e) + { + HideSingleInstanceNotice(); + } + + private void OnSingleInstanceNoticeTimerTick(object? sender, EventArgs e) + { + HideSingleInstanceNotice(); + } + + private void HideSingleInstanceNotice() + { + _singleInstanceNoticeTimer.Stop(); + SingleInstanceNoticeDock.IsVisible = false; + } +} diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index 9cf8fc9..23b93b9 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -469,51 +469,98 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/plugins/PluginMarketInstallService.cs b/LanMountainDesktop/plugins/PluginMarketInstallService.cs index 796902f..e5ea4cb 100644 --- a/LanMountainDesktop/plugins/PluginMarketInstallService.cs +++ b/LanMountainDesktop/plugins/PluginMarketInstallService.cs @@ -78,9 +78,13 @@ internal sealed class AirAppMarketInstallService : IDisposable } } - await using var hashStream = File.OpenRead(downloadPath); - var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken); - var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); + string actualHash; + await using (var hashStream = File.OpenRead(downloadPath)) + { + var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken); + actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase)) { File.Delete(downloadPath);