From 5003ff1be2f9fbb213cb1c6bd7c9774950dccad8 Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 10 Mar 2026 21:25:47 +0800 Subject: [PATCH] 0.5.12 --- LanMountainDesktop/App.axaml.cs | 20 +- LanMountainDesktop/LanMountainDesktop.csproj | 1 + LanMountainDesktop/Localization/en-US.json | 15 +- LanMountainDesktop/Localization/zh-CN.json | 15 +- LanMountainDesktop/Program.cs | 43 +- LanMountainDesktop/Services/AppLogger.cs | 169 ++++ .../Services/AppSettingsService.cs | 10 +- .../Services/ComponentSettingsService.cs | 53 +- .../Services/DesktopLayoutSettingsService.cs | 13 +- .../Services/FileOperationRetryHelper.cs | 98 ++ .../Services/LauncherSettingsService.cs | 13 +- .../Services/ResumableDownloadService.cs | 838 ++++-------------- .../Services/StartupDiagnosticsService.cs | 193 ++++ .../Services/WindowsNativeDialogService.cs | 30 + .../Services/WindowsStartupService.cs | 6 +- .../Views/MainWindow.DesktopPaging.cs | 100 +-- .../Views/MainWindow.Settings.cs | 1 - .../SettingsPages/LauncherSettingsPage.axaml | 6 - .../Views/SettingsWindow.Controls.cs | 1 - .../Views/SettingsWindow.WeatherLauncher.cs | 106 +-- .../installer/ChineseSimplified.isl | 418 +++++++++ .../installer/LanMountainDesktop.iss | 447 +++++++++- .../MainWindow.PluginSettingsControls.cs | 1 + .../MainWindow.PluginSettingsLocalization.cs | 8 +- LanMountainDesktop/plugins/PluginLoader.cs | 6 +- .../plugins/PluginMarketEmbeddedView.cs | 5 +- .../plugins/PluginMarketInstallService.cs | 16 + .../plugins/PluginRuntimeService.cs | 205 ++++- .../plugins/PluginSettingsPage.Host.cs | 266 +++--- .../plugins/PluginSettingsPage.axaml | 48 +- .../SettingsWindow.PluginSettingsControls.cs | 1 + ...ttingsWindow.PluginSettingsLocalization.cs | 6 +- LanMountainDesktop/scripts/package.ps1 | 34 +- 33 files changed, 2171 insertions(+), 1021 deletions(-) create mode 100644 LanMountainDesktop/Services/AppLogger.cs create mode 100644 LanMountainDesktop/Services/FileOperationRetryHelper.cs create mode 100644 LanMountainDesktop/Services/StartupDiagnosticsService.cs create mode 100644 LanMountainDesktop/Services/WindowsNativeDialogService.cs create mode 100644 LanMountainDesktop/installer/ChineseSimplified.isl diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 68084d8..c7e5eb3 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -4,7 +4,6 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Data.Core; using Avalonia.Data.Core.Plugins; using System; -using System.Diagnostics; using System.Linq; using Avalonia.Markup.Xaml; using Avalonia.Platform; @@ -29,6 +28,7 @@ public partial class App : Application public override void Initialize() { + AppLogger.Info("App", "Initializing application resources."); ConfigureWebViewUserDataFolder(); AvaloniaWebViewBuilder.Initialize(default); AvaloniaXamlLoader.Load(this); @@ -36,6 +36,7 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { + AppLogger.Info("App", "Framework initialization completed."); LinuxDesktopEntryInstaller.EnsureInstalled(); InitializePluginRuntime(); AppSettingsService.SettingsSaved += OnAppSettingsSaved; @@ -49,6 +50,7 @@ public partial class App : Application desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown; desktop.Exit += (_, _) => { + AppLogger.Info("App", "Desktop lifetime exit triggered."); AppSettingsService.SettingsSaved -= OnAppSettingsSaved; DisposeTrayIcon(); }; @@ -56,6 +58,7 @@ public partial class App : Application { DataContext = new MainWindowViewModel(), }; + AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}"); } base.OnFrameworkInitializationCompleted(); @@ -104,7 +107,7 @@ public partial class App : Application } catch (Exception ex) { - Debug.WriteLine($"[TraySettings] Failed to open settings window: {ex}"); + AppLogger.Warn("TraySettings", "Failed to open settings window.", ex); } }, DispatcherPriority.Normal); } @@ -159,9 +162,10 @@ public partial class App : Application userDataFolder, EnvironmentVariableTarget.Process); } - catch + catch (Exception ex) { // Keep startup resilient if user profile folders are unavailable. + AppLogger.Warn("WebView2", "Failed to configure WebView2 user data folder.", ex); } } @@ -175,7 +179,7 @@ public partial class App : Application } catch (Exception ex) { - Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}"); + AppLogger.Warn("PluginRuntime", "Failed to initialize plugin runtime.", ex); } } @@ -199,7 +203,7 @@ public partial class App : Application } catch (Exception ex) { - Debug.WriteLine($"[TrayIcon] Failed to initialize tray icon: {ex}"); + AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex); } } @@ -207,19 +211,19 @@ public partial class App : Application { var menu = new NativeMenu(); - var settingsItem = new NativeMenuItem(L("tray.menu.settings", "ÉèÖÃ")); + var settingsItem = new NativeMenuItem(L("tray.menu.settings", "设置")); settingsItem.Click += OnTraySettingsClick; menu.Items.Add(settingsItem); menu.Items.Add(new NativeMenuItemSeparator()); - var restartItem = new NativeMenuItem(L("tray.menu.restart", "ÖØÆôÓ¦ÓÃ")); + var restartItem = new NativeMenuItem(L("tray.menu.restart", "é‡å¯åº”用")); restartItem.Click += OnTrayRestartClick; menu.Items.Add(restartItem); menu.Items.Add(new NativeMenuItemSeparator()); - var exitItem = new NativeMenuItem(L("tray.menu.exit", "Í˳öÓ¦ÓÃ")); + var exitItem = new NativeMenuItem(L("tray.menu.exit", "退出应用")); exitItem.Click += OnTrayExitClick; menu.Items.Add(exitItem); diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 0dda8bf..14098a4 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -40,6 +40,7 @@ + diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 10e9436..b7d6b9e 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -306,15 +306,17 @@ "settings.launcher.hidden_empty": "No hidden items.", "settings.launcher.hidden_type_folder": "Folder", "settings.launcher.hidden_type_shortcut": "Shortcut", - "settings.launcher.restore_button": "Show Again", + "settings.launcher.restore_button": "Unhide", "settings.plugins.title": "Plugins", "settings.plugins.runtime_header": "Plugin Runtime", "settings.plugins.runtime_desc": "Review plugin runtime state and load results.", "settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.", "settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.", "settings.plugins.installed_header": "Installed Plugins", - "settings.plugins.installed_desc": "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.", - "settings.plugins.restart_hint": "Plugin enable state changes take effect after restarting the app.", + "settings.plugins.installed_desc": "Review installed plugins and remove them here.", + "settings.plugins.import_header": "Install From Package", + "settings.plugins.import_desc": "Open a .laapp package and stage it into the local plugin directory.", + "settings.plugins.restart_hint": "Plugin installation and deletion changes take effect after restarting the app.", "settings.plugins.empty": "No plugins found.", "settings.plugins.runtime_unavailable": "Plugin runtime is not available.", "settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.", @@ -329,6 +331,7 @@ "settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.", "settings.plugins.toggle_state_enabled": "enabled", "settings.plugins.toggle_state_disabled": "disabled", + "settings.plugins.toggle_failed_detail_format": "Failed to update plugin '{0}': {1}", "settings.plugins.install_button": "Open .laapp package", "settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.", "settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}", @@ -338,6 +341,12 @@ "settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.", "settings.plugins.install_success_format": "Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.", "settings.plugins.install_failed_format": "Failed to install plugin package: {0}", + "settings.plugins.delete_button": "Delete plugin", + "settings.plugins.delete_success_format": "Plugin '{0}' was staged for deletion. Restart the app to finish removing it.", + "settings.plugins.delete_failed_format": "Failed to delete plugin: {0}", + "settings.plugins.delete_failed_detail_format": "Failed to delete plugin '{0}': {1}", + "settings.plugins.publisher_format": "Publisher: {0}", + "settings.plugins.publisher_unknown": "Unknown publisher", "settings.plugins.source_package": ".laapp package", "settings.plugins.source_manifest": "Loose manifest", "settings.plugins.subtitle_format": "{0} | {1} | {2}", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 9f77f5a..5e04470 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -306,15 +306,17 @@ "settings.launcher.hidden_empty": "暂无éšè—项目。", "settings.launcher.hidden_type_folder": "文件夹", "settings.launcher.hidden_type_shortcut": "å¿«æ·æ–¹å¼", - "settings.launcher.restore_button": "釿–°æ˜¾ç¤º", + "settings.launcher.restore_button": "å–æ¶ˆéšè—", "settings.plugins.title": "æ’ä»¶", "settings.plugins.runtime_header": "æ’ä»¶è¿è¡Œæ—¶", "settings.plugins.runtime_desc": "查看æ’ä»¶è¿è¡Œæ—¶çжæ€ã€åŠ è½½ç»“æžœä¸Žè¯Šæ–­ä¿¡æ¯ã€‚", "settings.plugins.runtime_hint": "这里展示已安装æ’ä»¶çš„å‘现结果ã€åŠ è½½çŠ¶æ€å’Œè¿è¡Œæ—¶è¯Šæ–­ä¿¡æ¯ã€‚", "settings.plugins.runtime_status": "æ’件扫æå®ŒæˆåŽï¼Œè¿è¡Œæ—¶çжæ€ä¼šæ˜¾ç¤ºåœ¨è¿™é‡Œã€‚", "settings.plugins.installed_header": "已安装æ’ä»¶", - "settings.plugins.installed_desc": "在这里å¯ç”¨æˆ–ç¦ç”¨æ’件。æ’件自己的详细设置会作为独立设置页出现。", - "settings.plugins.restart_hint": "æ’ä»¶å¯ç”¨çжæ€å˜æ›´ä¼šåœ¨é‡å¯åº”用åŽç”Ÿæ•ˆã€‚", + "settings.plugins.installed_desc": "在这里查看和删除已安装的æ’件。", + "settings.plugins.import_header": "从安装包导入", + "settings.plugins.import_desc": "打开一个 .laapp æ’件包,并将其暂存到本地æ’件目录。", + "settings.plugins.restart_hint": "æ’ä»¶å®‰è£…å’Œåˆ é™¤å˜æ›´ä¼šåœ¨é‡å¯åº”用åŽç”Ÿæ•ˆã€‚", "settings.plugins.empty": "未找到æ’件。", "settings.plugins.runtime_unavailable": "æ’ä»¶è¿è¡Œæ—¶ä¸å¯ç”¨ã€‚", "settings.plugins.summary_format": "共检测到 {0} 个æ’件;已å¯ç”¨ {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。", @@ -329,6 +331,7 @@ "settings.plugins.toggle_result_format": "æ’件“{0}â€å·²åœ¨ä¸‹æ¬¡å¯åŠ¨æ—¶è®¾ä¸º{1}。é‡å¯åº”用åŽï¼Œè®¾ç½®é¡µå’Œç»„ä»¶å˜æ›´æ‰ä¼šç”Ÿæ•ˆã€‚", "settings.plugins.toggle_state_enabled": "å¯ç”¨", "settings.plugins.toggle_state_disabled": "ç¦ç”¨", + "settings.plugins.toggle_failed_detail_format": "æ›´æ–°æ’件“{0}â€çжæ€å¤±è´¥ï¼š{1}", "settings.plugins.install_button": "打开 .laapp æ’件包", "settings.plugins.install_unavailable": "æ’ä»¶è¿è¡Œæ—¶ä¸å¯ç”¨ï¼Œæš‚时无法安装 .laapp æ’件包。", "settings.plugins.install_hint_format": "打开一个 .laapp æ’件包,安装到:{0}", @@ -338,6 +341,12 @@ "settings.plugins.install_copy_failed": "å¤åˆ¶æ‰€é€‰ .laapp æ’件包失败。", "settings.plugins.install_success_format": "æ’件“{0}â€å®‰è£…完æˆã€‚é‡å¯åº”用åŽï¼Œæ–°å¢žçš„设置页和组件æ‰ä¼šç”Ÿæ•ˆã€‚", "settings.plugins.install_failed_format": "安装æ’件包失败:{0}", + "settings.plugins.delete_button": "删除æ’ä»¶", + "settings.plugins.delete_success_format": "æ’件“{0}â€å·²æš‚存删除。é‡å¯åº”用åŽä¼šå®Œæˆç§»é™¤ã€‚", + "settings.plugins.delete_failed_format": "删除æ’件失败:{0}", + "settings.plugins.delete_failed_detail_format": "删除æ’件“{0}â€å¤±è´¥ï¼š{1}", + "settings.plugins.publisher_format": "å‘布者:{0}", + "settings.plugins.publisher_unknown": "未知å‘布者", "settings.plugins.source_package": ".laapp 包", "settings.plugins.source_manifest": "散装清å•", "settings.plugins.subtitle_format": "{0} | {1} | {2}", diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index abd28dc..e5d8fc5 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.WebView.Desktop; using LanMountainDesktop.Services; using System; +using System.Threading.Tasks; namespace LanMountainDesktop; @@ -11,8 +12,27 @@ sealed class Program // SynchronizationContext-reliant code before AppMain is called: things aren't initialized // yet and stuff might break. [STAThread] - public static void Main(string[] args) => BuildAvaloniaApp(LoadConfiguredRenderMode()) - .StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) + { + AppLogger.Initialize(); + RegisterGlobalExceptionLogging(); + + var diagnostics = StartupDiagnosticsService.Run(args); + StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics); + + try + { + var renderMode = LoadConfiguredRenderMode(); + AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'."); + BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args); + AppLogger.Info("Startup", "Application exited normally."); + } + catch (Exception ex) + { + AppLogger.Critical("Startup", "Application terminated during startup.", ex); + throw; + } + } // Avalonia configuration, don't remove; also used by visual designer. public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default) @@ -44,9 +64,26 @@ sealed class Program { return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode); } - catch + catch (Exception ex) { + AppLogger.Warn("Startup", "Failed to load configured render mode. Falling back to default.", ex); return AppRenderingModeHelper.Default; } } + + private static void RegisterGlobalExceptionLogging() + { + AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => + { + AppLogger.Critical( + "UnhandledException", + $"Unhandled exception. IsTerminating={eventArgs.IsTerminating}", + eventArgs.ExceptionObject as Exception); + }; + + TaskScheduler.UnobservedTaskException += (_, eventArgs) => + { + AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception); + }; + } } diff --git a/LanMountainDesktop/Services/AppLogger.cs b/LanMountainDesktop/Services/AppLogger.cs new file mode 100644 index 0000000..33e61d5 --- /dev/null +++ b/LanMountainDesktop/Services/AppLogger.cs @@ -0,0 +1,169 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace LanMountainDesktop.Services; + +public static class AppLogger +{ + private static readonly object SyncRoot = new(); + private static bool _initialized; + private static string _logDirectory = string.Empty; + private static string _logFilePath = string.Empty; + + public static string LogDirectory + { + get + { + EnsureInitialized(); + return _logDirectory; + } + } + + public static string LogFilePath + { + get + { + EnsureInitialized(); + return _logFilePath; + } + } + + public static void Initialize() + { + lock (SyncRoot) + { + if (_initialized) + { + return; + } + + var preferredDirectory = Path.Combine(AppContext.BaseDirectory, "log"); + var fallbackDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop", + "log"); + + var preferredReady = TryPrepareDirectory(preferredDirectory, out var preferredError); + var fallbackReady = false; + string? fallbackError = null; + + if (preferredReady) + { + _logDirectory = preferredDirectory; + } + else + { + fallbackReady = TryPrepareDirectory(fallbackDirectory, out fallbackError); + _logDirectory = fallbackReady ? fallbackDirectory : preferredDirectory; + } + + _logFilePath = Path.Combine(_logDirectory, $"app-{DateTime.Now:yyyyMMdd}.log"); + _initialized = true; + + WriteCore( + "INFO", + "Logger", + $"Initialized. Directory={_logDirectory}; File={_logFilePath}; PreferredDirectory={preferredDirectory}"); + + if (!preferredReady && !string.IsNullOrWhiteSpace(preferredError)) + { + WriteCore( + "WARN", + "Logger", + $"Failed to use program log directory '{preferredDirectory}'. Falling back to '{_logDirectory}'. Reason: {preferredError}"); + } + + if (!preferredReady && !fallbackReady && !string.IsNullOrWhiteSpace(fallbackError)) + { + Trace.WriteLine( + $"[LanMountainDesktop][Logger][ERROR] Failed to initialize fallback log directory '{fallbackDirectory}': {fallbackError}"); + } + } + } + + public static void Info(string category, string message) + { + Write("INFO", category, message, null); + } + + public static void Warn(string category, string message, Exception? exception = null) + { + Write("WARN", category, message, exception); + } + + public static void Error(string category, string message, Exception? exception = null) + { + Write("ERROR", category, message, exception); + } + + public static void Critical(string category, string message, Exception? exception = null) + { + Write("CRITICAL", category, message, exception); + } + + private static void Write(string level, string category, string message, Exception? exception) + { + EnsureInitialized(); + + var payload = exception is null + ? message + : $"{message}{Environment.NewLine}{exception}"; + WriteCore(level, category, payload); + } + + private static void EnsureInitialized() + { + if (_initialized) + { + return; + } + + Initialize(); + } + + private static void WriteCore(string level, string category, string message) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); + var line = $"[{timestamp}] [{level}] [{category}] {message}"; + + lock (SyncRoot) + { + try + { + var directory = Path.GetDirectoryName(_logFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + File.AppendAllText(_logFilePath, line + Environment.NewLine, Encoding.UTF8); + } + catch (Exception ex) + { + Trace.WriteLine($"[LanMountainDesktop][Logger][ERROR] {ex}"); + } + } + + Trace.WriteLine(line); + } + + private static bool TryPrepareDirectory(string directory, out string? error) + { + try + { + Directory.CreateDirectory(directory); + var probePath = Path.Combine(directory, $".probe-{Guid.NewGuid():N}.tmp"); + File.WriteAllText(probePath, "probe"); + File.Delete(probePath); + error = null; + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } +} diff --git a/LanMountainDesktop/Services/AppSettingsService.cs b/LanMountainDesktop/Services/AppSettingsService.cs index b9c1a65..285740c 100644 --- a/LanMountainDesktop/Services/AppSettingsService.cs +++ b/LanMountainDesktop/Services/AppSettingsService.cs @@ -63,8 +63,9 @@ public sealed class AppSettingsService return loadedSnapshot.Clone(); } } - catch + catch (Exception ex) { + AppLogger.Warn("AppSettings", $"Failed to load settings from '{_settingsPath}'.", ex); return new AppSettingsSnapshot(); } } @@ -95,9 +96,9 @@ public sealed class AppSettingsService SettingsSaved?.Invoke(InstanceId); } - catch + catch (Exception ex) { - // Swallow persistence errors to keep UI interactions uninterrupted. + AppLogger.Warn("AppSettings", $"Failed to save settings to '{_settingsPath}'.", ex); } } @@ -136,8 +137,9 @@ public sealed class AppSettingsService var json = File.ReadAllText(_settingsPath); return JsonSerializer.Deserialize(json, SerializerOptions) ?? new AppSettingsSnapshot(); } - catch + catch (Exception ex) { + AppLogger.Warn("AppSettings", $"Failed to deserialize settings from '{_settingsPath}'.", ex); return new AppSettingsSnapshot(); } } diff --git a/LanMountainDesktop/Services/ComponentSettingsService.cs b/LanMountainDesktop/Services/ComponentSettingsService.cs index 9ba3a3c..a132d80 100644 --- a/LanMountainDesktop/Services/ComponentSettingsService.cs +++ b/LanMountainDesktop/Services/ComponentSettingsService.cs @@ -51,8 +51,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore return document.DefaultSettings.Clone(); } } - catch + catch (Exception ex) { + AppLogger.Warn("ComponentSettings", $"Failed to load component settings from '{_settingsPath}'.", ex); return new ComponentSettingsSnapshot(); } } @@ -76,9 +77,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore PersistDocumentLocked(document); } } - catch + catch (Exception ex) { - // Swallow persistence errors to keep UI interactions uninterrupted. + AppLogger.Warn("ComponentSettings", $"Failed to save default component settings to '{_settingsPath}'.", ex); } } @@ -99,8 +100,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore return document.DefaultSettings.Clone(); } } - catch + catch (Exception ex) { + AppLogger.Warn( + "ComponentSettings", + $"Failed to load component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}", + ex); return new ComponentSettingsSnapshot(); } } @@ -124,9 +129,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore PersistDocumentLocked(document); } } - catch + catch (Exception ex) { - // Swallow persistence errors to keep UI interactions uninterrupted. + AppLogger.Warn( + "ComponentSettings", + $"Failed to save component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}", + ex); } } @@ -151,9 +159,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore } } } - catch + catch (Exception ex) { - // Swallow persistence errors to keep UI interactions uninterrupted. + AppLogger.Warn( + "ComponentSettings", + $"Failed to delete component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}", + ex); } } @@ -174,8 +185,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore return JsonSerializer.Deserialize(settingsElement.GetRawText(), SerializerOptions) ?? new T(); } } - catch + catch (Exception ex) { + AppLogger.Warn( + "ComponentSettings", + $"Failed to load plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}", + ex); return new T(); } } @@ -197,9 +212,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore PersistDocumentLocked(document); } } - catch + catch (Exception ex) { - // Swallow persistence errors to keep UI interactions uninterrupted. + AppLogger.Warn( + "ComponentSettings", + $"Failed to save plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}", + ex); } } @@ -222,9 +240,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore } } } - catch + catch (Exception ex) { - // Swallow persistence errors to keep UI interactions uninterrupted. + AppLogger.Warn( + "ComponentSettings", + $"Failed to delete plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}", + ex); } } @@ -373,8 +394,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore DefaultSettings = NormalizeSnapshot(legacySnapshot) }; } - catch + catch (Exception ex) { + AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex); return new ComponentSettingsDocumentSnapshot(); } } @@ -428,8 +450,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore return true; } - catch + catch (Exception ex) { + AppLogger.Warn("ComponentSettings", $"Failed to migrate legacy component settings from '{_legacyAppSettingsPath}'.", ex); return false; } } diff --git a/LanMountainDesktop/Services/DesktopLayoutSettingsService.cs b/LanMountainDesktop/Services/DesktopLayoutSettingsService.cs index 141d086..37f11ad 100644 --- a/LanMountainDesktop/Services/DesktopLayoutSettingsService.cs +++ b/LanMountainDesktop/Services/DesktopLayoutSettingsService.cs @@ -80,8 +80,9 @@ public sealed class DesktopLayoutSettingsService return normalizedSnapshot.Clone(); } } - catch + catch (Exception ex) { + AppLogger.Warn("DesktopLayout", $"Failed to load desktop layout settings from '{_settingsPath}'.", ex); return new DesktopLayoutSettingsSnapshot(); } } @@ -99,9 +100,9 @@ public sealed class DesktopLayoutSettingsService UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow); } } - catch + catch (Exception ex) { - // Swallow persistence errors to keep UI interactions uninterrupted. + AppLogger.Warn("DesktopLayout", $"Failed to save desktop layout settings to '{_settingsPath}'.", ex); } } @@ -141,8 +142,9 @@ public sealed class DesktopLayoutSettingsService var snapshot = JsonSerializer.Deserialize(json, SerializerOptions); return NormalizeSnapshot(snapshot); } - catch + catch (Exception ex) { + AppLogger.Warn("DesktopLayout", $"Failed to deserialize desktop layout settings from '{_settingsPath}'.", ex); return new DesktopLayoutSettingsSnapshot(); } } @@ -174,8 +176,9 @@ public sealed class DesktopLayoutSettingsService return true; } - catch + catch (Exception ex) { + AppLogger.Warn("DesktopLayout", $"Failed to migrate legacy desktop layout settings from '{_legacyAppSettingsPath}'.", ex); return false; } } diff --git a/LanMountainDesktop/Services/FileOperationRetryHelper.cs b/LanMountainDesktop/Services/FileOperationRetryHelper.cs new file mode 100644 index 0000000..6296fe2 --- /dev/null +++ b/LanMountainDesktop/Services/FileOperationRetryHelper.cs @@ -0,0 +1,98 @@ +using System; +using System.IO; +using System.Threading; + +namespace LanMountainDesktop.Services; + +internal static class FileOperationRetryHelper +{ + private static readonly TimeSpan[] RetryDelays = + [ + TimeSpan.FromMilliseconds(120), + TimeSpan.FromMilliseconds(250), + TimeSpan.FromMilliseconds(500) + ]; + + public static void CopyWithRetry(string sourceFilePath, string destinationFilePath, bool overwrite, string category) + { + Retry( + () => File.Copy(sourceFilePath, destinationFilePath, overwrite), + category, + $"Copy '{sourceFilePath}' -> '{destinationFilePath}'"); + } + + public static void MoveWithOverwriteRetry(string sourceFilePath, string destinationFilePath, string category) + { + Retry( + () => File.Move(sourceFilePath, destinationFilePath, overwrite: true), + category, + $"Move '{sourceFilePath}' -> '{destinationFilePath}'"); + } + + public static void DeleteFileWithRetry(string filePath, string category) + { + Retry( + () => + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + }, + category, + $"Delete file '{filePath}'"); + } + + public static void DeleteDirectoryWithRetry(string directoryPath, bool recursive, string category) + { + Retry( + () => + { + if (Directory.Exists(directoryPath)) + { + Directory.Delete(directoryPath, recursive); + } + }, + category, + $"Delete directory '{directoryPath}'"); + } + + private static void Retry(Action action, string category, string operationDescription) + { + Exception? lastException = null; + + for (var attempt = 0; attempt <= RetryDelays.Length; attempt++) + { + try + { + action(); + return; + } + catch (Exception ex) when (IsRetriable(ex)) + { + lastException = ex; + if (attempt >= RetryDelays.Length) + { + break; + } + + var delay = RetryDelays[attempt]; + AppLogger.Warn( + category, + $"{operationDescription} failed on attempt {attempt + 1}. Retrying after {delay.TotalMilliseconds:0} ms.", + ex); + Thread.Sleep(delay); + } + } + + if (lastException is not null) + { + throw lastException; + } + } + + private static bool IsRetriable(Exception exception) + { + return exception is IOException or UnauthorizedAccessException; + } +} diff --git a/LanMountainDesktop/Services/LauncherSettingsService.cs b/LanMountainDesktop/Services/LauncherSettingsService.cs index 006e23d..12b39b6 100644 --- a/LanMountainDesktop/Services/LauncherSettingsService.cs +++ b/LanMountainDesktop/Services/LauncherSettingsService.cs @@ -85,8 +85,9 @@ public sealed class LauncherSettingsService return normalizedSnapshot.Clone(); } } - catch + catch (Exception ex) { + AppLogger.Warn("LauncherSettings", $"Failed to load launcher settings from '{_settingsPath}'.", ex); return new LauncherSettingsSnapshot(); } } @@ -106,9 +107,9 @@ public sealed class LauncherSettingsService SettingsSaved?.Invoke(InstanceId); } - catch + catch (Exception ex) { - // Swallow persistence errors to keep UI interactions uninterrupted. + AppLogger.Warn("LauncherSettings", $"Failed to save launcher settings to '{_settingsPath}'.", ex); } } @@ -148,8 +149,9 @@ public sealed class LauncherSettingsService var snapshot = JsonSerializer.Deserialize(json, SerializerOptions); return NormalizeSnapshot(snapshot); } - catch + catch (Exception ex) { + AppLogger.Warn("LauncherSettings", $"Failed to deserialize launcher settings from '{_settingsPath}'.", ex); return new LauncherSettingsSnapshot(); } } @@ -180,8 +182,9 @@ public sealed class LauncherSettingsService return true; } - catch + catch (Exception ex) { + AppLogger.Warn("LauncherSettings", $"Failed to migrate legacy launcher settings from '{_legacyAppSettingsPath}'.", ex); return false; } } diff --git a/LanMountainDesktop/Services/ResumableDownloadService.cs b/LanMountainDesktop/Services/ResumableDownloadService.cs index 4d3c523..c267d84 100644 --- a/LanMountainDesktop/Services/ResumableDownloadService.cs +++ b/LanMountainDesktop/Services/ResumableDownloadService.cs @@ -1,14 +1,9 @@ using System; -using System.Buffers; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Downloader; namespace LanMountainDesktop.Services; @@ -34,16 +29,11 @@ public sealed record DownloadResult( public sealed class ResumableDownloadService { - private static readonly JsonSerializerOptions MetadataSerializerOptions = new() - { - WriteIndented = false - }; + private static readonly ConcurrentDictionary DestinationGates = + new(StringComparer.OrdinalIgnoreCase); - private readonly HttpClient _httpClient; - - public ResumableDownloadService(HttpClient httpClient) + public ResumableDownloadService(System.Net.Http.HttpClient httpClient) { - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); } public async Task DownloadAsync( @@ -57,13 +47,19 @@ public sealed class ResumableDownloadService ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath); var normalizedOptions = NormalizeOptions(options); + var fullDestinationPath = Path.GetFullPath(destinationFilePath); + var destinationGate = DestinationGates.GetOrAdd( + fullDestinationPath, + static _ => new SemaphoreSlim(1, 1)); + + await destinationGate.WaitAsync(cancellationToken); try { if (File.Exists(source)) { return await CopyLocalFileAsync( source, - destinationFilePath, + fullDestinationPath, normalizedOptions, progress, cancellationToken); @@ -77,7 +73,7 @@ public sealed class ResumableDownloadService return await DownloadRemoteFileAsync( sourceUri, - destinationFilePath, + fullDestinationPath, normalizedOptions, progress, cancellationToken); @@ -88,8 +84,16 @@ public sealed class ResumableDownloadService } catch (Exception ex) { + AppLogger.Warn( + "Downloader", + $"Download failed. Source='{source}'; Destination='{fullDestinationPath}'.", + ex); return new DownloadResult(false, null, ex.Message, false, false); } + finally + { + destinationGate.Release(); + } } private async Task CopyLocalFileAsync( @@ -104,13 +108,12 @@ public sealed class ResumableDownloadService var totalBytes = new FileInfo(fullSourcePath).Length; var tempFilePath = BuildTempFilePath(fullDestinationPath); - var metadataFilePath = BuildMetadataFilePath(fullDestinationPath); PrepareDestination(fullDestinationPath); if (CanReuseCompletedDestination(fullDestinationPath, totalBytes)) { progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, false, false)); - CleanupPartialArtifacts(tempFilePath, metadataFilePath); + CleanupLocalPartialArtifacts(tempFilePath); return new DownloadResult(true, fullDestinationPath, null, false, false); } @@ -120,7 +123,7 @@ public sealed class ResumableDownloadService existingBytes = new FileInfo(tempFilePath).Length; if (existingBytes > totalBytes) { - ResetPartialArtifacts(tempFilePath, metadataFilePath); + CleanupLocalPartialArtifacts(tempFilePath); existingBytes = 0; } } @@ -138,7 +141,7 @@ public sealed class ResumableDownloadService if (existingBytes >= totalBytes) { - CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath); + CompleteLocalCopy(tempFilePath, fullDestinationPath); progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, existingBytes > 0, false)); return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false); } @@ -169,13 +172,10 @@ public sealed class ResumableDownloadService destinationStream, existingBytes, totalBytes, - isResuming: existingBytes > 0, - isParallel: false, - options.BufferSize, progress, cancellationToken); - CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath); + CompleteLocalCopy(tempFilePath, fullDestinationPath); return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false); } @@ -186,501 +186,144 @@ public sealed class ResumableDownloadService IProgress? progress, CancellationToken cancellationToken) { - var fullDestinationPath = Path.GetFullPath(destinationFilePath); - var tempFilePath = BuildTempFilePath(fullDestinationPath); - var metadataFilePath = BuildMetadataFilePath(fullDestinationPath); - PrepareDestination(fullDestinationPath); + PrepareDestination(destinationFilePath); - var probe = await ProbeRemoteFileAsync(sourceUri, cancellationToken); - var totalBytes = probe.TotalBytes ?? options.ExpectedSizeBytes; - if (CanReuseCompletedDestination(fullDestinationPath, totalBytes)) + if (CanReuseCompletedDestination(destinationFilePath, options.ExpectedSizeBytes)) { - progress?.Report(new DownloadProgressInfo( - totalBytes ?? new FileInfo(fullDestinationPath).Length, - totalBytes, - 1d, - false, - false)); - CleanupPartialArtifacts(tempFilePath, metadataFilePath); - return new DownloadResult(true, fullDestinationPath, null, false, false); + var existingLength = new FileInfo(destinationFilePath).Length; + progress?.Report(new DownloadProgressInfo(existingLength, options.ExpectedSizeBytes, 1d, false, false)); + CleanupDownloaderArtifacts(destinationFilePath); + return new DownloadResult(true, destinationFilePath, null, false, false); } - var canUseParallel = probe.SupportsRanges && - totalBytes is > 0 && - totalBytes.Value >= options.ParallelThresholdBytes && - options.MaxParallelSegments > 1; + var usedResume = HasDownloaderResumeArtifacts(destinationFilePath); + var usedParallelDownload = ShouldUseParallelDownload(options); + var configuration = CreateConfiguration(options, usedParallelDownload); + using var downloader = new DownloadService(configuration); - try + downloader.DownloadProgressChanged += (_, args) => { - var result = canUseParallel - ? await DownloadRemoteInParallelAsync( - sourceUri, - fullDestinationPath, - tempFilePath, - metadataFilePath, - totalBytes!.Value, - options, - progress, - cancellationToken) - : await DownloadRemoteSequentiallyAsync( - sourceUri, - fullDestinationPath, - tempFilePath, - metadataFilePath, - totalBytes, - probe.SupportsRanges, - options, - progress, - cancellationToken); + progress?.Report(MapProgress(args, options.ExpectedSizeBytes, usedResume, usedParallelDownload)); + }; - return result; - } - catch (RangeRequestNotSupportedException) + using var cancellationRegistration = cancellationToken.Register(() => { - ResetPartialArtifacts(tempFilePath, metadataFilePath); - return await DownloadRemoteSequentiallyAsync( - sourceUri, - fullDestinationPath, - tempFilePath, - metadataFilePath, - totalBytes, - allowResume: false, - options, - progress, - cancellationToken); - } - } - - private async Task DownloadRemoteSequentiallyAsync( - Uri sourceUri, - string destinationFilePath, - string tempFilePath, - string metadataFilePath, - long? totalBytes, - bool allowResume, - DownloadOptions options, - IProgress? progress, - CancellationToken cancellationToken) - { - long existingBytes = 0; - if (File.Exists(tempFilePath)) - { - existingBytes = new FileInfo(tempFilePath).Length; - if (totalBytes is > 0 && existingBytes > totalBytes.Value) + try { - ResetPartialArtifacts(tempFilePath, metadataFilePath); - existingBytes = 0; + downloader.CancelAsync(); } - } - - if (!allowResume && existingBytes > 0) - { - ResetPartialArtifacts(tempFilePath, metadataFilePath); - existingBytes = 0; - } - - if (totalBytes is > 0 && existingBytes >= totalBytes.Value) - { - CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); - progress?.Report(new DownloadProgressInfo(totalBytes.Value, totalBytes, 1d, existingBytes > 0, false)); - return new DownloadResult(true, destinationFilePath, null, existingBytes > 0, false); - } - - using var request = new HttpRequestMessage(HttpMethod.Get, sourceUri); - if (allowResume && existingBytes > 0) - { - request.Headers.Range = new RangeHeaderValue(existingBytes, null); - } - - using var response = await _httpClient.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - - if (allowResume && existingBytes > 0) - { - if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && totalBytes is > 0 && existingBytes == totalBytes) + catch (Exception ex) { - CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); - progress?.Report(new DownloadProgressInfo(totalBytes.Value, totalBytes, 1d, true, false)); - return new DownloadResult(true, destinationFilePath, null, true, false); + AppLogger.Warn( + "Downloader", + $"Failed to cancel Downloader request for '{destinationFilePath}'.", + ex); } + }); - if (response.StatusCode != HttpStatusCode.PartialContent) - { - throw new RangeRequestNotSupportedException("The server did not honor the resume range request."); - } - } + AppLogger.Info( + "Downloader", + $"Starting remote download. Source='{sourceUri}'; Destination='{destinationFilePath}'; Parallel={usedParallelDownload}; ChunkCount={configuration.ChunkCount}; Resume={usedResume}."); - response.EnsureSuccessStatusCode(); + await downloader.DownloadFileTaskAsync(sourceUri.AbsoluteUri, destinationFilePath); - await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken); - await using var destinationStream = new FileStream( - tempFilePath, - existingBytes > 0 ? FileMode.Open : FileMode.Create, - FileAccess.Write, - FileShare.Read, - options.BufferSize, - FileOptions.Asynchronous | FileOptions.SequentialScan); - - if (existingBytes > 0) + if (!File.Exists(destinationFilePath)) { - destinationStream.Seek(existingBytes, SeekOrigin.Begin); + throw new FileNotFoundException( + $"Downloader completed without producing '{destinationFilePath}'.", + destinationFilePath); } - var effectiveTotalBytes = totalBytes; - if (effectiveTotalBytes is null && response.Content.Headers.ContentLength is > 0) - { - effectiveTotalBytes = existingBytes + response.Content.Headers.ContentLength.Value; - } + var finalLength = new FileInfo(destinationFilePath).Length; + progress?.Report(new DownloadProgressInfo( + finalLength, + options.ExpectedSizeBytes ?? finalLength, + 1d, + usedResume, + usedParallelDownload)); - await CopyStreamAsync( - sourceStream, - destinationStream, - existingBytes, - effectiveTotalBytes, - isResuming: existingBytes > 0, - isParallel: false, - options.BufferSize, - progress, - cancellationToken); + AppLogger.Info( + "Downloader", + $"Remote download completed. Source='{sourceUri}'; Destination='{destinationFilePath}'; Size={finalLength}; Parallel={usedParallelDownload}; Resume={usedResume}."); - CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); - return new DownloadResult(true, destinationFilePath, null, existingBytes > 0, false); - } - - private async Task DownloadRemoteInParallelAsync( - Uri sourceUri, - string destinationFilePath, - string tempFilePath, - string metadataFilePath, - long totalBytes, - DownloadOptions options, - IProgress? progress, - CancellationToken cancellationToken) - { - var requestedSegments = Math.Min(options.MaxParallelSegments, CalculateRecommendedSegments(totalBytes)); - var metadata = await LoadOrCreateMetadataAsync( - sourceUri, - tempFilePath, - metadataFilePath, - totalBytes, - requestedSegments, - cancellationToken); - - await using (var tempStream = new FileStream( - tempFilePath, - FileMode.OpenOrCreate, - FileAccess.Write, - FileShare.ReadWrite, - options.BufferSize, - FileOptions.Asynchronous | FileOptions.RandomAccess)) - { - if (tempStream.Length != totalBytes) - { - tempStream.SetLength(totalBytes); - } - } - - var initialDownloadedBytes = metadata.Segments.Sum(segment => segment.CompletedBytes); - ReportProgress(progress, initialDownloadedBytes, totalBytes, initialDownloadedBytes > 0, true); - - if (initialDownloadedBytes >= totalBytes) - { - CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); - return new DownloadResult(true, destinationFilePath, null, initialDownloadedBytes > 0, true); - } - - long downloadedBytes = initialDownloadedBytes; - var metadataWriter = new MetadataWriter(metadataFilePath, metadata); - - try - { - var tasks = metadata.Segments - .Where(segment => segment.CompletedBytes < segment.Length) - .Select(segment => DownloadSegmentAsync( - sourceUri, - tempFilePath, - segment, - options.BufferSize, - delta => - { - var currentDownloaded = Interlocked.Add(ref downloadedBytes, delta); - ReportProgress(progress, currentDownloaded, totalBytes, initialDownloadedBytes > 0, true); - }, - metadataWriter, - cancellationToken)) - .ToArray(); - - await Task.WhenAll(tasks); - await metadataWriter.FlushAsync(cancellationToken); - } - catch - { - await metadataWriter.FlushAsync(cancellationToken); - throw; - } - - CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); - ReportProgress(progress, totalBytes, totalBytes, initialDownloadedBytes > 0, true); - return new DownloadResult(true, destinationFilePath, null, initialDownloadedBytes > 0, true); - } - - private async Task DownloadSegmentAsync( - Uri sourceUri, - string tempFilePath, - DownloadSegmentState segment, - int bufferSize, - Action reportDownloadedBytes, - MetadataWriter metadataWriter, - CancellationToken cancellationToken) - { - var rangeStart = segment.Start + segment.CompletedBytes; - if (rangeStart > segment.EndInclusive) - { - return; - } - - using var request = new HttpRequestMessage(HttpMethod.Get, sourceUri); - request.Headers.Range = new RangeHeaderValue(rangeStart, segment.EndInclusive); - - using var response = await _httpClient.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - - if (response.StatusCode != HttpStatusCode.PartialContent) - { - throw new RangeRequestNotSupportedException( - $"The server returned HTTP {(int)response.StatusCode} for range {rangeStart}-{segment.EndInclusive}."); - } - - response.EnsureSuccessStatusCode(); - - var contentRange = response.Content.Headers.ContentRange; - if (contentRange?.From != rangeStart || contentRange.To != segment.EndInclusive) - { - throw new RangeRequestNotSupportedException("The server returned an unexpected content range."); - } - - await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken); - await using var destinationStream = new FileStream( - tempFilePath, - FileMode.Open, - FileAccess.Write, - FileShare.ReadWrite, - bufferSize, - FileOptions.Asynchronous | FileOptions.RandomAccess); - destinationStream.Seek(rangeStart, SeekOrigin.Begin); - - var buffer = ArrayPool.Shared.Rent(bufferSize); - try - { - while (segment.CompletedBytes < segment.Length) - { - var remainingBytes = segment.Length - segment.CompletedBytes; - var readSize = (int)Math.Min(buffer.Length, remainingBytes); - var read = await sourceStream.ReadAsync(buffer.AsMemory(0, readSize), cancellationToken); - if (read <= 0) - { - throw new EndOfStreamException( - $"Unexpected end of stream while downloading range {segment.Start}-{segment.EndInclusive}."); - } - - await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); - segment.CompletedBytes += read; - reportDownloadedBytes(read); - metadataWriter.MarkDirty(); - } - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - private async Task ProbeRemoteFileAsync(Uri sourceUri, CancellationToken cancellationToken) - { - long? totalBytes = null; - var supportsRanges = false; - - try - { - using var headRequest = new HttpRequestMessage(HttpMethod.Head, sourceUri); - using var headResponse = await _httpClient.SendAsync( - headRequest, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - - if (headResponse.IsSuccessStatusCode) - { - totalBytes = headResponse.Content.Headers.ContentLength; - supportsRanges = headResponse.Headers.AcceptRanges.Any( - value => string.Equals(value, "bytes", StringComparison.OrdinalIgnoreCase)); - } - } - catch - { - // Fall back to a small range probe when HEAD is unsupported or blocked. - } - - if (supportsRanges && totalBytes is > 0) - { - return new RemoteProbeResult(totalBytes, true); - } - - using var rangeRequest = new HttpRequestMessage(HttpMethod.Get, sourceUri); - rangeRequest.Headers.Range = new RangeHeaderValue(0, 0); - - using var rangeResponse = await _httpClient.SendAsync( - rangeRequest, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - - if (rangeResponse.StatusCode == HttpStatusCode.PartialContent) - { - totalBytes = rangeResponse.Content.Headers.ContentRange?.Length ?? totalBytes; - return new RemoteProbeResult(totalBytes, true); - } - - rangeResponse.EnsureSuccessStatusCode(); - totalBytes ??= rangeResponse.Content.Headers.ContentLength; - return new RemoteProbeResult(totalBytes, false); + return new DownloadResult(true, destinationFilePath, null, usedResume, usedParallelDownload); } private static async Task CopyStreamAsync( Stream sourceStream, Stream destinationStream, long initialDownloadedBytes, - long? totalBytes, - bool isResuming, - bool isParallel, - int bufferSize, - IProgress? progress, - CancellationToken cancellationToken) - { - var buffer = ArrayPool.Shared.Rent(bufferSize); - var downloadedBytes = initialDownloadedBytes; - try - { - ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel); - while (true) - { - var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); - if (read <= 0) - { - break; - } - - await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); - downloadedBytes += read; - ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel); - } - - await destinationStream.FlushAsync(cancellationToken); - ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - private static void ReportProgress( - IProgress? progress, - long downloadedBytes, - long? totalBytes, - bool isResuming, - bool isParallel) - { - if (progress is null) - { - return; - } - - double normalizedProgress; - if (totalBytes is > 0) - { - normalizedProgress = Math.Clamp(downloadedBytes / (double)totalBytes.Value, 0d, 1d); - } - else - { - normalizedProgress = 0d; - } - - progress.Report(new DownloadProgressInfo( - downloadedBytes, - totalBytes, - normalizedProgress, - isResuming, - isParallel)); - } - - private static async Task LoadOrCreateMetadataAsync( - Uri sourceUri, - string tempFilePath, - string metadataFilePath, long totalBytes, - int segmentCount, + IProgress? progress, CancellationToken cancellationToken) { - if (File.Exists(metadataFilePath)) + var buffer = new byte[128 * 1024]; + var downloadedBytes = initialDownloadedBytes; + progress?.Report(new DownloadProgressInfo(downloadedBytes, totalBytes, downloadedBytes / (double)totalBytes, initialDownloadedBytes > 0, false)); + + while (true) { - try + var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + if (read <= 0) { - var json = await File.ReadAllTextAsync(metadataFilePath, cancellationToken); - var metadata = JsonSerializer.Deserialize(json); - if (metadata is not null) - { - var normalizedMetadata = metadata.ToRuntime(); - if (string.Equals(normalizedMetadata.Source, sourceUri.ToString(), StringComparison.OrdinalIgnoreCase) && - normalizedMetadata.TotalBytes == totalBytes && - normalizedMetadata.Segments.Count > 0) - { - return normalizedMetadata.Normalize(); - } - } - } - catch - { - // Reset invalid metadata below. + break; } + + await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + downloadedBytes += read; + progress?.Report(new DownloadProgressInfo( + downloadedBytes, + totalBytes, + Math.Clamp(downloadedBytes / (double)totalBytes, 0d, 1d), + initialDownloadedBytes > 0, + false)); } - ResetPartialArtifacts(tempFilePath, metadataFilePath); - var createdMetadata = DownloadMetadata.Create(sourceUri.ToString(), totalBytes, segmentCount); - var serialized = JsonSerializer.Serialize(createdMetadata.ToSerializable(), MetadataSerializerOptions); - await File.WriteAllTextAsync(metadataFilePath, serialized, cancellationToken); - return createdMetadata; + await destinationStream.FlushAsync(cancellationToken); } - private static DownloadOptions NormalizeOptions(DownloadOptions? options) + private static DownloadConfiguration CreateConfiguration(DownloadOptions options, bool useParallelDownload) { - var normalized = options ?? new DownloadOptions(); - var maxParallelSegments = Math.Clamp(normalized.MaxParallelSegments, 1, 8); - var parallelThresholdBytes = Math.Max(1_048_576, normalized.ParallelThresholdBytes); - var bufferSize = Math.Max(16 * 1024, normalized.BufferSize); - return normalized with + return new DownloadConfiguration { - MaxParallelSegments = maxParallelSegments, - ParallelThresholdBytes = parallelThresholdBytes, - BufferSize = bufferSize + BufferBlockSize = options.BufferSize, + ChunkCount = useParallelDownload ? options.MaxParallelSegments : 1, + ParallelCount = useParallelDownload ? options.MaxParallelSegments : 1, + ParallelDownload = useParallelDownload, + MinimumSizeOfChunking = options.ParallelThresholdBytes, + MaxTryAgainOnFailure = 3, + ResumeDownloadIfCan = true, + ClearPackageOnCompletionWithFailure = false, + FileExistPolicy = FileExistPolicy.Delete, + DownloadFileExtension = ".part" }; } - private static int CalculateRecommendedSegments(long totalBytes) + private static DownloadProgressInfo MapProgress( + DownloadProgressChangedEventArgs args, + long? expectedSizeBytes, + bool isResuming, + bool isParallel) { - if (totalBytes < 16 * 1024 * 1024) + var totalBytes = args.TotalBytesToReceive > 0 + ? args.TotalBytesToReceive + : expectedSizeBytes; + var downloadedBytes = Math.Max(0L, args.ReceivedBytesSize); + var normalizedProgress = args.ProgressPercentage > 1d + ? args.ProgressPercentage / 100d + : args.ProgressPercentage; + + if (totalBytes is > 0 && normalizedProgress <= 0d) { - return 2; + normalizedProgress = downloadedBytes / (double)totalBytes.Value; } - if (totalBytes < 64 * 1024 * 1024) - { - return 4; - } - - return 6; + return new DownloadProgressInfo( + downloadedBytes, + totalBytes, + Math.Clamp(normalizedProgress, 0d, 1d), + isResuming, + isParallel); } private static bool CanReuseCompletedDestination(string destinationFilePath, long? expectedSizeBytes) @@ -707,234 +350,63 @@ public sealed class ResumableDownloadService } } - private static void CompleteDownload(string tempFilePath, string destinationFilePath, string metadataFilePath) + private static void CleanupLocalPartialArtifacts(string tempFilePath) + { + if (File.Exists(tempFilePath)) + { + FileOperationRetryHelper.DeleteFileWithRetry(tempFilePath, "Downloader"); + } + } + + private static void CompleteLocalCopy(string tempFilePath, string destinationFilePath) { if (!File.Exists(tempFilePath)) { return; } - File.Move(tempFilePath, destinationFilePath, overwrite: true); - if (File.Exists(metadataFilePath)) - { - File.Delete(metadataFilePath); - } + FileOperationRetryHelper.MoveWithOverwriteRetry(tempFilePath, destinationFilePath, "Downloader"); } - private static void CleanupPartialArtifacts(string tempFilePath, string metadataFilePath) + private static void CleanupDownloaderArtifacts(string destinationFilePath) { - if (File.Exists(tempFilePath)) + var transientFilePath = BuildTempFilePath(destinationFilePath); + var metadataFilePath = BuildPackageFilePath(destinationFilePath); + + if (File.Exists(transientFilePath)) { - File.Delete(tempFilePath); + FileOperationRetryHelper.DeleteFileWithRetry(transientFilePath, "Downloader"); } if (File.Exists(metadataFilePath)) { - File.Delete(metadataFilePath); + FileOperationRetryHelper.DeleteFileWithRetry(metadataFilePath, "Downloader"); } } - private static void ResetPartialArtifacts(string tempFilePath, string metadataFilePath) + private static bool HasDownloaderResumeArtifacts(string destinationFilePath) { - CleanupPartialArtifacts(tempFilePath, metadataFilePath); + return File.Exists(BuildTempFilePath(destinationFilePath)) || + File.Exists(BuildPackageFilePath(destinationFilePath)); + } + + private static bool ShouldUseParallelDownload(DownloadOptions options) + { + return options.MaxParallelSegments > 1; + } + + private static DownloadOptions NormalizeOptions(DownloadOptions? options) + { + var normalized = options ?? new DownloadOptions(); + return normalized with + { + MaxParallelSegments = Math.Clamp(normalized.MaxParallelSegments, 1, 8), + ParallelThresholdBytes = Math.Max(1_048_576, normalized.ParallelThresholdBytes), + BufferSize = Math.Max(16 * 1024, normalized.BufferSize) + }; } private static string BuildTempFilePath(string destinationFilePath) => destinationFilePath + ".part"; - private static string BuildMetadataFilePath(string destinationFilePath) => destinationFilePath + ".part.json"; - - private sealed record RemoteProbeResult(long? TotalBytes, bool SupportsRanges); - - private sealed class RangeRequestNotSupportedException : InvalidOperationException - { - public RangeRequestNotSupportedException(string message) - : base(message) - { - } - } - - private sealed class MetadataWriter - { - private readonly string _metadataFilePath; - private readonly DownloadMetadata _metadata; - private readonly SemaphoreSlim _writeGate = new(1, 1); - private long _lastPersistedTickCount; - private int _dirty; - - public MetadataWriter(string metadataFilePath, DownloadMetadata metadata) - { - _metadataFilePath = metadataFilePath; - _metadata = metadata; - _lastPersistedTickCount = Environment.TickCount64; - } - - public void MarkDirty() - { - Interlocked.Exchange(ref _dirty, 1); - var now = Environment.TickCount64; - if (now - Interlocked.Read(ref _lastPersistedTickCount) < 750) - { - return; - } - - _ = Task.Run(async () => - { - try - { - await FlushAsync(CancellationToken.None); - } - catch - { - // The final flush still runs on completion/cancellation. - } - }); - } - - public async Task FlushAsync(CancellationToken cancellationToken) - { - if (Interlocked.Exchange(ref _dirty, 0) == 0 && File.Exists(_metadataFilePath)) - { - return; - } - - await _writeGate.WaitAsync(cancellationToken); - try - { - var json = JsonSerializer.Serialize(_metadata.ToSerializable(), MetadataSerializerOptions); - await File.WriteAllTextAsync(_metadataFilePath, json, cancellationToken); - Interlocked.Exchange(ref _lastPersistedTickCount, Environment.TickCount64); - } - finally - { - _writeGate.Release(); - } - } - } - - private sealed class DownloadMetadata - { - public string Source { get; init; } = string.Empty; - - public long TotalBytes { get; init; } - - public List Segments { get; init; } = []; - - public static DownloadMetadata Create(string source, long totalBytes, int segmentCount) - { - var segments = SplitIntoSegments(totalBytes, segmentCount) - .Select(range => new DownloadSegmentState(range.Start, range.EndInclusive, 0)) - .ToList(); - - return new DownloadMetadata - { - Source = source, - TotalBytes = totalBytes, - Segments = segments - }; - } - - public DownloadMetadata Normalize() - { - foreach (var segment in Segments) - { - segment.CompletedBytes = Math.Clamp(segment.CompletedBytes, 0, segment.Length); - } - - return this; - } - - public SerializableDownloadMetadata ToSerializable() - { - return new SerializableDownloadMetadata - { - Source = Source, - TotalBytes = TotalBytes, - Segments = Segments - .Select(segment => new SerializableDownloadSegment - { - Start = segment.Start, - EndInclusive = segment.EndInclusive, - CompletedBytes = segment.CompletedBytes - }) - .ToList() - }; - } - } - - private sealed class DownloadSegmentState - { - public DownloadSegmentState(long start, long endInclusive, long completedBytes) - { - Start = start; - EndInclusive = endInclusive; - CompletedBytes = completedBytes; - } - - public long Start { get; } - - public long EndInclusive { get; } - - public long Length => EndInclusive - Start + 1; - - public long CompletedBytes { get; set; } - } - - private sealed class SerializableDownloadMetadata - { - public string Source { get; init; } = string.Empty; - - public long TotalBytes { get; init; } - - public List Segments { get; init; } = []; - - public DownloadMetadata ToRuntime() - { - return new DownloadMetadata - { - Source = Source, - TotalBytes = TotalBytes, - Segments = Segments - .Select(segment => new DownloadSegmentState( - segment.Start, - segment.EndInclusive, - segment.CompletedBytes)) - .ToList() - }; - } - } - - private sealed class SerializableDownloadSegment - { - public long Start { get; init; } - - public long EndInclusive { get; init; } - - public long CompletedBytes { get; init; } - } - - private static IEnumerable<(long Start, long EndInclusive)> SplitIntoSegments(long totalBytes, int segmentCount) - { - if (totalBytes <= 0) - { - yield break; - } - - var normalizedSegmentCount = Math.Max(1, segmentCount); - var segmentSize = totalBytes / normalizedSegmentCount; - var remainder = totalBytes % normalizedSegmentCount; - long start = 0; - - for (var index = 0; index < normalizedSegmentCount; index++) - { - var currentSegmentSize = segmentSize + (index < remainder ? 1 : 0); - if (currentSegmentSize <= 0) - { - continue; - } - - var endInclusive = start + currentSegmentSize - 1; - yield return (start, endInclusive); - start = endInclusive + 1; - } - } + private static string BuildPackageFilePath(string destinationFilePath) => destinationFilePath + ".download"; } diff --git a/LanMountainDesktop/Services/StartupDiagnosticsService.cs b/LanMountainDesktop/Services/StartupDiagnosticsService.cs new file mode 100644 index 0000000..9f7cb89 --- /dev/null +++ b/LanMountainDesktop/Services/StartupDiagnosticsService.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace LanMountainDesktop.Services; + +public sealed class StartupDiagnosticsResult +{ + public string ExecutablePath { get; init; } = string.Empty; + + public string BaseDirectory { get; init; } = string.Empty; + + public string ExecutableName { get; init; } = string.Empty; + + public bool IsLegacyExecutableLaunch { get; init; } + + public IReadOnlyList FoundLegacyArtifacts { get; init; } = Array.Empty(); + + public IReadOnlyList DeletedLegacyArtifacts { get; init; } = Array.Empty(); + + public IReadOnlyList FailedLegacyArtifacts { get; init; } = Array.Empty(); +} + +public static class StartupDiagnosticsService +{ + private const string CurrentExecutableName = "LanMountainDesktop.exe"; + + private static readonly string[] LegacyArtifactNames = + [ + "LanMontainDesktop.exe", + "LanMontainDesktop.dll", + "LanMontainDesktop.deps.json", + "LanMontainDesktop.runtimeconfig.json", + "LanMontainDesktop.pdb", + "LanMontainDesktop.exe.WebView2" + ]; + + public static StartupDiagnosticsResult Run(string[] args) + { + var executablePath = ResolveExecutablePath(); + var baseDirectory = AppContext.BaseDirectory; + var executableName = Path.GetFileName(executablePath); + var assemblyVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown"; + var fileVersion = string.Empty; + + try + { + if (!string.IsNullOrWhiteSpace(executablePath) && File.Exists(executablePath)) + { + fileVersion = FileVersionInfo.GetVersionInfo(executablePath).FileVersion ?? string.Empty; + } + } + catch + { + // Keep diagnostics best-effort. + } + + AppLogger.Info( + "Startup", + $"Application starting. ExecutablePath={executablePath}; BaseDirectory={baseDirectory}; ExecutableName={executableName}; AssemblyVersion={assemblyVersion}; FileVersion={fileVersion}; Args=[{string.Join(", ", args)}]"); + + var foundLegacyArtifacts = LegacyArtifactNames + .Select(name => Path.Combine(baseDirectory, name)) + .Where(path => File.Exists(path) || Directory.Exists(path)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var deletedLegacyArtifacts = new List(); + var failedLegacyArtifacts = new List(); + foreach (var legacyArtifact in foundLegacyArtifacts) + { + if (string.Equals(legacyArtifact, executablePath, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (TryDeleteLegacyArtifact(legacyArtifact, out var error)) + { + deletedLegacyArtifacts.Add(legacyArtifact); + } + else if (!string.IsNullOrWhiteSpace(error)) + { + failedLegacyArtifacts.Add($"{legacyArtifact} ({error})"); + } + } + + if (foundLegacyArtifacts.Count > 0) + { + AppLogger.Warn( + "StartupDiagnostics", + $"Found legacy artifacts: {string.Join("; ", foundLegacyArtifacts)}"); + } + + if (deletedLegacyArtifacts.Count > 0) + { + AppLogger.Info( + "StartupDiagnostics", + $"Deleted legacy artifacts: {string.Join("; ", deletedLegacyArtifacts)}"); + } + + if (failedLegacyArtifacts.Count > 0) + { + AppLogger.Warn( + "StartupDiagnostics", + $"Failed to delete legacy artifacts: {string.Join("; ", failedLegacyArtifacts)}"); + } + + var isLegacyExecutableLaunch = string.Equals( + executableName, + "LanMontainDesktop.exe", + StringComparison.OrdinalIgnoreCase); + + if (isLegacyExecutableLaunch) + { + AppLogger.Warn( + "StartupDiagnostics", + $"Legacy executable launch detected. Current executable should be '{CurrentExecutableName}', but actual executable is '{executableName}'."); + } + + return new StartupDiagnosticsResult + { + ExecutablePath = executablePath, + BaseDirectory = baseDirectory, + ExecutableName = executableName, + IsLegacyExecutableLaunch = isLegacyExecutableLaunch, + FoundLegacyArtifacts = foundLegacyArtifacts, + DeletedLegacyArtifacts = deletedLegacyArtifacts, + FailedLegacyArtifacts = failedLegacyArtifacts + }; + } + + public static void ShowLegacyExecutableWarningIfNeeded(StartupDiagnosticsResult diagnostics) + { + if (!diagnostics.IsLegacyExecutableLaunch) + { + return; + } + + var message = + "æ£€æµ‹åˆ°å½“å‰æ˜¯ä»Žæ—§æ®‹ç•™å¯æ‰§è¡Œæ–‡ä»¶å¯åŠ¨çš„ã€‚\r\n\r\n" + + $"当剿–‡ä»¶: {diagnostics.ExecutableName}\r\n" + + $"当å‰è·¯å¾„: {diagnostics.ExecutablePath}\r\n\r\n" + + $"请改用 {CurrentExecutableName} å¯åŠ¨ï¼Œä»¥å…ç»§ç»­è¯»å–æ—§æ®‹ç•™æ–‡ä»¶ã€‚\r\n" + + $"日志目录: {AppLogger.LogDirectory}"; + + WindowsNativeDialogService.ShowWarning("LanMountainDesktop å¯åŠ¨è¯Šæ–­", message); + } + + private static string ResolveExecutablePath() + { + try + { + return Environment.ProcessPath ?? + Process.GetCurrentProcess().MainModule?.FileName ?? + string.Empty; + } + catch + { + return string.Empty; + } + } + + private static bool TryDeleteLegacyArtifact(string path, out string? error) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + error = null; + return true; + } + + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + error = null; + return true; + } + + error = null; + return false; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } +} diff --git a/LanMountainDesktop/Services/WindowsNativeDialogService.cs b/LanMountainDesktop/Services/WindowsNativeDialogService.cs new file mode 100644 index 0000000..5f16d88 --- /dev/null +++ b/LanMountainDesktop/Services/WindowsNativeDialogService.cs @@ -0,0 +1,30 @@ +using System; +using System.Runtime.InteropServices; + +namespace LanMountainDesktop.Services; + +internal static class WindowsNativeDialogService +{ + private const uint Ok = 0x00000000; + private const uint IconWarning = 0x00000030; + + public static void ShowWarning(string caption, string message) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + _ = MessageBoxW(IntPtr.Zero, message, caption, Ok | IconWarning); + } + catch (Exception ex) + { + AppLogger.Warn("StartupDiagnostics", "Failed to show legacy executable warning dialog.", ex); + } + } + + [DllImport("user32.dll", EntryPoint = "MessageBoxW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type); +} diff --git a/LanMountainDesktop/Services/WindowsStartupService.cs b/LanMountainDesktop/Services/WindowsStartupService.cs index 58813c8..833b4b4 100644 --- a/LanMountainDesktop/Services/WindowsStartupService.cs +++ b/LanMountainDesktop/Services/WindowsStartupService.cs @@ -30,8 +30,9 @@ public sealed class WindowsStartupService return runKey?.GetValue(ValueName) is string value && !string.IsNullOrWhiteSpace(value); } - catch + catch (Exception ex) { + AppLogger.Warn("WindowsStartup", "Failed to query startup registry state.", ex); return false; } } @@ -67,8 +68,9 @@ public sealed class WindowsStartupService return IsEnabled() == enabled; } - catch + catch (Exception ex) { + AppLogger.Warn("WindowsStartup", $"Failed to set startup registry state. Enabled={enabled}", ex); return false; } } diff --git a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs index a68e515..4136337 100644 --- a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs +++ b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs @@ -13,6 +13,7 @@ using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Threading; using Avalonia.VisualTree; +using FluentAvalonia.UI.Controls; using LanMountainDesktop.Models; using LanMountainDesktop.Services; @@ -1069,12 +1070,12 @@ public partial class MainWindow private void RenderLauncherHiddenItemsList() { - if (LauncherHiddenItemsListPanel is null || LauncherHiddenItemsEmptyTextBlock is null) + if (LauncherHiddenItemsSettingsExpander is null || LauncherHiddenItemsEmptyTextBlock is null) { return; } - LauncherHiddenItemsListPanel.Children.Clear(); + LauncherHiddenItemsSettingsExpander.Items.Clear(); var hiddenItems = BuildLauncherHiddenItems(); LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0; if (hiddenItems.Count == 0) @@ -1084,7 +1085,7 @@ public partial class MainWindow foreach (var hiddenItem in hiddenItems) { - LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem)); + LauncherHiddenItemsSettingsExpander.Items.Add(CreateLauncherHiddenItemRow(hiddenItem)); } } @@ -1186,92 +1187,47 @@ public partial class MainWindow : fileName; } - private Control CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem) + private SettingsExpanderItem CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem) { - Control icon = hiddenItem.IconBitmap is not null - ? new Image - { - Source = hiddenItem.IconBitmap, - Width = 24, - Height = 24, - Stretch = Stretch.Uniform - } - : new Border - { - Width = 24, - Height = 24, - CornerRadius = new CornerRadius(999), - Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"), - Child = new TextBlock - { - Text = hiddenItem.Monogram, - FontSize = 10, - FontWeight = FontWeight.Bold, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center - } - }; - var typeText = hiddenItem.Kind == LauncherEntryKind.Folder ? L("settings.launcher.hidden_type_folder", "Folder") : L("settings.launcher.hidden_type_shortcut", "Shortcut"); - var infoPanel = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 10, - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Stretch - }; - infoPanel.Children.Add(icon); - infoPanel.Children.Add(new StackPanel - { - Spacing = 2, - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Left, - Children = - { - new TextBlock - { - Text = hiddenItem.DisplayName, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxLines = 1 - }, - new TextBlock - { - Text = typeText, - FontSize = 11, - Opacity = 0.7 - } - } - }); - var restoreButton = new Button { - Content = L("settings.launcher.restore_button", "Show Again"), + Content = L("settings.launcher.restore_button", "Unhide"), MinWidth = 110, Padding = new Thickness(12, 6), Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key) }; restoreButton.Click += OnRestoreLauncherHiddenItemClick; - var row = new Grid + return new SettingsExpanderItem { - ColumnDefinitions = new ColumnDefinitions("*,Auto"), - ColumnSpacing = 10 + Content = hiddenItem.DisplayName, + Description = typeText, + IconSource = CreateLauncherHiddenItemIconSource(hiddenItem), + IsClickEnabled = false, + Footer = restoreButton }; - row.Children.Add(infoPanel); - Grid.SetColumn(infoPanel, 0); - row.Children.Add(restoreButton); - Grid.SetColumn(restoreButton, 1); + } - return new Border + private IconSource CreateLauncherHiddenItemIconSource(LauncherHiddenItemView hiddenItem) + { + if (hiddenItem.IconBitmap is not null) { - Classes = { "glass-panel" }, - BorderThickness = new Thickness(0), - CornerRadius = new CornerRadius(14), - Padding = new Thickness(10, 8), - Child = row + return new ImageIconSource + { + Source = hiddenItem.IconBitmap + }; + } + + return new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = hiddenItem.Kind == LauncherEntryKind.Folder + ? FluentIcons.Common.Symbol.Folder + : FluentIcons.Common.Symbol.Apps, + IconVariant = FluentIcons.Common.IconVariant.Regular }; } diff --git a/LanMountainDesktop/Views/MainWindow.Settings.cs b/LanMountainDesktop/Views/MainWindow.Settings.cs index 116d10b..14e3eea 100644 --- a/LanMountainDesktop/Views/MainWindow.Settings.cs +++ b/LanMountainDesktop/Views/MainWindow.Settings.cs @@ -2805,7 +2805,6 @@ public partial class MainWindow // --- LauncherSettingsPage --- internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl("LauncherSettingsPanelTitleTextBlock")!; internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl("LauncherHiddenItemsSettingsExpander")!; - internal StackPanel LauncherHiddenItemsListPanel => LauncherSettingsPanel.FindControl("LauncherHiddenItemsListPanel")!; internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl("LauncherHiddenItemsEmptyTextBlock")!; internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl("LauncherHiddenItemsDescriptionTextBlock")!; diff --git a/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml index 635fbfb..5a3e962 100644 --- a/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml @@ -31,12 +31,6 @@ IsVisible="False" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" Text="No hidden items." /> - - - diff --git a/LanMountainDesktop/Views/SettingsWindow.Controls.cs b/LanMountainDesktop/Views/SettingsWindow.Controls.cs index 295e715..61e223e 100644 --- a/LanMountainDesktop/Views/SettingsWindow.Controls.cs +++ b/LanMountainDesktop/Views/SettingsWindow.Controls.cs @@ -211,7 +211,6 @@ public partial class SettingsWindow // --- LauncherSettingsPage --- internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl("LauncherSettingsPanelTitleTextBlock")!; internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl("LauncherHiddenItemsSettingsExpander")!; - internal StackPanel LauncherHiddenItemsListPanel => LauncherSettingsPanel.FindControl("LauncherHiddenItemsListPanel")!; internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl("LauncherHiddenItemsEmptyTextBlock")!; internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl("LauncherHiddenItemsDescriptionTextBlock")!; diff --git a/LanMountainDesktop/Views/SettingsWindow.WeatherLauncher.cs b/LanMountainDesktop/Views/SettingsWindow.WeatherLauncher.cs index 09846c4..35846fa 100644 --- a/LanMountainDesktop/Views/SettingsWindow.WeatherLauncher.cs +++ b/LanMountainDesktop/Views/SettingsWindow.WeatherLauncher.cs @@ -11,6 +11,7 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Threading; +using FluentAvalonia.UI.Controls; using FluentIcons.Common; using LanMountainDesktop.Models; using LanMountainDesktop.Services; @@ -626,18 +627,18 @@ public partial class SettingsWindow return string.Create(CultureInfo.InvariantCulture, $"{temperatureC:0.#}°C"); } - private static Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight) + private static FluentIcons.Common.Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight) { return weatherCode switch { - 0 => isNight ? Symbol.WeatherMoon : Symbol.WeatherSunny, - 1 or 2 => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay, - 3 or 7 => Symbol.WeatherRainShowersDay, - 8 or 9 => Symbol.WeatherRain, - 4 => Symbol.WeatherThunderstorm, - 13 or 14 or 15 or 16 => Symbol.WeatherSnow, - 18 or 32 => Symbol.WeatherFog, - _ => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay + 0 => isNight ? FluentIcons.Common.Symbol.WeatherMoon : FluentIcons.Common.Symbol.WeatherSunny, + 1 or 2 => isNight ? FluentIcons.Common.Symbol.WeatherPartlyCloudyNight : FluentIcons.Common.Symbol.WeatherPartlyCloudyDay, + 3 or 7 => FluentIcons.Common.Symbol.WeatherRainShowersDay, + 8 or 9 => FluentIcons.Common.Symbol.WeatherRain, + 4 => FluentIcons.Common.Symbol.WeatherThunderstorm, + 13 or 14 or 15 or 16 => FluentIcons.Common.Symbol.WeatherSnow, + 18 or 32 => FluentIcons.Common.Symbol.WeatherFog, + _ => isNight ? FluentIcons.Common.Symbol.WeatherPartlyCloudyNight : FluentIcons.Common.Symbol.WeatherPartlyCloudyDay }; } @@ -782,7 +783,7 @@ public partial class SettingsWindow private void RenderLauncherHiddenItemsList() { - LauncherHiddenItemsListPanel.Children.Clear(); + LauncherHiddenItemsSettingsExpander.Items.Clear(); var hiddenItems = BuildLauncherHiddenItems(); LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0; if (hiddenItems.Count == 0) @@ -792,7 +793,7 @@ public partial class SettingsWindow foreach (var hiddenItem in hiddenItems) { - LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem)); + LauncherHiddenItemsSettingsExpander.Items.Add(CreateLauncherHiddenItemRow(hiddenItem)); } } @@ -864,82 +865,47 @@ public partial class SettingsWindow return string.IsNullOrWhiteSpace(fileName) ? key : fileName; } - private Control CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem) + private SettingsExpanderItem CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem) { - Control icon = hiddenItem.IconBitmap is not null - ? new Image - { - Source = hiddenItem.IconBitmap, - Width = 24, - Height = 24, - Stretch = Stretch.Uniform - } - : new Border - { - Width = 24, - Height = 24, - CornerRadius = new CornerRadius(999), - Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"), - Child = new TextBlock - { - Text = hiddenItem.Monogram, - FontSize = 10, - FontWeight = FontWeight.Bold, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center - } - }; - var typeText = hiddenItem.Kind == LauncherEntryKind.Folder ? L("settings.launcher.hidden_type_folder", "Folder") : L("settings.launcher.hidden_type_shortcut", "Shortcut"); - var infoPanel = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 10, - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Stretch - }; - infoPanel.Children.Add(icon); - infoPanel.Children.Add(new StackPanel - { - Spacing = 2, - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Left, - Children = - { - new TextBlock { Text = hiddenItem.DisplayName, TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 }, - new TextBlock { Text = typeText, FontSize = 11, Opacity = 0.7 } - } - }); - var restoreButton = new Button { - Content = L("settings.launcher.restore_button", "Show Again"), + Content = L("settings.launcher.restore_button", "Unhide"), MinWidth = 110, Padding = new Thickness(12, 6), Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key) }; restoreButton.Click += OnRestoreLauncherHiddenItemClick; - var row = new Grid + return new SettingsExpanderItem { - ColumnDefinitions = new ColumnDefinitions("*,Auto"), - ColumnSpacing = 10 + Content = hiddenItem.DisplayName, + Description = typeText, + IconSource = CreateLauncherHiddenItemIconSource(hiddenItem), + IsClickEnabled = false, + Footer = restoreButton }; - row.Children.Add(infoPanel); - Grid.SetColumn(infoPanel, 0); - row.Children.Add(restoreButton); - Grid.SetColumn(restoreButton, 1); + } - return new Border + private IconSource CreateLauncherHiddenItemIconSource(LauncherHiddenItemView hiddenItem) + { + if (hiddenItem.IconBitmap is not null) { - Classes = { "glass-panel" }, - BorderThickness = new Thickness(0), - CornerRadius = new CornerRadius(14), - Padding = new Thickness(10, 8), - Child = row + return new ImageIconSource + { + Source = hiddenItem.IconBitmap + }; + } + + return new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = hiddenItem.Kind == LauncherEntryKind.Folder + ? FluentIcons.Common.Symbol.Folder + : FluentIcons.Common.Symbol.Apps, + IconVariant = FluentIcons.Common.IconVariant.Regular }; } diff --git a/LanMountainDesktop/installer/ChineseSimplified.isl b/LanMountainDesktop/installer/ChineseSimplified.isl new file mode 100644 index 0000000..d6a11c4 --- /dev/null +++ b/LanMountainDesktop/installer/ChineseSimplified.isl @@ -0,0 +1,418 @@ +; *** Inno Setup version 6.5.0+ Chinese Simplified messages *** +; +; To download user-contributed translations of this file, go to: +; https://jrsoftware.org/files/istrans/ +; +; Note: When translating this text, do not add periods (.) to the end of +; messages that didn't have them already, because on those messages Inno +; Setup adds the periods automatically (appending a period would result in +; two periods being displayed). +; +; Maintained by Zhenghan Yang +; Email: 847320916@QQ.com +; Translation based on network resource +; The latest Translation is on https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation +; + +[LangOptions] +; The following three entries are very important. Be sure to read and +; understand the '[LangOptions] section' topic in the help file. +LanguageName=简体中文 +; If Language Name display incorrect, uncomment next line +; LanguageName=<7B80><4F53><4E2D><6587> +; About LanguageID, to reference link: +; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c +LanguageID=$0804 +; About CodePage, to reference link: +; https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers +LanguageCodePage=936 +; If the language you are translating to requires special font faces or +; sizes, uncomment any of the following entries and change them accordingly. +;DialogFontName= +;DialogFontSize=9 +;DialogFontBaseScaleWidth=7 +;DialogFontBaseScaleHeight=15 +;WelcomeFontName=Segoe UI +;WelcomeFontSize=14 + +[Messages] + +; *** åº”ç”¨ç¨‹åºæ ‡é¢˜ +SetupAppTitle=安装 +SetupWindowTitle=安装 - %1 +UninstallAppTitle=å¸è½½ +UninstallAppFullTitle=%1 å¸è½½ + +; *** Misc. common +InformationTitle=ä¿¡æ¯ +ConfirmTitle=确认 +ErrorTitle=错误 + +; *** SetupLdr messages +SetupLdrStartupMessage=现在将安装 %1。您想è¦ç»§ç»­å—? +LdrCannotCreateTemp=无法创建临时文件。安装程åºå·²ä¸­æ­¢ +LdrCannotExecTemp=无法执行临时目录中的文件。安装程åºå·²ä¸­æ­¢ +HelpTextNote= + +; *** å¯åŠ¨é”™è¯¯æ¶ˆæ¯ +LastErrorMessage=%1。%n%n错误 %2: %3 +SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获å–程åºçš„æ–°å‰¯æœ¬ã€‚ +SetupFileCorrupt=安装文件已æŸå。请获å–程åºçš„æ–°å‰¯æœ¬ã€‚ +SetupFileCorruptOrWrongVer=安装文件已æŸå,或是与这个安装程åºçš„版本ä¸å…¼å®¹ã€‚è¯·ä¿®æ­£è¿™ä¸ªé—®é¢˜æˆ–èŽ·å–æ–°çš„程åºå‰¯æœ¬ã€‚ +InvalidParameter=æ— æ•ˆçš„å‘½ä»¤è¡Œå‚æ•°ï¼š%n%n%1 +SetupAlreadyRunning=å®‰è£…ç¨‹åºæ­£åœ¨è¿è¡Œã€‚ +WindowsVersionNotSupported=此程åºä¸æ”¯æŒå½“å‰è®¡ç®—机è¿è¡Œçš„ Windows 版本。 +WindowsServicePackRequired=此程åºéœ€è¦ %1 æœåŠ¡åŒ… %2 或更高版本。 +NotOnThisPlatform=此程åºä¸èƒ½åœ¨ %1 上è¿è¡Œã€‚ +OnlyOnThisPlatform=此程åºåªèƒ½åœ¨ %1 上è¿è¡Œã€‚ +OnlyOnTheseArchitectures=此程åºåªèƒ½å®‰è£…到为下列处ç†å™¨æž¶æž„设计的 Windows 版本中:%n%n%1 +WinVersionTooLowError=此程åºéœ€è¦ %1 版本 %2 或更高。 +WinVersionTooHighError=此程åºä¸èƒ½å®‰è£…于 %1 版本 %2 或更高。 +AdminPrivilegesRequired=åœ¨å®‰è£…æ­¤ç¨‹åºæ—¶æ‚¨å¿…须以管ç†å‘˜èº«ä»½ç™»å½•。 +PowerUserPrivilegesRequired=åœ¨å®‰è£…æ­¤ç¨‹åºæ—¶æ‚¨å¿…须以管ç†å‘˜èº«ä»½æˆ–有æƒé™çš„用户组身份登录。 +SetupAppRunningError=安装程åºå‘现 %1 当剿­£åœ¨è¿è¡Œã€‚%n%n请先关闭正在è¿è¡Œçš„程åºï¼Œç„¶åŽç‚¹å‡»â€œç¡®å®šâ€ç»§ç»­ï¼Œæˆ–ç‚¹å‡»â€œå–æ¶ˆâ€é€€å‡ºã€‚ +UninstallAppRunningError=å¸è½½ç¨‹åºå‘现 %1 当剿­£åœ¨è¿è¡Œã€‚%n%n请先关闭正在è¿è¡Œçš„程åºï¼Œç„¶åŽç‚¹å‡»â€œç¡®å®šâ€ç»§ç»­ï¼Œæˆ–ç‚¹å‡»â€œå–æ¶ˆâ€é€€å‡ºã€‚ + +; *** å¯åŠ¨é—®é¢˜ +PrivilegesRequiredOverrideTitle=é€‰æ‹©å®‰è£…ç¨‹åºæ¨¡å¼ +PrivilegesRequiredOverrideInstruction=é€‰æ‹©å®‰è£…æ¨¡å¼ +PrivilegesRequiredOverrideText1=%1 å¯ä»¥ä¸ºæ‰€æœ‰ç”¨æˆ·å®‰è£…(需è¦ç®¡ç†å‘˜æƒé™),或仅为您安装。 +PrivilegesRequiredOverrideText2=%1 å¯ä»¥ä»…为您安装,或为所有用户安装(需è¦ç®¡ç†å‘˜æƒé™)。 +PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A) +PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A) (建议选项) +PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M) +PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M) (建议选项) + +; *** 其他错误 +ErrorCreatingDir=å®‰è£…ç¨‹åºæ— æ³•创建目录“%1†+ErrorTooManyFilesInDir=无法在目录“%1â€ä¸­åˆ›å»ºæ–‡ä»¶ï¼Œå› ä¸ºé‡Œé¢åŒ…å«å¤ªå¤šæ–‡ä»¶ + +; *** 安装程åºå…¬å…±æ¶ˆæ¯ +ExitSetupTitle=é€€å‡ºå®‰è£…ç¨‹åº +ExitSetupMessage=安装程åºå°šæœªå®Œæˆã€‚如果现在退出,将ä¸ä¼šå®‰è£…该程åºã€‚%n%n您之åŽå¯ä»¥å†æ¬¡è¿è¡Œå®‰è£…程åºå®Œæˆå®‰è£…。%n%n现在退出安装程åºå—? +AboutSetupMenuItem=关于安装程åº(&A)... +AboutSetupTitle=å…³äºŽå®‰è£…ç¨‹åº +AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4 +AboutSetupNote= +TranslatorNote=简体中文翻译由Kira(847320916@qq.com)维护。项目地å€ï¼šhttps://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation + +; *** 按钮 +ButtonBack=< 上一步(&B) +ButtonNext=下一步(&N) > +ButtonInstall=安装(&I) +ButtonOK=确定 +ButtonCancel=å–æ¶ˆ +ButtonYes=是(&Y) +ButtonYesToAll=全是(&A) +ButtonNo=å¦(&N) +ButtonNoToAll=å…¨å¦(&O) +ButtonFinish=完æˆ(&F) +ButtonBrowse=æµè§ˆ(&B)... +ButtonWizardBrowse=æµè§ˆ(&R)... +ButtonNewFolder=新建文件夹(&M) + +; *** “选择语言â€å¯¹è¯æ¡†æ¶ˆæ¯ +SelectLanguageTitle=选择安装语言 +SelectLanguageLabel=选择安装时使用的语言。 + +; *** 公共å‘导文字 +ClickNext=点击“下一步â€ç»§ç»­ï¼Œæˆ–ç‚¹å‡»â€œå–æ¶ˆâ€é€€å‡ºå®‰è£…程åºã€‚ +BeveledLabel= +BrowseDialogTitle=æµè§ˆæ–‡ä»¶å¤¹ +BrowseDialogLabel=在下é¢çš„列表中选择一个文件夹,然åŽç‚¹å‡»â€œç¡®å®šâ€ã€‚ +NewFolderName=新建文件夹 + +; *** “欢迎â€å‘导页 +WelcomeLabel1=欢迎使用 [name] 安装å‘导 +WelcomeLabel2=现在将安装 [name/ver] 到您的电脑中。%n%n建议您在继续安装å‰å…³é—­æ‰€æœ‰å…¶ä»–应用程åºã€‚ + +; *** “密ç â€å‘导页 +WizardPassword=å¯†ç  +PasswordLabel1=è¿™ä¸ªå®‰è£…ç¨‹åºæœ‰å¯†ç ä¿æŠ¤ã€‚ +PasswordLabel3=请输入密ç ï¼Œç„¶åŽç‚¹å‡»â€œä¸‹ä¸€æ­¥â€ç»§ç»­ã€‚密ç åŒºåˆ†å¤§å°å†™ã€‚ +PasswordEditLabel=密ç (&P): +IncorrectPassword=您输入的密ç ä¸æ­£ç¡®ï¼Œè¯·é‡æ–°è¾“入。 + +; *** “许å¯åè®®â€å‘导页 +WizardLicense=许å¯åè®® +LicenseLabel=请在继续安装å‰é˜…读以下é‡è¦ä¿¡æ¯ã€‚ +LicenseLabel3=请仔细阅读下列许å¯åè®®ã€‚åœ¨ç»§ç»­å®‰è£…å‰æ‚¨å¿…é¡»åŒæ„这些åè®®æ¡æ¬¾ã€‚ +LicenseAccepted=æˆ‘åŒæ„æ­¤åè®®(&A) +LicenseNotAccepted=我ä¸åŒæ„æ­¤åè®®(&D) + +; *** “信æ¯â€å‘导页 +WizardInfoBefore=ä¿¡æ¯ +InfoBeforeLabel=请在继续安装å‰é˜…读以下é‡è¦ä¿¡æ¯ã€‚ +InfoBeforeClickLabel=准备好继续安装åŽï¼Œç‚¹å‡»â€œä¸‹ä¸€æ­¥â€ã€‚ +WizardInfoAfter=ä¿¡æ¯ +InfoAfterLabel=请在继续安装å‰é˜…读以下é‡è¦ä¿¡æ¯ã€‚ +InfoAfterClickLabel=准备好继续安装åŽï¼Œç‚¹å‡»â€œä¸‹ä¸€æ­¥â€ã€‚ + +; *** “用户信æ¯â€å‘导页 +WizardUserInfo=ç”¨æˆ·ä¿¡æ¯ +UserInfoDesc=请输入您的信æ¯ã€‚ +UserInfoName=用户å(&U): +UserInfoOrg=组织(&O): +UserInfoSerial=åºåˆ—å·(&S): +UserInfoNameRequired=您必须输入用户å。 + +; *** “选择目标目录â€å‘导页 +WizardSelectDir=选择目标ä½ç½® +SelectDirDesc=您想将 [name] 安装在哪里? +SelectDirLabel3=安装程åºå°†å®‰è£… [name] 到下é¢çš„æ–‡ä»¶å¤¹ä¸­ã€‚ +SelectDirBrowseLabel=点击“下一步â€ç»§ç»­ã€‚如果您想选择其他文件夹,点击“æµè§ˆâ€ã€‚ +DiskSpaceGBLabel=è‡³å°‘éœ€è¦æœ‰ [gb] GB çš„å¯ç”¨ç£ç›˜ç©ºé—´ã€‚ +DiskSpaceMBLabel=è‡³å°‘éœ€è¦æœ‰ [mb] MB çš„å¯ç”¨ç£ç›˜ç©ºé—´ã€‚ +CannotInstallToNetworkDrive=å®‰è£…ç¨‹åºæ— æ³•安装到一个网络驱动器。 +CannotInstallToUNCPath=å®‰è£…ç¨‹åºæ— æ³•安装到一个 UNC 路径。 +InvalidPath=æ‚¨å¿…é¡»è¾“å…¥ä¸€ä¸ªå¸¦é©±åŠ¨å™¨å·æ ‡çš„完整路径,例如:%n%nC:\APP%n%n或UNC路径:%n%n\\server\share +InvalidDrive=您选定的驱动器或 UNC 共享ä¸å­˜åœ¨æˆ–ä¸èƒ½è®¿é—®ã€‚请选择其他ä½ç½®ã€‚ +DiskSpaceWarningTitle=ç£ç›˜ç©ºé—´ä¸è¶³ +DiskSpaceWarning=安装程åºè‡³å°‘éœ€è¦ %1 KB çš„å¯ç”¨ç©ºé—´æ‰èƒ½å®‰è£…ï¼Œä½†é€‰å®šé©±åŠ¨å™¨åªæœ‰ %2 KB çš„å¯ç”¨ç©ºé—´ã€‚%n%n您一定è¦ç»§ç»­å—? +DirNameTooLong=文件夹å称或路径太长。 +InvalidDirName=文件夹å称无效。 +BadDirName32=文件夹åç§°ä¸èƒ½åŒ…å«ä¸‹åˆ—任何字符:%n%n%1 +DirExistsTitle=文件夹已存在 +DirExists=文件夹:%n%n%1%n%nå·²ç»å­˜åœ¨ã€‚您一定è¦å®‰è£…到这个文件夹中å—? +DirDoesntExistTitle=文件夹ä¸å­˜åœ¨ +DirDoesntExist=文件夹:%n%n%1%n%nä¸å­˜åœ¨ã€‚您想è¦åˆ›å»ºæ­¤æ–‡ä»¶å¤¹å—? + +; *** “选择组件â€å‘导页 +WizardSelectComponents=选择组件 +SelectComponentsDesc=您想安装哪些程åºç»„件? +SelectComponentsLabel2=é€‰ä¸­æ‚¨æƒ³å®‰è£…çš„ç»„ä»¶ï¼›å–æ¶ˆæ‚¨ä¸æƒ³å®‰è£…的组件。然åŽç‚¹å‡»â€œä¸‹ä¸€æ­¥â€ç»§ç»­ã€‚ +FullInstallation=完全安装 +; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language) +CompactInstallation=简æ´å®‰è£… +CustomInstallation=自定义安装 +NoUninstallWarningTitle=组件已存在 +NoUninstallWarning=å®‰è£…ç¨‹åºæ£€æµ‹åˆ°ä¸‹åˆ—组件已安装在您的电脑中:%n%n%1%n%nå–æ¶ˆé€‰ä¸­è¿™äº›ç»„ä»¶ä¸ä¼šå¸è½½å®ƒä»¬ã€‚%n%n确定è¦ç»§ç»­å—? +ComponentSize1=%1 KB +ComponentSize2=%1 MB +ComponentsDiskSpaceGBLabel=当å‰é€‰æ‹©çš„组件需è¦è‡³å°‘ [gb] GB çš„ç£ç›˜ç©ºé—´ã€‚ +ComponentsDiskSpaceMBLabel=当å‰é€‰æ‹©çš„组件需è¦è‡³å°‘ [mb] MB çš„ç£ç›˜ç©ºé—´ã€‚ + +; *** “选择附加任务â€å‘导页 +WizardSelectTasks=选择附加任务 +SelectTasksDesc=您想è¦å®‰è£…ç¨‹åºæ‰§è¡Œå“ªäº›é™„加任务? +SelectTasksLabel2=选择您想è¦å®‰è£…程åºåœ¨å®‰è£… [name] 时执行的附加任务,然åŽç‚¹å‡»â€œä¸‹ä¸€æ­¥â€ã€‚ + +; *** “选择开始èœå•文件夹â€å‘导页 +WizardSelectProgramGroup=选择开始èœå•文件夹 +SelectStartMenuFolderDesc=安装程åºåº”该在哪里放置程åºçš„å¿«æ·æ–¹å¼ï¼Ÿ +SelectStartMenuFolderLabel3=安装程åºå°†åœ¨ä¸‹åˆ—“开始â€èœå•文件夹中创建程åºçš„å¿«æ·æ–¹å¼ã€‚ +SelectStartMenuFolderBrowseLabel=点击“下一步â€ç»§ç»­ã€‚如果您想选择其他文件夹,点击“æµè§ˆâ€ã€‚ +MustEnterGroupName=您必须输入一个文件夹å。 +GroupNameTooLong=æ–‡ä»¶å¤¹åæˆ–路径太长。 +InvalidGroupName=无效的文件夹å字。 +BadGroupName=文件夹åä¸èƒ½åŒ…å«ä¸‹åˆ—任何字符:%n%n%1 +NoProgramGroupCheck2=ä¸åˆ›å»ºå¼€å§‹èœå•文件夹(&D) + +; *** “准备安装â€å‘导页 +WizardReady=准备安装 +ReadyLabel1=安装程åºå‡†å¤‡å°±ç»ªï¼ŒçŽ°åœ¨å¯ä»¥å¼€å§‹å®‰è£… [name] 到您的电脑。 +ReadyLabel2a=点击“安装â€ç»§ç»­æ­¤å®‰è£…程åºã€‚å¦‚æžœæ‚¨æƒ³é‡æ–°è€ƒè™‘或修改任何设置,点击“上一步â€ã€‚ +ReadyLabel2b=点击“安装â€ç»§ç»­æ­¤å®‰è£…程åºã€‚ +ReadyMemoUserInfo=用户信æ¯ï¼š +ReadyMemoDir=目标ä½ç½®ï¼š +ReadyMemoType=安装类型: +ReadyMemoComponents=已选择组件: +ReadyMemoGroup=开始èœå•文件夹: +ReadyMemoTasks=附加任务: + +; *** TExtractionWizardPage å‘导页é¢ä¸Ž ExtractArchive +ExtractingLabel=正在解压文件... +ButtonStopExtraction=åœæ­¢è§£åŽ‹(&S) +StopExtraction=您确定è¦åœæ­¢è§£åŽ‹å—? +ErrorExtractionAborted=解压已中止 +ErrorExtractionFailed=解压失败:%1 + +; *** 压缩文件解压失败详情 +ArchiveIncorrectPassword=压缩文件密ç ä¸æ­£ç¡® +ArchiveIsCorrupted=压缩文件已æŸå +ArchiveUnsupportedFormat=䏿”¯æŒçš„åŽ‹ç¼©æ–‡ä»¶æ ¼å¼ + +; *** TDownloadWizardPage å‘导页é¢å’Œ DownloadTemporaryFile +DownloadingLabel2=正在下载文件... +ButtonStopDownload=åœæ­¢ä¸‹è½½(&S) +StopDownload=您确定è¦åœæ­¢ä¸‹è½½å—? +ErrorDownloadAborted=下载已中止 +ErrorDownloadFailed=下载失败:%1 %2 +ErrorDownloadSizeFailed=获å–下载大å°å¤±è´¥ï¼š%1 %2 +ErrorProgress=无效的进度:%1 / %2 +ErrorFileSize=文件大å°é”™è¯¯ï¼šé¢„期 %1,实际 %2 + +; *** “正在准备安装â€å‘导页 +WizardPreparing=正在准备安装 +PreparingDesc=å®‰è£…ç¨‹åºæ­£åœ¨å‡†å¤‡å®‰è£… [name] 到您的电脑。 +PreviousInstallNotCompleted=å…ˆå‰çš„程åºå®‰è£…或å¸è½½æœªå®Œæˆï¼Œæ‚¨éœ€è¦é‡å¯æ‚¨çš„电脑以完æˆã€‚%n%n在é‡å¯ç”µè„‘åŽï¼Œå†æ¬¡è¿è¡Œå®‰è£…程åºä»¥å®Œæˆ [name] 的安装。 +CannotContinue=安装程åºä¸èƒ½ç»§ç»­ã€‚è¯·ç‚¹å‡»â€œå–æ¶ˆâ€é€€å‡ºã€‚ +ApplicationsFound=ä»¥ä¸‹åº”ç”¨ç¨‹åºæ­£åœ¨ä½¿ç”¨å°†ç”±å®‰è£…ç¨‹åºæ›´æ–°çš„æ–‡ä»¶ã€‚建议您å…许安装程åºè‡ªåŠ¨å…³é—­è¿™äº›åº”ç”¨ç¨‹åºã€‚ +ApplicationsFound2=ä»¥ä¸‹åº”ç”¨ç¨‹åºæ­£åœ¨ä½¿ç”¨å°†ç”±å®‰è£…ç¨‹åºæ›´æ–°çš„æ–‡ä»¶ã€‚建议您å…许安装程åºè‡ªåŠ¨å…³é—­è¿™äº›åº”ç”¨ç¨‹åºã€‚安装完æˆåŽï¼Œå®‰è£…程åºå°†å°è¯•釿–°å¯åŠ¨è¿™äº›åº”ç”¨ç¨‹åºã€‚ +CloseApplications=自动关闭应用程åº(&A) +DontCloseApplications=ä¸è¦å…³é—­åº”用程åº(&D) +ErrorCloseApplications=å®‰è£…ç¨‹åºæ— æ³•自动关闭所有应用程åºã€‚建议您在继续之å‰ï¼Œå…³é—­æ‰€æœ‰åœ¨ä½¿ç”¨éœ€è¦ç”±å®‰è£…ç¨‹åºæ›´æ–°çš„æ–‡ä»¶çš„应用程åºã€‚ +PrepareToInstallNeedsRestart=安装程åºå¿…é¡»é‡å¯æ‚¨çš„计算机。计算机é‡å¯åŽï¼Œè¯·å†æ¬¡è¿è¡Œå®‰è£…程åºä»¥å®Œæˆ [name] 的安装。%n%n是å¦ç«‹å³é‡æ–°å¯åŠ¨ï¼Ÿ + +; *** “正在安装â€å‘导页 +WizardInstalling=正在安装 +InstallingLabel=å®‰è£…ç¨‹åºæ­£åœ¨å®‰è£… [name] 到您的电脑,请ç¨å€™ã€‚ + +; *** “安装完æˆâ€å‘导页 +FinishedHeadingLabel=[name] å®‰è£…å®Œæˆ +FinishedLabelNoIcons=安装程åºå·²åœ¨æ‚¨çš„电脑中安装了 [name]。 +FinishedLabel=安装程åºå·²åœ¨æ‚¨çš„电脑中安装了 [name]。您å¯ä»¥é€šè¿‡å·²å®‰è£…çš„å¿«æ·æ–¹å¼è¿è¡Œæ­¤åº”用程åºã€‚ +ClickFinish=点击“完æˆâ€é€€å‡ºå®‰è£…程åºã€‚ +FinishedRestartLabel=ä¸ºå®Œæˆ [name] 的安装,安装程åºå¿…须釿–°å¯åŠ¨æ‚¨çš„ç”µè„‘ã€‚è¦ç«‹å³é‡å¯å—? +FinishedRestartMessage=ä¸ºå®Œæˆ [name] 的安装,安装程åºå¿…须釿–°å¯åŠ¨æ‚¨çš„ç”µè„‘ã€‚%n%nè¦ç«‹å³é‡å¯å—? +ShowReadmeCheck=是,我想查阅自述文件 +YesRadio=是,立å³é‡å¯ç”µè„‘(&Y) +NoRadio=å¦ï¼Œç¨åŽé‡å¯ç”µè„‘(&N) +; used for example as 'Run MyProg.exe' +RunEntryExec=è¿è¡Œ %1 +; used for example as 'View Readme.txt' +RunEntryShellExec=查阅 %1 + +; *** “安装程åºéœ€è¦ä¸‹ä¸€å¼ ç£ç›˜â€æç¤º +ChangeDiskTitle=安装程åºéœ€è¦ä¸‹ä¸€å¼ ç£ç›˜ +SelectDiskLabel2=请æ’å…¥ç£ç›˜ %1 并点击“确定â€ã€‚%n%n如果这个ç£ç›˜ä¸­çš„æ–‡ä»¶å¯ä»¥åœ¨ä¸‹åˆ—文件夹之外的文件夹中找到,请输入正确的路径或点击“æµè§ˆâ€ã€‚ +PathLabel=路径(&P): +FileNotInDir2=“%2â€ä¸­æ‰¾ä¸åˆ°æ–‡ä»¶â€œ%1â€ã€‚请æ’入正确的ç£ç›˜æˆ–选择其他文件夹。 +SelectDirectoryLabel=请指定下一张ç£ç›˜çš„ä½ç½®ã€‚ + +; *** å®‰è£…é˜¶æ®µæ¶ˆæ¯ +SetupAborted=å®‰è£…ç¨‹åºæœªå®Œæˆå®‰è£…。%n%nè¯·ä¿®æ­£è¿™ä¸ªé—®é¢˜å¹¶é‡æ–°è¿è¡Œå®‰è£…程åºã€‚ +AbortRetryIgnoreSelectAction=选择æ“作 +AbortRetryIgnoreRetry=é‡è¯•(&T) +AbortRetryIgnoreIgnore=忽略错误并继续(&I) +AbortRetryIgnoreCancel=å…³é—­å®‰è£…ç¨‹åº +RetryCancelSelectAction=选择æ“作 +RetryCancelRetry=é‡è¯•(&T) +RetryCancelCancel=å–æ¶ˆ(&C) + +; *** å®‰è£…çŠ¶æ€æ¶ˆæ¯ +StatusClosingApplications=正在关闭应用程åº... +StatusCreateDirs=正在创建目录... +StatusExtractFiles=正在æå–文件... +StatusDownloadFiles=正在下载文件... +StatusCreateIcons=æ­£åœ¨åˆ›å»ºå¿«æ·æ–¹å¼... +StatusCreateIniEntries=正在创建 INI æ¡ç›®... +StatusCreateRegistryEntries=正在创建注册表æ¡ç›®... +StatusRegisterFiles=正在注册文件... +StatusSavingUninstall=正在ä¿å­˜å¸è½½ä¿¡æ¯... +StatusRunProgram=正在完æˆå®‰è£…... +StatusRestartingApplications=正在é‡å¯åº”用程åº... +StatusRollback=正在撤销更改... + +; *** 其他错误 +ErrorInternal2=内部错误:%1 +ErrorFunctionFailedNoCode=%1 失败 +ErrorFunctionFailed=%1 å¤±è´¥ï¼›é”™è¯¯ä»£ç  %2 +ErrorFunctionFailedWithMessage=%1 å¤±è´¥ï¼›é”™è¯¯ä»£ç  %2.%n%3 +ErrorExecutingProgram=无法执行文件:%n%1 + +; *** 注册表错误 +ErrorRegOpenKey=打开注册表项时出错:%n%1\%2 +ErrorRegCreateKey=创建注册表项时出错:%n%1\%2 +ErrorRegWriteKey=写入注册表项时出错:%n%1\%2 + +; *** INI 错误 +ErrorIniEntry=在文件“%1â€ä¸­åˆ›å»º INI æ¡ç›®æ—¶å‡ºé”™ã€‚ + +; *** 文件å¤åˆ¶é”™è¯¯ +FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S) (䏿ލè) +FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I) (䏿ލè) +SourceIsCorrupted=æºæ–‡ä»¶å·²æŸå +SourceDoesntExist=æºæ–‡ä»¶â€œ%1â€ä¸å­˜åœ¨ +SourceVerificationFailed=æºæ–‡ä»¶éªŒè¯å¤±è´¥: %1 +VerificationSignatureDoesntExist=ç­¾åæ–‡ä»¶â€œ%1â€ä¸å­˜åœ¨ +VerificationSignatureInvalid=ç­¾åæ–‡ä»¶â€œ%1â€æ— æ•ˆ +VerificationKeyNotFound=ç­¾åæ–‡ä»¶â€œ%1â€ä½¿ç”¨äº†æœªçŸ¥å¯†é’¥ +VerificationFileNameIncorrect=文件å䏿­£ç¡® +VerificationFileTagIncorrect=æ–‡ä»¶æ ‡ç­¾ä¸æ­£ç¡® +VerificationFileSizeIncorrect=文件大å°ä¸æ­£ç¡® +VerificationFileHashIncorrect=æ–‡ä»¶å“ˆå¸Œå€¼ä¸æ­£ç¡® +ExistingFileReadOnly2=无法替æ¢çŽ°æœ‰æ–‡ä»¶ï¼Œå®ƒæ˜¯åªè¯»çš„。 +ExistingFileReadOnlyRetry=移除åªè¯»å±žæ€§å¹¶é‡è¯•(&R) +ExistingFileReadOnlyKeepExisting=ä¿ç•™çŽ°æœ‰æ–‡ä»¶(&K) +ErrorReadingExistingDest=å°è¯•读å–现有文件时出错: +FileExistsSelectAction=选择æ“作 +FileExists2=文件已ç»å­˜åœ¨ã€‚ +FileExistsOverwriteExisting=覆盖已存在的文件(&O) +FileExistsKeepExisting=ä¿ç•™çŽ°æœ‰çš„æ–‡ä»¶(&K) +FileExistsOverwriteOrKeepAll=ä¸ºæ‰€æœ‰å†²çªæ–‡ä»¶æ‰§è¡Œæ­¤æ“作(&D) +ExistingFileNewerSelectAction=选择æ“作 +ExistingFileNewer2=现有的文件比安装程åºå°†è¦å®‰è£…çš„æ–‡ä»¶è¿˜è¦æ–°ã€‚ +ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O) +ExistingFileNewerKeepExisting=ä¿ç•™çŽ°æœ‰çš„æ–‡ä»¶(&K) (推è) +ExistingFileNewerOverwriteOrKeepAll=ä¸ºæ‰€æœ‰å†²çªæ–‡ä»¶æ‰§è¡Œæ­¤æ“作(&D) +ErrorChangingAttr=å°è¯•更改下列现有文件的属性时出错: +ErrorCreatingTemp=å°è¯•在目标目录创建文件时出错: +ErrorReadingSource=å°è¯•读å–ä¸‹åˆ—æºæ–‡ä»¶æ—¶å‡ºé”™ï¼š +ErrorCopying=å°è¯•å¤åˆ¶ä¸‹åˆ—文件时出错: +ErrorDownloading=下载文件时出错: +ErrorExtracting=解压压缩文件时出错: +ErrorReplacingExistingFile=å°è¯•替æ¢çŽ°æœ‰æ–‡ä»¶æ—¶å‡ºé”™ï¼š +ErrorRestartReplace=é‡å¯å¹¶æ›¿æ¢å¤±è´¥ï¼š +ErrorRenamingTemp=å°è¯•é‡å‘½å下列目标目录中的一个文件时出错: +ErrorRegisterServer=无法注册 DLL/OCX:%1 +ErrorRegSvr32Failed=RegSvr32 å¤±è´¥ï¼›é€€å‡ºä»£ç  %1 +ErrorRegisterTypeLib=无法注册类库:%1 + +; *** å¸è½½æ˜¾ç¤ºå字标记 +; used for example as 'My Program (32-bit)' +UninstallDisplayNameMark=%1 (%2) +; used for example as 'My Program (32-bit, All users)' +UninstallDisplayNameMarks=%1 (%2, %3) +UninstallDisplayNameMark32Bit=32 ä½ +UninstallDisplayNameMark64Bit=64 ä½ +UninstallDisplayNameMarkAllUsers=所有用户 +UninstallDisplayNameMarkCurrentUser=当å‰ç”¨æˆ· + +; *** 安装åŽé”™è¯¯ +ErrorOpeningReadme=å°è¯•打开自述文件时出错。 +ErrorRestartingComputer=å®‰è£…ç¨‹åºæ— æ³•é‡å¯ç”µè„‘,请手动é‡å¯ã€‚ + +; *** å¸è½½æ¶ˆæ¯ +UninstallNotFound=文件“%1â€ä¸å­˜åœ¨ã€‚无法å¸è½½ã€‚ +UninstallOpenError=文件“%1â€ä¸èƒ½è¢«æ‰“开。无法å¸è½½ã€‚ +UninstallUnsupportedVer=此版本的å¸è½½ç¨‹åºæ— æ³•识别å¸è½½æ—¥å¿—文件“%1â€çš„æ ¼å¼ã€‚无法å¸è½½ +UninstallUnknownEntry=å¸è½½æ—¥å¿—中é‡åˆ°ä¸€ä¸ªæœªçŸ¥æ¡ç›® (%1) +ConfirmUninstall=您确认è¦å®Œå…¨ç§»é™¤ %1 åŠå…¶æ‰€æœ‰ç»„ä»¶å—? +UninstallOnlyOnWin64=ä»…å…许在 64 ä½ Windows 中å¸è½½æ­¤ç¨‹åºã€‚ +OnlyAdminCanUninstall=仅使用管ç†å‘˜æƒé™çš„ç”¨æˆ·èƒ½å®Œæˆæ­¤å¸è½½ã€‚ +UninstallStatusLabel=正在从您的电脑中移除 %1,请ç¨å€™ã€‚ +UninstalledAll=已顺利从您的电脑中移除 %1。 +UninstalledMost=%1 å¸è½½å®Œæˆã€‚%n%n有部分内容未能被删除,但您å¯ä»¥æ‰‹åŠ¨åˆ é™¤å®ƒä»¬ã€‚ +UninstalledAndNeedsRestart=ä¸ºå®Œæˆ %1 çš„å¸è½½ï¼Œéœ€è¦é‡å¯æ‚¨çš„电脑。%n%nç«‹å³é‡å¯ç”µè„‘å—? +UninstallDataCorrupted=文件“%1â€å·²æŸå。无法å¸è½½ + +; *** å¸è½½çŠ¶æ€æ¶ˆæ¯ +ConfirmDeleteSharedFileTitle=删除共享的文件å—? +ConfirmDeleteSharedFile2=ç³»ç»Ÿè¡¨ç¤ºä¸‹åˆ—å…±äº«çš„æ–‡ä»¶å·²ä¸æœ‰å…¶ä»–程åºä½¿ç”¨ã€‚您希望å¸è½½ç¨‹åºåˆ é™¤è¿™äº›å…±äº«çš„æ–‡ä»¶å—?%n%nå¦‚æžœåˆ é™¤è¿™äº›æ–‡ä»¶ï¼Œä½†ä»æœ‰ç¨‹åºåœ¨ä½¿ç”¨è¿™äº›æ–‡ä»¶ï¼Œåˆ™è¿™äº›ç¨‹åºå¯èƒ½å‡ºçŽ°å¼‚å¸¸ã€‚å¦‚æžœæ‚¨ä¸èƒ½ç¡®å®šï¼Œè¯·é€‰æ‹©â€œå¦â€ï¼Œåœ¨ç³»ç»Ÿä¸­ä¿ç•™è¿™äº›æ–‡ä»¶ä»¥å…引å‘问题。 +SharedFileNameLabel=文件å: +SharedFileLocationLabel=ä½ç½®ï¼š +WizardUninstalling=å¸è½½çŠ¶æ€ +StatusUninstalling=正在å¸è½½ %1... + +; *** Shutdown block reasons +ShutdownBlockReasonInstallingApp=正在安装 %1。 +ShutdownBlockReasonUninstallingApp=正在å¸è½½ %1。 + +; The custom messages below aren't used by Setup itself, but if you make +; use of them in your scripts, you'll want to translate them. + +[CustomMessages] + +NameAndVersion=%1 版本 %2 +AdditionalIcons=é™„åŠ å¿«æ·æ–¹å¼ï¼š +CreateDesktopIcon=创建桌é¢å¿«æ·æ–¹å¼(&D) +CreateQuickLaunchIcon=创建快速å¯åЍæ å¿«æ·æ–¹å¼(&Q) +ProgramOnTheWeb=%1 网站 +UninstallProgram=å¸è½½ %1 +LaunchProgram=è¿è¡Œ %1 +AssocFileExtension=å°† %2 文件扩展å与 %1 建立关è”(&A) +AssocingFileExtension=正在将 %2 文件扩展å与 %1 建立关è”... +AutoStartProgramGroupDescription=å¯åŠ¨ï¼š +AutoStartProgram=自动å¯åЍ %1 +AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您è¦ç»§ç»­å—? diff --git a/LanMountainDesktop/installer/LanMountainDesktop.iss b/LanMountainDesktop/installer/LanMountainDesktop.iss index ddee6a7..ba690a3 100644 --- a/LanMountainDesktop/installer/LanMountainDesktop.iss +++ b/LanMountainDesktop/installer/LanMountainDesktop.iss @@ -1,6 +1,8 @@ -#define MyAppName "LanMountainDesktop" +#define MyAppName "LanMountainDesktop" #define MyAppPublisher "LanMountainDesktop Team" #define MyAppExeName "LanMountainDesktop.exe" +#define MyAppId "{{5A058B0D-F95D-4A18-B9A0-93F843655DDB}" +#define MyAppRegistryId "{5A058B0D-F95D-4A18-B9A0-93F843655DDB}" #ifndef MyAppVersion #define MyAppVersion "0.0.0" @@ -19,13 +21,16 @@ #endif [Setup] -AppId={{5A058B0D-F95D-4A18-B9A0-93F843655DDB} +AppId={#MyAppId} AppName={#MyAppName} AppVersion={#MyAppVersion} AppPublisher={#MyAppPublisher} DefaultDirName={autopf}\{#MyAppName} DisableDirPage=no UsePreviousAppDir=no +ShowLanguageDialog=yes +UsePreviousLanguage=no +LanguageDetectionMethod=uilanguage DefaultGroupName={#MyAppName} UninstallDisplayIcon={app}\{#MyAppExeName} OutputDir={#MyOutputDir} @@ -34,6 +39,9 @@ Compression=lzma2/ultra64 SolidCompression=yes WizardStyle=modern PrivilegesRequired=admin +CloseApplications=yes +CloseApplicationsFilter={#MyAppExeName} +RestartApplications=no DisableProgramGroupPage=yes #if MyAppArch == "x64" @@ -47,10 +55,55 @@ ArchitecturesAllowed=x86compatible [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" +Name: "chinesesimplified"; MessagesFile: "{#SourcePath}\ChineseSimplified.isl" + +[CustomMessages] +english.StartupTaskDescription=Launch LanMountainDesktop when you sign in to Windows +chinesesimplified.StartupTaskDescription=登录 Windows æ—¶å¯åЍ LanMountainDesktop +english.WebView2MissingMessage=Microsoft Edge WebView2 Runtime is required for the browser component. +chinesesimplified.WebView2MissingMessage=æµè§ˆå™¨ç»„ä»¶éœ€è¦ Microsoft Edge WebView2 Runtime。 +english.WebView2MissingAction=Click "Yes" to open the official download page. Install it first, then run this installer again. +chinesesimplified.WebView2MissingAction=å•å‡»â€œæ˜¯â€æ‰“开官方下载页é¢ã€‚请先完æˆå®‰è£…,然åŽé‡æ–°è¿è¡Œæ­¤å®‰è£…程åºã€‚ +english.WebView2OpenFailedMessage=Unable to open the download page automatically. +chinesesimplified.WebView2OpenFailedMessage=无法自动打开下载页é¢ã€‚ +english.WebView2OpenFailedAction=Please open this URL manually: +chinesesimplified.WebView2OpenFailedAction=请手动打开以下链接: +english.UpgradePageCaption=Upgrade Existing Installation +chinesesimplified.UpgradePageCaption=å‡çº§çŽ°æœ‰å®‰è£… +english.UpgradePageDescription=LanMountainDesktop is already installed on this computer. Choose how to upgrade it. +chinesesimplified.UpgradePageDescription=此计算机上已安装 LanMountainDesktop。请选择å‡çº§æ–¹å¼ã€‚ +english.UpgradeDetectedVersionLabel=Detected version: +chinesesimplified.UpgradeDetectedVersionLabel=检测到的版本: +english.UpgradeCurrentLocationLabel=Current location: +chinesesimplified.UpgradeCurrentLocationLabel=当å‰å®‰è£…ä½ç½®ï¼š +english.UpgradePageSubCaption=Choose "Upgrade existing installation" to reuse the current location, or choose "Change installation location and migrate installation" to move the app without leaving a duplicate copy behind. +chinesesimplified.UpgradePageSubCaption=选择“å‡çº§çŽ°æœ‰å®‰è£…â€å¯å¤ç”¨å½“å‰å®‰è£…ä½ç½®ï¼›é€‰æ‹©â€œæ›´æ”¹å®‰è£…ä½ç½®å¹¶è¿ç§»å®‰è£…â€å¯ç§»åŠ¨åº”ç”¨ï¼ŒåŒæ—¶é¿å…留下é‡å¤å®‰è£…。 +english.UpgradeOptionInPlace=Upgrade existing installation +chinesesimplified.UpgradeOptionInPlace=å‡çº§çŽ°æœ‰å®‰è£… +english.UpgradeOptionRelocate=Change installation location and migrate installation +chinesesimplified.UpgradeOptionRelocate=更改安装ä½ç½®å¹¶è¿ç§»å®‰è£… +english.UpgradeUnknownVersion=Unknown +chinesesimplified.UpgradeUnknownVersion=未知 +english.UpgradeCleanupMissingUninstaller=Setup found an existing installation, but its uninstaller is unavailable. Please uninstall the current version manually and run this installer again. +chinesesimplified.UpgradeCleanupMissingUninstaller=安装程åºå‘现了现有安装,但无法找到它的å¸è½½ç¨‹åºã€‚请先手动å¸è½½å½“å‰ç‰ˆæœ¬ï¼Œå†é‡æ–°è¿è¡Œæ­¤å®‰è£…程åºã€‚ +english.UpgradeCleanupFailedPrefix=Setup could not remove the existing installation automatically. Error code: +chinesesimplified.UpgradeCleanupFailedPrefix=å®‰è£…ç¨‹åºæ— æ³•自动移除现有安装。错误代ç ï¼š +english.UpgradeCleanupFailedSuffix=Please close LanMountainDesktop, uninstall the current version manually, and then run this installer again. +chinesesimplified.UpgradeCleanupFailedSuffix=请关闭 LanMountainDesktop,手动å¸è½½å½“å‰ç‰ˆæœ¬ï¼Œç„¶åŽé‡æ–°è¿è¡Œæ­¤å®‰è£…程åºã€‚ [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" -Name: "startup"; Description: "Launch LanMountainDesktop when you sign in to Windows"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "startup"; Description: "{cm:StartupTaskDescription}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Dirs] +Name: "{app}\log"; Permissions: users-modify + +[InstallDelete] +Type: files; Name: "{app}\LanMontainDesktop.exe" +Type: files; Name: "{app}\LanMontainDesktop.dll" +Type: files; Name: "{app}\LanMontainDesktop.deps.json" +Type: files; Name: "{app}\LanMontainDesktop.runtimeconfig.json" +Type: files; Name: "{app}\LanMontainDesktop.pdb" [Files] Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs @@ -67,12 +120,309 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChang [Code] const + UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1'; WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'; WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703'; + UpgradeChoiceInPlace = 0; + UpgradeChoiceRelocate = 1; + +var + UpgradeModePage: TInputOptionWizardPage; + ExistingInstallFound: Boolean; + ExistingInstallPath: String; + ExistingInstallVersion: String; + ExistingUninstallCommand: String; + ExistingInstallWas64Bit: Boolean; + ExistingInstallIsPerUser: Boolean; + ExistingInstallRemoved: Boolean; + +function NormalizePathValue(const Value: String): String; +begin + Result := RemoveBackslashUnlessRoot(Trim(Value)); +end; + +function CombinePath(const BasePath: String; const ChildName: String): String; +begin + if BasePath = '' then + begin + Result := ChildName; + end + else + begin + Result := NormalizePathValue(BasePath) + '\' + ChildName; + end; +end; + +function SamePath(const LeftPath: String; const RightPath: String): Boolean; +begin + Result := CompareText(NormalizePathValue(LeftPath), NormalizePathValue(RightPath)) = 0; +end; + +function ExtractExecutableFromCommand(const CommandLine: String): String; +var + CommandText: String; + ClosingQuotePos: Integer; + ExePos: Integer; +begin + Result := ''; + CommandText := Trim(CommandLine); + if CommandText = '' then + begin + exit; + end; + + if CommandText[1] = '"' then + begin + Delete(CommandText, 1, 1); + ClosingQuotePos := Pos('"', CommandText); + if ClosingQuotePos > 0 then + begin + Result := Copy(CommandText, 1, ClosingQuotePos - 1); + end + else + begin + Result := CommandText; + end; + end + else + begin + ExePos := Pos('.exe', LowerCase(CommandText)); + if ExePos > 0 then + begin + Result := Copy(CommandText, 1, ExePos + 3); + end + else + begin + Result := CommandText; + end; + end; + + Result := NormalizePathValue(RemoveQuotes(Result)); +end; + +function GetExistingExecutablePath(): String; +begin + if ExistingInstallPath = '' then + begin + Result := ''; + end + else + begin + Result := CombinePath(ExistingInstallPath, '{#MyAppExeName}'); + end; +end; + +function GetDefaultInstallPath(): String; +begin + Result := NormalizePathValue(ExpandConstant('{autopf}\{#MyAppName}')); +end; + +function GetExistingInstallVersionText(): String; +begin + Result := Trim(ExistingInstallVersion); + if Result = '' then + begin + Result := CustomMessage('UpgradeUnknownVersion'); + end; +end; + +procedure ShowUpgradeCleanupError(const MessageText: String); +begin + Log(MessageText); + if not WizardSilent then + begin + MsgBox(MessageText, mbError, MB_OK); + end; +end; + +function TryLoadExistingInstallation( + const RootKey: Integer; + const Is64BitView: Boolean; + const IsPerUser: Boolean): Boolean; +var + InstallLocation: String; + AppPath: String; + UninstallString: String; + DisplayVersion: String; + ResolvedPath: String; +begin + Result := False; + InstallLocation := ''; + AppPath := ''; + UninstallString := ''; + DisplayVersion := ''; + + if not RegKeyExists(RootKey, UninstallRegSubkey) then + begin + exit; + end; + + RegQueryStringValue(RootKey, UninstallRegSubkey, 'InstallLocation', InstallLocation); + RegQueryStringValue(RootKey, UninstallRegSubkey, 'Inno Setup: App Path', AppPath); + RegQueryStringValue(RootKey, UninstallRegSubkey, 'UninstallString', UninstallString); + RegQueryStringValue(RootKey, UninstallRegSubkey, 'DisplayVersion', DisplayVersion); + + ResolvedPath := NormalizePathValue(InstallLocation); + if ResolvedPath = '' then + begin + ResolvedPath := NormalizePathValue(AppPath); + end; + if (ResolvedPath = '') and (UninstallString <> '') then + begin + ResolvedPath := NormalizePathValue(ExtractFileDir(ExtractExecutableFromCommand(UninstallString))); + end; + + if (ResolvedPath = '') or + (not DirExists(ResolvedPath)) or + (not FileExists(CombinePath(ResolvedPath, '{#MyAppExeName}'))) then + begin + exit; + end; + + ExistingInstallFound := True; + ExistingInstallPath := ResolvedPath; + ExistingInstallVersion := Trim(DisplayVersion); + ExistingUninstallCommand := Trim(UninstallString); + ExistingInstallWas64Bit := Is64BitView; + ExistingInstallIsPerUser := IsPerUser; + Result := True; +end; + +procedure DetectExistingInstallation; +begin + ExistingInstallFound := False; + ExistingInstallPath := ''; + ExistingInstallVersion := ''; + ExistingUninstallCommand := ''; + ExistingInstallWas64Bit := False; + ExistingInstallIsPerUser := False; + ExistingInstallRemoved := False; + + if IsWin64 then + begin + if TryLoadExistingInstallation(HKLM64, True, False) then + begin + exit; + end; + if TryLoadExistingInstallation(HKCU64, True, True) then + begin + exit; + end; + end; + + if TryLoadExistingInstallation(HKLM32, False, False) then + begin + exit; + end; + + TryLoadExistingInstallation(HKCU32, False, True); +end; + +function SelectedUpgradeChoice(): Integer; +begin + if UpgradeModePage <> nil then + begin + Result := UpgradeModePage.SelectedValueIndex; + end + else + begin + Result := UpgradeChoiceInPlace; + end; +end; + +procedure ApplySelectedInstallDirectory; +var + CurrentDir: String; +begin + if not ExistingInstallFound then + begin + exit; + end; + + if SelectedUpgradeChoice() = UpgradeChoiceInPlace then + begin + WizardForm.DirEdit.Text := ExistingInstallPath; + exit; + end; + + CurrentDir := NormalizePathValue(WizardDirValue); + if (CurrentDir = '') or SamePath(CurrentDir, GetDefaultInstallPath()) then + begin + WizardForm.DirEdit.Text := ExistingInstallPath; + end; +end; + +function GetSelectedInstallPath(): String; +begin + Result := NormalizePathValue(ExpandConstant('{app}')); + if Result = '' then + begin + Result := NormalizePathValue(WizardDirValue); + end; + if Result = '' then + begin + Result := ExistingInstallPath; + end; +end; + +function ExistingInstallRequiresCleanup(): Boolean; +var + TargetPath: String; +begin + Result := False; + if not ExistingInstallFound or ExistingInstallRemoved then + begin + exit; + end; + + TargetPath := GetSelectedInstallPath(); + Result := ExistingInstallIsPerUser or + (not SamePath(TargetPath, ExistingInstallPath)) or + (ExistingInstallWas64Bit <> Is64BitInstallMode); +end; + +function RemoveExistingInstallation(): Boolean; +var + UninstallerPath: String; + ResultCode: Integer; +begin + Result := True; + + if not ExistingInstallRequiresCleanup() then + begin + exit; + end; + + UninstallerPath := ExtractExecutableFromCommand(ExistingUninstallCommand); + if (UninstallerPath = '') or (not FileExists(UninstallerPath)) then + begin + ShowUpgradeCleanupError(CustomMessage('UpgradeCleanupMissingUninstaller')); + Result := False; + exit; + end; + + ResultCode := -1; + if not Exec( + UninstallerPath, + '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART', + ExtractFileDir(UninstallerPath), + SW_SHOWNORMAL, + ewWaitUntilTerminated, + ResultCode) or (ResultCode <> 0) then + begin + ShowUpgradeCleanupError( + CustomMessage('UpgradeCleanupFailedPrefix') + ' ' + IntToStr(ResultCode) + '. ' + + CustomMessage('UpgradeCleanupFailedSuffix')); + Result := False; + exit; + end; + + ExistingInstallRemoved := True; +end; function IsWebView2RuntimeInstalled(): Boolean; var - VersionValue: string; + VersionValue: String; begin Result := RegQueryStringValue(HKLM64, WebView2RuntimeKeyPath, 'pv', VersionValue) or @@ -92,16 +442,16 @@ begin end; if MsgBox( - 'Microsoft Edge WebView2 Runtime is required for the browser component.'#13#10#13#10 + - 'Click "Yes" to open the official download page. Install it first, then run this installer again.', + CustomMessage('WebView2MissingMessage') + #13#10#13#10 + + CustomMessage('WebView2MissingAction'), mbConfirmation, MB_YESNO) = IDYES then begin if not ShellExec('open', WebView2RuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then begin MsgBox( - 'Unable to open the download page automatically.'#13#10 + - 'Please open this URL manually:'#13#10 + WebView2RuntimeDownloadUrl, + CustomMessage('WebView2OpenFailedMessage') + #13#10 + + CustomMessage('WebView2OpenFailedAction') + #13#10 + WebView2RuntimeDownloadUrl, mbError, MB_OK); end; @@ -109,3 +459,84 @@ begin Result := False; end; + +procedure InitializeWizard; +var + DetailsText: String; +begin + DetectExistingInstallation; + + if not ExistingInstallFound then + begin + exit; + end; + + DetailsText := + CustomMessage('UpgradeDetectedVersionLabel') + ' ' + GetExistingInstallVersionText() + #13#10 + + CustomMessage('UpgradeCurrentLocationLabel') + ' ' + ExistingInstallPath + #13#10#13#10 + + CustomMessage('UpgradePageSubCaption'); + + UpgradeModePage := CreateInputOptionPage( + wpWelcome, + CustomMessage('UpgradePageCaption'), + CustomMessage('UpgradePageDescription'), + DetailsText, + True, + False); + UpgradeModePage.Add(CustomMessage('UpgradeOptionInPlace')); + UpgradeModePage.Add(CustomMessage('UpgradeOptionRelocate')); + UpgradeModePage.SelectedValueIndex := UpgradeChoiceInPlace; +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + + if (UpgradeModePage <> nil) and (CurPageID = UpgradeModePage.ID) then + begin + ApplySelectedInstallDirectory; + end; +end; + +function ShouldSkipPage(PageID: Integer): Boolean; +begin + Result := False; + + if (UpgradeModePage <> nil) and (PageID = UpgradeModePage.ID) then + begin + Result := not ExistingInstallFound; + exit; + end; + + if PageID = wpSelectDir then + begin + Result := ExistingInstallFound and (SelectedUpgradeChoice() = UpgradeChoiceInPlace); + end; +end; + +procedure RegisterExtraCloseApplicationsResources; +var + ExistingExecutablePath: String; +begin + if not ExistingInstallFound then + begin + exit; + end; + + ExistingExecutablePath := GetExistingExecutablePath(); + if (ExistingExecutablePath <> '') and FileExists(ExistingExecutablePath) then + begin + RegisterExtraCloseApplicationsResource(False, ExistingExecutablePath); + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssInstall then + begin + if not RemoveExistingInstallation() then + begin + Abort; + end; + end; +end; diff --git a/LanMountainDesktop/plugins/MainWindow.PluginSettingsControls.cs b/LanMountainDesktop/plugins/MainWindow.PluginSettingsControls.cs index 0eead31..71c86ca 100644 --- a/LanMountainDesktop/plugins/MainWindow.PluginSettingsControls.cs +++ b/LanMountainDesktop/plugins/MainWindow.PluginSettingsControls.cs @@ -9,6 +9,7 @@ public partial class MainWindow internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl("PluginSystemDescriptionTextBlock")!; internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl("PluginSystemStatusTextBlock")!; internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl("InstalledPluginsSettingsExpander")!; + internal FluentAvalonia.UI.Controls.SettingsExpander ImportPluginPackageSettingsExpander => PluginSettingsPanel.FindControl("ImportPluginPackageSettingsExpander")!; internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl("PluginRestartHintTextBlock")!; internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl("PluginCatalogEmptyTextBlock")!; } diff --git a/LanMountainDesktop/plugins/MainWindow.PluginSettingsLocalization.cs b/LanMountainDesktop/plugins/MainWindow.PluginSettingsLocalization.cs index 16d7975..ad5d84c 100644 --- a/LanMountainDesktop/plugins/MainWindow.PluginSettingsLocalization.cs +++ b/LanMountainDesktop/plugins/MainWindow.PluginSettingsLocalization.cs @@ -18,10 +18,14 @@ public partial class MainWindow InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins"); InstalledPluginsSettingsExpander.Description = L( "settings.plugins.installed_desc", - "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages."); + "Review installed plugins and remove them here."); + ImportPluginPackageSettingsExpander.Header = L("settings.plugins.import_header", "Install From Package"); + ImportPluginPackageSettingsExpander.Description = L( + "settings.plugins.import_desc", + "Open a .laapp package and stage it into the local plugin directory."); PluginRestartHintTextBlock.Text = L( "settings.plugins.restart_hint", - "Plugin enable state changes take effect after restarting the app."); + "Plugin installation and deletion changes take effect after restarting the app."); PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found."); PluginSettingsPanel.RefreshFromRuntime(); } diff --git a/LanMountainDesktop/plugins/PluginLoader.cs b/LanMountainDesktop/plugins/PluginLoader.cs index 2022423..ab9bfc1 100644 --- a/LanMountainDesktop/plugins/PluginLoader.cs +++ b/LanMountainDesktop/plugins/PluginLoader.cs @@ -10,6 +10,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using LanMountainDesktop.Services; using LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.Plugins; @@ -346,6 +347,9 @@ public sealed class PluginLoader private string ExtractPackage(string packagePath, string pluginsRootDirectory) { var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath); + AppLogger.Info( + "PluginLoader", + $"Extracting package '{packagePath}' to '{extractionDirectory}'."); RecreateDirectory(extractionDirectory); ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true); return extractionDirectory; @@ -381,7 +385,7 @@ public sealed class PluginLoader { if (Directory.Exists(directoryPath)) { - Directory.Delete(directoryPath, recursive: true); + FileOperationRetryHelper.DeleteDirectoryWithRetry(directoryPath, recursive: true, "PluginLoader"); } Directory.CreateDirectory(directoryPath); diff --git a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs b/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs index 781e17e..ab6ab4e 100644 --- a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs +++ b/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs @@ -61,7 +61,10 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable public PluginMarketEmbeddedView(PluginRuntimeService runtime) { _runtime = runtime; - var dataDirectory = Path.Combine(AppContext.BaseDirectory, "Data", "AirAppMarket"); + var dataDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop", + "AirAppMarket"); _indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory)); _installService = new AirAppMarketInstallService(runtime, dataDirectory); _readmeService = new AirAppMarketReadmeService(); diff --git a/LanMountainDesktop/plugins/PluginMarketInstallService.cs b/LanMountainDesktop/plugins/PluginMarketInstallService.cs index 271dfa9..273fe51 100644 --- a/LanMountainDesktop/plugins/PluginMarketInstallService.cs +++ b/LanMountainDesktop/plugins/PluginMarketInstallService.cs @@ -44,7 +44,13 @@ internal sealed class AirAppMarketInstallService : IDisposable try { + AppLogger.Info( + "PluginMarket", + $"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'."); var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken); + AppLogger.Info( + "PluginMarket", + $"Resolved download url for '{plugin.Id}' to '{resolvedDownloadUrl}'."); if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath)) { @@ -84,14 +90,24 @@ internal sealed class AirAppMarketInstallService : IDisposable } var manifest = _runtime.InstallPluginPackage(downloadPath); + AppLogger.Info( + "PluginMarket", + $"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'."); return new AirAppMarketInstallResult(true, manifest, null); } catch (OperationCanceledException) { + AppLogger.Warn( + "PluginMarket", + $"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'."); throw; } catch (Exception ex) { + AppLogger.Error( + "PluginMarket", + $"Install failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.", + ex); return new AirAppMarketInstallResult(false, null, ex.Message); } } diff --git a/LanMountainDesktop/plugins/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs index 19edfdc..2e26c93 100644 --- a/LanMountainDesktop/plugins/PluginRuntimeService.cs +++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs @@ -5,6 +5,7 @@ using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; +using System.Text.Json; using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; @@ -16,6 +17,8 @@ namespace LanMountainDesktop.Services; public sealed class PluginRuntimeService : IDisposable { + private const string PendingDeletionFileName = ".pending-plugin-deletions.json"; + private readonly PluginLoader _loader; private readonly AppSettingsService _appSettingsService = new(); private readonly IServiceProvider _hostServices; @@ -25,6 +28,7 @@ public sealed class PluginRuntimeService : IDisposable private readonly List _catalog = []; private readonly List _settingsPages = []; private readonly List _desktopComponents = []; + private readonly object _packageMutationGate = new(); public PluginRuntimeService() { @@ -49,6 +53,7 @@ public sealed class PluginRuntimeService : IDisposable public void LoadInstalledPlugins() { Directory.CreateDirectory(PluginsDirectory); + ApplyPendingPluginDeletions(); UnloadInstalledPlugins(); var disabledPluginIds = GetDisabledPluginIds(); @@ -170,6 +175,7 @@ public sealed class PluginRuntimeService : IDisposable .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) .ToList(); _appSettingsService.Save(snapshot); + PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true); for (var i = 0; i < _catalog.Count; i++) { @@ -184,7 +190,50 @@ public sealed class PluginRuntimeService : IDisposable public PluginManifest InstallPluginPackage(string packagePath) { - return InstallPluginPackageCore(packagePath).Manifest; + lock (_packageMutationGate) + { + return InstallPluginPackageCore(packagePath).Manifest; + } + } + + public bool DeleteInstalledPlugin(string pluginId) + { + lock (_packageMutationGate) + { + return DeleteInstalledPluginCore(pluginId); + } + } + + private bool DeleteInstalledPluginCore(string pluginId) + { + if (string.IsNullOrWhiteSpace(pluginId)) + { + return false; + } + + var entry = _catalog.FirstOrDefault(candidate => + string.Equals(candidate.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase)); + if (entry is null) + { + return false; + } + + var targetPath = ResolvePluginRemovalTargetPath(entry); + if (string.IsNullOrWhiteSpace(targetPath)) + { + return false; + } + + var fullTargetPath = Path.GetFullPath(targetPath); + if (!TryDeletePluginTarget(fullTargetPath)) + { + RegisterPendingPluginDeletion(fullTargetPath); + } + + RemovePluginFromSnapshot(pluginId); + RemovePluginFromCatalog(pluginId); + PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true); + return true; } internal IReadOnlyList GetInstalledPluginsSnapshot() @@ -219,16 +268,22 @@ public sealed class PluginRuntimeService : IDisposable Directory.CreateDirectory(PluginsDirectory); var manifest = ReadManifestFromPackage(fullPackagePath); + AppLogger.Info( + "PluginRuntime", + $"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'."); var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath); var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id)); if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase)) { - File.Copy(fullPackagePath, destinationPath, overwrite: true); + FileOperationRetryHelper.CopyWithRetry(fullPackagePath, destinationPath, overwrite: true, "PluginRuntime"); } UpdateCatalogAfterPackageInstall(manifest, destinationPath); PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true); + AppLogger.Info( + "PluginRuntime", + $"Package staged. PluginId='{manifest.Id}'; Destination='{destinationPath}'; ReplacedExisting={replacedExisting}."); return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true); } @@ -349,7 +404,7 @@ public sealed class PluginRuntimeService : IDisposable continue; } - File.Delete(existingPackagePath); + FileOperationRetryHelper.DeleteFileWithRetry(existingPackagePath, "PluginRuntime"); replacedExisting = true; } catch @@ -445,6 +500,150 @@ public sealed class PluginRuntimeService : IDisposable } } + private void ApplyPendingPluginDeletions() + { + var pendingPaths = ReadPendingPluginDeletions(); + if (pendingPaths.Count == 0) + { + return; + } + + var remainingPaths = new List(); + foreach (var path in pendingPaths) + { + if (!TryDeletePluginTarget(path)) + { + remainingPaths.Add(path); + } + } + + SavePendingPluginDeletions(remainingPaths); + } + + private string ResolvePluginRemovalTargetPath(PluginCatalogEntry entry) + { + if (entry.IsPackage) + { + return entry.SourcePath; + } + + var fullSourcePath = Path.GetFullPath(entry.SourcePath); + if (File.Exists(fullSourcePath) && + string.Equals(Path.GetFileName(fullSourcePath), "plugin.json", StringComparison.OrdinalIgnoreCase)) + { + return Path.GetDirectoryName(fullSourcePath) ?? fullSourcePath; + } + + return fullSourcePath; + } + + private static bool TryDeletePluginTarget(string targetPath) + { + try + { + if (File.Exists(targetPath)) + { + File.Delete(targetPath); + } + else if (Directory.Exists(targetPath)) + { + Directory.Delete(targetPath, recursive: true); + } + + return !File.Exists(targetPath) && !Directory.Exists(targetPath); + } + catch + { + return false; + } + } + + private void RegisterPendingPluginDeletion(string targetPath) + { + var pendingPaths = ReadPendingPluginDeletions(); + if (pendingPaths.Contains(targetPath, StringComparer.OrdinalIgnoreCase)) + { + return; + } + + pendingPaths.Add(targetPath); + SavePendingPluginDeletions(pendingPaths); + } + + private List ReadPendingPluginDeletions() + { + var pendingDeletionFilePath = GetPendingDeletionFilePath(); + if (!File.Exists(pendingDeletionFilePath)) + { + return []; + } + + try + { + var json = File.ReadAllText(pendingDeletionFilePath); + var paths = JsonSerializer.Deserialize>(json); + return paths? + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() ?? []; + } + catch + { + return []; + } + } + + private void SavePendingPluginDeletions(IEnumerable pendingPaths) + { + var pendingDeletionFilePath = GetPendingDeletionFilePath(); + var normalizedPaths = pendingPaths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (normalizedPaths.Length == 0) + { + if (File.Exists(pendingDeletionFilePath)) + { + File.Delete(pendingDeletionFilePath); + } + + return; + } + + Directory.CreateDirectory(PluginsDirectory); + var json = JsonSerializer.Serialize(normalizedPaths, new JsonSerializerOptions + { + WriteIndented = true + }); + File.WriteAllText(pendingDeletionFilePath, json); + } + + private string GetPendingDeletionFilePath() + { + return Path.Combine(PluginsDirectory, PendingDeletionFileName); + } + + private void RemovePluginFromSnapshot(string pluginId) + { + var snapshot = _appSettingsService.Load(); + if (snapshot.DisabledPluginIds.RemoveAll(id => string.Equals(id, pluginId, StringComparison.OrdinalIgnoreCase)) > 0) + { + _appSettingsService.Save(snapshot); + } + } + + private void RemovePluginFromCatalog(string pluginId) + { + _catalog.RemoveAll(entry => string.Equals(entry.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase)); + _settingsPages.RemoveAll(entry => string.Equals(entry.Plugin.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase)); + _desktopComponents.RemoveAll(entry => string.Equals(entry.Plugin.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase)); + _loadResults.RemoveAll(entry => string.Equals(entry.Manifest?.Id, pluginId, StringComparison.OrdinalIgnoreCase)); + } + private sealed record PluginCandidate( string SourcePath, PluginManifest Manifest, diff --git a/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs b/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs index bfeffe2..71684fe 100644 --- a/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs +++ b/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs @@ -8,6 +8,8 @@ using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; +using FluentIcons.Avalonia.Fluent; +using FluentIcons.Common; using FluentAvalonia.UI.Controls; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; @@ -18,6 +20,7 @@ public partial class PluginSettingsPage : UserControl { private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E")); private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C")); + private static readonly IBrush DestructiveBrush = new SolidColorBrush(Color.Parse("#FFF87171")); private readonly AppSettingsService _appSettingsService = new(); private readonly LocalizationService _localizationService = new(); @@ -38,8 +41,9 @@ public partial class PluginSettingsPage : UserControl { PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available."); PluginRuntimeSummaryPanel.Children.Clear(); - PluginCatalogItemsHost.Children.Clear(); + InstalledPluginsSettingsExpander.Items.Clear(); PluginRestartHintTextBlock.IsVisible = false; + PluginCatalogEmptyTextBlock.IsVisible = false; return; } @@ -74,7 +78,7 @@ public partial class PluginSettingsPage : UserControl "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.", runtime.Catalog.Count, enabledCount, - runtime.LoadedPlugins.Count, + runtime.Catalog.Count(entry => entry.IsLoaded), runtime.SettingsPages.Count, runtime.DesktopComponents.Count, failures.Length); @@ -99,7 +103,7 @@ public partial class PluginSettingsPage : UserControl private void BuildPluginCatalog(PluginRuntimeService runtime) { - PluginCatalogItemsHost.Children.Clear(); + InstalledPluginsSettingsExpander.Items.Clear(); var plugins = runtime.Catalog .OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase) @@ -110,86 +114,20 @@ public partial class PluginSettingsPage : UserControl foreach (var plugin in plugins) { - PluginCatalogItemsHost.Children.Add(CreatePluginCatalogItem(runtime, plugin)); + InstalledPluginsSettingsExpander.Items.Add(CreatePluginCatalogItem(runtime, plugin)); } } - private Control CreatePluginCatalogItem(PluginRuntimeService runtime, PluginCatalogEntry entry) + private SettingsExpanderItem CreatePluginCatalogItem(PluginRuntimeService runtime, PluginCatalogEntry entry) { - var title = new TextBlock + return new SettingsExpanderItem { - Text = entry.Manifest.Name, - FontSize = 16, - FontWeight = FontWeight.SemiBold, - TextWrapping = TextWrapping.Wrap + Content = entry.Manifest.Name, + Description = BuildPluginSubtitle(entry), + IconSource = CreatePluginCatalogIconSource(), + IsClickEnabled = false, + Footer = CreatePluginCatalogActions(runtime, entry) }; - - var subtitle = new TextBlock - { - Text = BuildPluginSubtitle(entry), - Foreground = PluginSystemDescriptionTextBlock.Foreground, - TextWrapping = TextWrapping.Wrap - }; - - var enabledToggle = new ToggleSwitch - { - IsChecked = entry.IsEnabled, - OnContent = L("settings.plugins.toggle_on", "Enabled"), - OffContent = L("settings.plugins.toggle_off", "Disabled"), - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center - }; - - enabledToggle.IsCheckedChanged += (_, _) => OnPluginEnableChanged(runtime, entry, enabledToggle.IsChecked == true); - - var header = new Grid - { - ColumnDefinitions = new ColumnDefinitions("*,Auto"), - ColumnSpacing = 12, - Children = - { - new StackPanel - { - Spacing = 4, - Children = { title, subtitle } - }, - enabledToggle - } - }; - Grid.SetColumn(enabledToggle, 1); - - var details = new TextBlock - { - Text = BuildPluginDetails(entry), - Foreground = PluginSystemDescriptionTextBlock.Foreground, - TextWrapping = TextWrapping.Wrap - }; - - return new Border - { - Background = new SolidColorBrush(Color.Parse("#14000000")), - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14), - Child = new StackPanel - { - Spacing = 10, - Children = { header, details } - } - }; - } - - private void OnPluginEnableChanged(PluginRuntimeService runtime, PluginCatalogEntry entry, bool isEnabled) - { - runtime.SetPluginEnabled(entry.Manifest.Id, isEnabled); - BuildRuntimeSummary(runtime); - BuildPluginCatalog(runtime); - PluginSystemStatusTextBlock.Text = F( - "settings.plugins.toggle_result_format", - "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.", - entry.Manifest.Name, - isEnabled - ? L("settings.plugins.toggle_state_enabled", "enabled") - : L("settings.plugins.toggle_state_disabled", "disabled")); } private async void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) @@ -247,6 +185,7 @@ public partial class PluginSettingsPage : UserControl var manifest = runtime.InstallPluginPackage(temporaryPackagePath); RefreshFromRuntime(); + RefreshPluginNavigation(TopLevel.GetTopLevel(this)); SetPackageImportStatus( F( "settings.plugins.install_success_format", @@ -279,6 +218,79 @@ public partial class PluginSettingsPage : UserControl } } + private void OnDeletePluginClick(PluginRuntimeService runtime, PluginCatalogEntry entry) + { + try + { + if (!runtime.DeleteInstalledPlugin(entry.Manifest.Id)) + { + SetPackageImportStatus( + F( + "settings.plugins.delete_failed_format", + "Failed to delete plugin: {0}", + entry.Manifest.Name), + isError: true); + return; + } + + RefreshFromRuntime(); + RefreshPluginNavigation(TopLevel.GetTopLevel(this)); + PluginSystemStatusTextBlock.Text = F( + "settings.plugins.delete_success_format", + "Plugin '{0}' was staged for deletion. Restart the app to finish removing it.", + entry.Manifest.Name); + SetPackageImportStatus( + F( + "settings.plugins.delete_success_format", + "Plugin '{0}' was staged for deletion. Restart the app to finish removing it.", + entry.Manifest.Name), + isError: false); + } + catch (Exception ex) + { + SetPackageImportStatus( + F( + "settings.plugins.delete_failed_detail_format", + "Failed to delete plugin '{0}': {1}", + entry.Manifest.Name, + ex.Message), + isError: true); + } + } + + private void OnPluginEnabledChanged(PluginRuntimeService runtime, PluginCatalogEntry entry, bool isEnabled) + { + try + { + if (!runtime.SetPluginEnabled(entry.Manifest.Id, isEnabled)) + { + return; + } + + RefreshFromRuntime(); + var toggleState = isEnabled + ? L("settings.plugins.toggle_state_enabled", "enabled") + : L("settings.plugins.toggle_state_disabled", "disabled"); + SetPackageImportStatus( + F( + "settings.plugins.toggle_result_format", + "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.", + entry.Manifest.Name, + toggleState), + isError: false); + } + catch (Exception ex) + { + SetPackageImportStatus( + F( + "settings.plugins.toggle_failed_detail_format", + "Failed to update plugin '{0}': {1}", + entry.Manifest.Name, + ex.Message), + isError: true); + } + } + private void RefreshPluginNavigation(TopLevel? topLevel) { switch (topLevel) @@ -301,32 +313,13 @@ public partial class PluginSettingsPage : UserControl private string BuildPluginSubtitle(PluginCatalogEntry entry) { - var source = entry.IsPackage - ? L("settings.plugins.source_package", ".laapp package") - : L("settings.plugins.source_manifest", "Loose manifest"); - var state = entry.IsEnabled - ? entry.IsLoaded - ? L("settings.plugins.state.loaded", "Loaded") - : L("settings.plugins.state.load_failed", "Load failed") - : L("settings.plugins.state.disabled", "Disabled"); + var publisher = string.IsNullOrWhiteSpace(entry.Manifest.Author) + ? L("settings.plugins.publisher_unknown", "Unknown publisher") + : entry.Manifest.Author; return F( - "settings.plugins.subtitle_format", - "{0} | {1} | {2}", - state, - source, - entry.Manifest.Id); - } - - private string BuildPluginDetails(PluginCatalogEntry entry) - { - var detail = F( - "settings.plugins.detail_format", - "Settings pages: {0} | Widgets: {1}", - entry.SettingsPageCount, - entry.WidgetCount); - return string.IsNullOrWhiteSpace(entry.ErrorMessage) - ? detail - : detail + Environment.NewLine + entry.ErrorMessage; + "settings.plugins.publisher_format", + "Publisher: {0}", + publisher); } private TextBlock CreateSummaryLine(string text) @@ -350,6 +343,75 @@ public partial class PluginSettingsPage : UserControl return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args); } + private FluentIcons.Avalonia.Fluent.SymbolIconSource CreatePluginCatalogIconSource() + { + return new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = FluentIcons.Common.Symbol.PuzzlePiece, + IconVariant = FluentIcons.Common.IconVariant.Regular + }; + } + + private Control CreatePluginCatalogActions(PluginRuntimeService runtime, PluginCatalogEntry entry) + { + return new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 10, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + CreateEnablePluginToggle(runtime, entry), + CreateDeletePluginButton(runtime, entry) + } + }; + } + + private ToggleSwitch CreateEnablePluginToggle(PluginRuntimeService runtime, PluginCatalogEntry entry) + { + var toggle = new ToggleSwitch + { + IsChecked = entry.IsEnabled, + VerticalAlignment = VerticalAlignment.Center + }; + + ToolTip.SetTip( + toggle, + entry.IsEnabled + ? L("settings.plugins.toggle_off", "Disable") + : L("settings.plugins.toggle_on", "Enable")); + toggle.IsCheckedChanged += (_, _) => OnPluginEnabledChanged(runtime, entry, toggle.IsChecked == true); + return toggle; + } + + private Button CreateDeletePluginButton(PluginRuntimeService runtime, PluginCatalogEntry entry) + { + var button = new Button + { + Width = 36, + Height = 36, + Padding = new Thickness(0), + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + Content = new FluentIcons.Avalonia.Fluent.SymbolIcon + { + Symbol = FluentIcons.Common.Symbol.Delete, + IconVariant = FluentIcons.Common.IconVariant.Regular, + FontSize = 18, + Foreground = DestructiveBrush, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + }; + + ToolTip.SetTip(button, L("settings.plugins.delete_button", "Delete plugin")); + button.Click += (_, _) => OnDeletePluginClick(runtime, entry); + return button; + } + private static async Task CopyPackageToTemporaryFileAsync(IStorageFile file) { try diff --git a/LanMountainDesktop/plugins/PluginSettingsPage.axaml b/LanMountainDesktop/plugins/PluginSettingsPage.axaml index c2de80a..caefe78 100644 --- a/LanMountainDesktop/plugins/PluginSettingsPage.axaml +++ b/LanMountainDesktop/plugins/PluginSettingsPage.axaml @@ -44,31 +44,13 @@ - - -