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 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsControls.cs b/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsControls.cs
index c24e534..a438fe2 100644
--- a/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsControls.cs
+++ b/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsControls.cs
@@ -9,6 +9,7 @@ public partial class SettingsWindow
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/SettingsWindow.PluginSettingsLocalization.cs b/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsLocalization.cs
index 707de45..2704bd0 100644
--- a/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsLocalization.cs
+++ b/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsLocalization.cs
@@ -10,8 +10,10 @@ public partial class SettingsWindow
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
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.");
- PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
+ InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "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 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/scripts/package.ps1 b/LanMountainDesktop/scripts/package.ps1
index 9e7e772..e01064c 100644
--- a/LanMountainDesktop/scripts/package.ps1
+++ b/LanMountainDesktop/scripts/package.ps1
@@ -141,6 +141,37 @@ function Create-PackageArchive {
return $archivePath
}
+function Clear-DirectoryContents {
+ param([Parameter(Mandatory = $true)][string]$TargetDirectory)
+
+ [System.IO.Directory]::CreateDirectory($TargetDirectory) | Out-Null
+ Get-ChildItem -LiteralPath $TargetDirectory -Force -ErrorAction SilentlyContinue | ForEach-Object {
+ Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop
+ }
+}
+
+function Remove-LegacyOutputArtifacts {
+ param([Parameter(Mandatory = $true)][string]$TargetDirectory)
+
+ $legacyArtifacts = @(
+ "LanMontainDesktop.exe",
+ "LanMontainDesktop.dll",
+ "LanMontainDesktop.deps.json",
+ "LanMontainDesktop.runtimeconfig.json",
+ "LanMontainDesktop.pdb"
+ )
+
+ foreach ($artifactName in $legacyArtifacts) {
+ $artifactPath = Join-Path $TargetDirectory $artifactName
+ if (-not (Test-Path -LiteralPath $artifactPath)) {
+ continue
+ }
+
+ Remove-Item -LiteralPath $artifactPath -Force -ErrorAction Stop
+ Write-Host "Removed legacy artifact: $artifactPath"
+ }
+}
+
function Add-LinuxDesktopAssets {
param(
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
@@ -187,7 +218,7 @@ if (-not $PublishDir) {
if (-not [System.IO.Path]::IsPathRooted($PublishDir)) {
$PublishDir = Join-Path $repoRoot $PublishDir
}
-[System.IO.Directory]::CreateDirectory($PublishDir) | Out-Null
+Clear-DirectoryContents -TargetDirectory $PublishDir
Write-Host "Publishing project..."
$publishArgs = @(
@@ -210,6 +241,7 @@ if ($LASTEXITCODE -ne 0) {
}
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
+Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir
if ($RuntimeIdentifier -like "linux-*") {
Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot