mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2781d7e0d9 | ||
|
|
5003ff1be2 | ||
|
|
e1be072b97 |
@@ -0,0 +1,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
177
LanMountainDesktop.PluginsInstallHelper/Program.cs
Normal file
177
LanMountainDesktop.PluginsInstallHelper/Program.cs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private static async Task<int> Main(string[] args)
|
||||||
|
{
|
||||||
|
var result = new HelperResult();
|
||||||
|
string? resultPath = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsedArgs = ParseArgs(args);
|
||||||
|
if (!parsedArgs.TryGetValue("source", out var sourcePath) ||
|
||||||
|
!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
|
||||||
|
!parsedArgs.TryGetValue("result", out resultPath) ||
|
||||||
|
string.IsNullOrWhiteSpace(sourcePath) ||
|
||||||
|
string.IsNullOrWhiteSpace(pluginsDirectory) ||
|
||||||
|
string.IsNullOrWhiteSpace(resultPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Required arguments: --source <path> --plugins-dir <path> --result <path>.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||||
|
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||||
|
resultPath = Path.GetFullPath(resultPath);
|
||||||
|
|
||||||
|
if (!File.Exists(fullSourcePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||||
|
Directory.CreateDirectory(fullPluginsDirectory);
|
||||||
|
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id);
|
||||||
|
|
||||||
|
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||||
|
File.Copy(fullSourcePath, destinationPath, overwrite: true);
|
||||||
|
|
||||||
|
result = new HelperResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
InstalledPackagePath = destinationPath,
|
||||||
|
ManifestId = manifest.Id,
|
||||||
|
ManifestName = manifest.Name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result = new HelperResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
ErrorMessage = ex.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(resultPath))
|
||||||
|
{
|
||||||
|
var resultDirectory = Path.GetDirectoryName(resultPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(resultDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(resultDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
resultPath,
|
||||||
|
JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
}),
|
||||||
|
Encoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Success ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
var current = args[i];
|
||||||
|
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = current[2..];
|
||||||
|
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
values[key] = args[++i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
||||||
|
{
|
||||||
|
using var archive = ZipFile.OpenRead(packagePath);
|
||||||
|
var entries = archive.Entries
|
||||||
|
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (entries.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.Length > 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = entries[0].Open();
|
||||||
|
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId)
|
||||||
|
{
|
||||||
|
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
|
||||||
|
foreach (var existingPackagePath in Directory
|
||||||
|
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||||
|
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(existingPackagePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore unrelated or malformed packages while replacing an install target.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildInstalledPackageFileName(string pluginId)
|
||||||
|
{
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||||
|
return fileName + PluginSdkInfo.PackageFileExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EnsureTrailingSeparator(string path)
|
||||||
|
{
|
||||||
|
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||||
|
? path
|
||||||
|
: path + Path.DirectorySeparatorChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HelperResult
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
public string? InstalledPackagePath { get; init; }
|
||||||
|
|
||||||
|
public string? ManifestId { get; init; }
|
||||||
|
|
||||||
|
public string? ManifestName { get; init; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginPa
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginsInstallHelper", "LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj", "{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -33,5 +35,9 @@ Global
|
|||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
|
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -16,23 +16,6 @@
|
|||||||
<local:ViewLocator/>
|
<local:ViewLocator/>
|
||||||
</Application.DataTemplates>
|
</Application.DataTemplates>
|
||||||
|
|
||||||
<TrayIcon.Icons>
|
|
||||||
<TrayIcons>
|
|
||||||
<TrayIcon Icon="/Assets/avalonia-logo.ico"
|
|
||||||
ToolTipText="LanMountainDesktop">
|
|
||||||
<TrayIcon.Menu>
|
|
||||||
<NativeMenu>
|
|
||||||
<NativeMenuItem Header="设置" Click="OnTraySettingsClick" />
|
|
||||||
<NativeMenuItemSeparator />
|
|
||||||
<NativeMenuItem Header="重启应用" Click="OnTrayRestartClick" />
|
|
||||||
<NativeMenuItemSeparator />
|
|
||||||
<NativeMenuItem Header="退出应用" Click="OnTrayExitClick" />
|
|
||||||
</NativeMenu>
|
|
||||||
</TrayIcon.Menu>
|
|
||||||
</TrayIcon>
|
|
||||||
</TrayIcons>
|
|
||||||
</TrayIcon.Icons>
|
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<sty:FluentAvaloniaTheme />
|
<sty:FluentAvaloniaTheme />
|
||||||
<mi:MaterialIconStyles />
|
<mi:MaterialIconStyles />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Data.Core;
|
using Avalonia.Data.Core;
|
||||||
using Avalonia.Data.Core.Plugins;
|
using Avalonia.Data.Core.Plugins;
|
||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Platform;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.ViewModels;
|
using LanMountainDesktop.ViewModels;
|
||||||
@@ -16,13 +17,18 @@ namespace LanMountainDesktop;
|
|||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
|
private readonly LocalizationService _localizationService = new();
|
||||||
|
|
||||||
private SettingsWindow? _traySettingsWindow;
|
private SettingsWindow? _traySettingsWindow;
|
||||||
|
private TrayIcons? _trayIcons;
|
||||||
private PluginRuntimeService? _pluginRuntimeService;
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
|
|
||||||
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
|
AppLogger.Info("App", "Initializing application resources.");
|
||||||
ConfigureWebViewUserDataFolder();
|
ConfigureWebViewUserDataFolder();
|
||||||
AvaloniaWebViewBuilder.Initialize(default);
|
AvaloniaWebViewBuilder.Initialize(default);
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
@@ -30,19 +36,29 @@ public partial class App : Application
|
|||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
|
AppLogger.Info("App", "Framework initialization completed.");
|
||||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||||
InitializePluginRuntime();
|
InitializePluginRuntime();
|
||||||
|
AppSettingsService.SettingsSaved += OnAppSettingsSaved;
|
||||||
|
InitializeTrayIcon();
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||||
DisableAvaloniaDataAnnotationValidation();
|
DisableAvaloniaDataAnnotationValidation();
|
||||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
||||||
|
desktop.Exit += (_, _) =>
|
||||||
|
{
|
||||||
|
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||||
|
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
|
||||||
|
DisposeTrayIcon();
|
||||||
|
};
|
||||||
desktop.MainWindow = new MainWindow
|
desktop.MainWindow = new MainWindow
|
||||||
{
|
{
|
||||||
DataContext = new MainWindowViewModel(),
|
DataContext = new MainWindowViewModel(),
|
||||||
};
|
};
|
||||||
|
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
@@ -50,6 +66,8 @@ public partial class App : Application
|
|||||||
|
|
||||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
DisposeTrayIcon();
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
desktop.Shutdown();
|
desktop.Shutdown();
|
||||||
@@ -89,7 +107,7 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Debug.WriteLine($"[TraySettings] Failed to open settings window: {ex}");
|
AppLogger.Warn("TraySettings", "Failed to open settings window.", ex);
|
||||||
}
|
}
|
||||||
}, DispatcherPriority.Normal);
|
}, DispatcherPriority.Normal);
|
||||||
}
|
}
|
||||||
@@ -99,6 +117,17 @@ public partial class App : Application
|
|||||||
AppRestartService.TryRestartApplication();
|
AppRestartService.TryRestartApplication();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnAppSettingsSaved(string _)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (_trayIcons is not null)
|
||||||
|
{
|
||||||
|
InitializeTrayIcon();
|
||||||
|
}
|
||||||
|
}, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
private void DisableAvaloniaDataAnnotationValidation()
|
private void DisableAvaloniaDataAnnotationValidation()
|
||||||
{
|
{
|
||||||
// Get an array of plugins to remove
|
// Get an array of plugins to remove
|
||||||
@@ -133,9 +162,10 @@ public partial class App : Application
|
|||||||
userDataFolder,
|
userDataFolder,
|
||||||
EnvironmentVariableTarget.Process);
|
EnvironmentVariableTarget.Process);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Keep startup resilient if user profile folders are unavailable.
|
// Keep startup resilient if user profile folders are unavailable.
|
||||||
|
AppLogger.Warn("WebView2", "Failed to configure WebView2 user data folder.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +179,77 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}");
|
AppLogger.Warn("PluginRuntime", "Failed to initialize plugin runtime.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void InitializeTrayIcon()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DisposeTrayIcon();
|
||||||
|
|
||||||
|
using var iconStream = AssetLoader.Open(new Uri("avares://LanMountainDesktop/Assets/avalonia-logo.ico"));
|
||||||
|
var trayIcon = new TrayIcon
|
||||||
|
{
|
||||||
|
Icon = new WindowIcon(iconStream),
|
||||||
|
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
|
||||||
|
Menu = BuildTrayMenu(),
|
||||||
|
IsVisible = true
|
||||||
|
};
|
||||||
|
|
||||||
|
_trayIcons = [trayIcon];
|
||||||
|
TrayIcon.SetIcons(this, _trayIcons);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private NativeMenu BuildTrayMenu()
|
||||||
|
{
|
||||||
|
var menu = new NativeMenu();
|
||||||
|
|
||||||
|
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", "重启应用"));
|
||||||
|
restartItem.Click += OnTrayRestartClick;
|
||||||
|
menu.Items.Add(restartItem);
|
||||||
|
|
||||||
|
menu.Items.Add(new NativeMenuItemSeparator());
|
||||||
|
|
||||||
|
var exitItem = new NativeMenuItem(L("tray.menu.exit", "退出应用"));
|
||||||
|
exitItem.Click += OnTrayExitClick;
|
||||||
|
menu.Items.Add(exitItem);
|
||||||
|
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisposeTrayIcon()
|
||||||
|
{
|
||||||
|
if (_trayIcons is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrayIcon.SetIcons(this, null);
|
||||||
|
foreach (var trayIcon in _trayIcons)
|
||||||
|
{
|
||||||
|
trayIcon.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_trayIcons = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var snapshot = _appSettingsService.Load();
|
||||||
|
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||||
|
return _localizationService.GetString(languageCode, key, fallback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj"
|
||||||
|
ReferenceOutputAssembly="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
||||||
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||||
|
<PackageReference Include="Downloader" Version="4.1.1" />
|
||||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
||||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
||||||
@@ -55,4 +58,22 @@
|
|||||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
|
||||||
|
<ItemGroup>
|
||||||
|
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy SourceFiles="@(PluginsInstallHelperFiles)"
|
||||||
|
DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||||
|
SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
||||||
|
<ItemGroup>
|
||||||
|
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)"
|
||||||
|
DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||||
|
SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"app.title": "LanMountainDesktop",
|
"app.title": "LanMountainDesktop",
|
||||||
|
"tray.tooltip": "LanMountainDesktop",
|
||||||
|
"tray.menu.settings": "Settings",
|
||||||
|
"tray.menu.restart": "Restart App",
|
||||||
|
"tray.menu.exit": "Exit App",
|
||||||
"button.back_to_windows": "Back to Windows",
|
"button.back_to_windows": "Back to Windows",
|
||||||
"tooltip.back_to_windows": "Back to Windows",
|
"tooltip.back_to_windows": "Back to Windows",
|
||||||
"tooltip.open_settings": "Settings",
|
"tooltip.open_settings": "Settings",
|
||||||
@@ -302,15 +306,17 @@
|
|||||||
"settings.launcher.hidden_empty": "No hidden items.",
|
"settings.launcher.hidden_empty": "No hidden items.",
|
||||||
"settings.launcher.hidden_type_folder": "Folder",
|
"settings.launcher.hidden_type_folder": "Folder",
|
||||||
"settings.launcher.hidden_type_shortcut": "Shortcut",
|
"settings.launcher.hidden_type_shortcut": "Shortcut",
|
||||||
"settings.launcher.restore_button": "Show Again",
|
"settings.launcher.restore_button": "Unhide",
|
||||||
"settings.plugins.title": "Plugins",
|
"settings.plugins.title": "Plugins",
|
||||||
"settings.plugins.runtime_header": "Plugin Runtime",
|
"settings.plugins.runtime_header": "Plugin Runtime",
|
||||||
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
|
"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_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.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
|
||||||
"settings.plugins.installed_header": "Installed Plugins",
|
"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.installed_desc": "Review installed plugins and remove them here.",
|
||||||
"settings.plugins.restart_hint": "Plugin enable state changes take effect after restarting the app.",
|
"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.empty": "No plugins found.",
|
||||||
"settings.plugins.runtime_unavailable": "Plugin runtime is not available.",
|
"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}.",
|
"settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
|
||||||
@@ -325,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_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_enabled": "enabled",
|
||||||
"settings.plugins.toggle_state_disabled": "disabled",
|
"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_button": "Open .laapp package",
|
||||||
"settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.",
|
"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}",
|
"settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}",
|
||||||
@@ -334,6 +341,12 @@
|
|||||||
"settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.",
|
"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_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.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_package": ".laapp package",
|
||||||
"settings.plugins.source_manifest": "Loose manifest",
|
"settings.plugins.source_manifest": "Loose manifest",
|
||||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
@@ -364,6 +377,15 @@
|
|||||||
"market.detail.min_host_version": "Minimum Host Version",
|
"market.detail.min_host_version": "Minimum Host Version",
|
||||||
"market.detail.installed_version": "Installed Version",
|
"market.detail.installed_version": "Installed Version",
|
||||||
"market.detail.not_installed": "Not installed",
|
"market.detail.not_installed": "Not installed",
|
||||||
|
"market.detail.readme": "README",
|
||||||
|
"market.detail.plugin_information": "Plugin Information",
|
||||||
|
"market.detail.author_subtitle_format": "By {0}",
|
||||||
|
"market.detail.package_size": "Package Size",
|
||||||
|
"market.detail.published_at": "Published At",
|
||||||
|
"market.detail.updated_at": "Updated At",
|
||||||
|
"market.detail.tags": "Tags",
|
||||||
|
"market.detail.project": "Project",
|
||||||
|
"market.detail.state": "Install State",
|
||||||
"market.detail.market_source": "Market Source",
|
"market.detail.market_source": "Market Source",
|
||||||
"market.detail.homepage": "Homepage",
|
"market.detail.homepage": "Homepage",
|
||||||
"market.detail.repository": "Repository",
|
"market.detail.repository": "Repository",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"app.title": "LanMountainDesktop",
|
"app.title": "LanMountainDesktop",
|
||||||
|
"tray.tooltip": "LanMountainDesktop",
|
||||||
|
"tray.menu.settings": "设置",
|
||||||
|
"tray.menu.restart": "重启应用",
|
||||||
|
"tray.menu.exit": "退出应用",
|
||||||
"button.back_to_windows": "回到Windows",
|
"button.back_to_windows": "回到Windows",
|
||||||
"tooltip.back_to_windows": "回到Windows",
|
"tooltip.back_to_windows": "回到Windows",
|
||||||
"tooltip.open_settings": "设置",
|
"tooltip.open_settings": "设置",
|
||||||
@@ -302,15 +306,17 @@
|
|||||||
"settings.launcher.hidden_empty": "暂无隐藏项目。",
|
"settings.launcher.hidden_empty": "暂无隐藏项目。",
|
||||||
"settings.launcher.hidden_type_folder": "文件夹",
|
"settings.launcher.hidden_type_folder": "文件夹",
|
||||||
"settings.launcher.hidden_type_shortcut": "快捷方式",
|
"settings.launcher.hidden_type_shortcut": "快捷方式",
|
||||||
"settings.launcher.restore_button": "重新显示",
|
"settings.launcher.restore_button": "取消隐藏",
|
||||||
"settings.plugins.title": "插件",
|
"settings.plugins.title": "插件",
|
||||||
"settings.plugins.runtime_header": "插件运行时",
|
"settings.plugins.runtime_header": "插件运行时",
|
||||||
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
|
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
|
||||||
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
|
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
|
||||||
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
|
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
|
||||||
"settings.plugins.installed_header": "已安装插件",
|
"settings.plugins.installed_header": "已安装插件",
|
||||||
"settings.plugins.installed_desc": "在这里启用或禁用插件。插件自己的详细设置会作为独立设置页出现。",
|
"settings.plugins.installed_desc": "在这里查看和删除已安装的插件。",
|
||||||
"settings.plugins.restart_hint": "插件启用状态变更会在重启应用后生效。",
|
"settings.plugins.import_header": "从安装包导入",
|
||||||
|
"settings.plugins.import_desc": "打开一个 .laapp 插件包,并将其暂存到本地插件目录。",
|
||||||
|
"settings.plugins.restart_hint": "插件安装和删除变更会在重启应用后生效。",
|
||||||
"settings.plugins.empty": "未找到插件。",
|
"settings.plugins.empty": "未找到插件。",
|
||||||
"settings.plugins.runtime_unavailable": "插件运行时不可用。",
|
"settings.plugins.runtime_unavailable": "插件运行时不可用。",
|
||||||
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
|
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
|
||||||
@@ -325,6 +331,7 @@
|
|||||||
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
||||||
"settings.plugins.toggle_state_enabled": "启用",
|
"settings.plugins.toggle_state_enabled": "启用",
|
||||||
"settings.plugins.toggle_state_disabled": "禁用",
|
"settings.plugins.toggle_state_disabled": "禁用",
|
||||||
|
"settings.plugins.toggle_failed_detail_format": "更新插件“{0}”状态失败:{1}",
|
||||||
"settings.plugins.install_button": "打开 .laapp 插件包",
|
"settings.plugins.install_button": "打开 .laapp 插件包",
|
||||||
"settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。",
|
"settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。",
|
||||||
"settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}",
|
"settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}",
|
||||||
@@ -334,6 +341,12 @@
|
|||||||
"settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。",
|
"settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。",
|
||||||
"settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。",
|
"settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。",
|
||||||
"settings.plugins.install_failed_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_package": ".laapp 包",
|
||||||
"settings.plugins.source_manifest": "散装清单",
|
"settings.plugins.source_manifest": "散装清单",
|
||||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
@@ -364,6 +377,15 @@
|
|||||||
"market.detail.min_host_version": "最低宿主版本",
|
"market.detail.min_host_version": "最低宿主版本",
|
||||||
"market.detail.installed_version": "已安装版本",
|
"market.detail.installed_version": "已安装版本",
|
||||||
"market.detail.not_installed": "未安装",
|
"market.detail.not_installed": "未安装",
|
||||||
|
"market.detail.readme": "README",
|
||||||
|
"market.detail.plugin_information": "插件信息",
|
||||||
|
"market.detail.author_subtitle_format": "作者:{0}",
|
||||||
|
"market.detail.package_size": "包大小",
|
||||||
|
"market.detail.published_at": "首次发布",
|
||||||
|
"market.detail.updated_at": "最近更新",
|
||||||
|
"market.detail.tags": "标签",
|
||||||
|
"market.detail.project": "项目",
|
||||||
|
"market.detail.state": "安装状态",
|
||||||
"market.detail.market_source": "市场源",
|
"market.detail.market_source": "市场源",
|
||||||
"market.detail.homepage": "主页",
|
"market.detail.homepage": "主页",
|
||||||
"market.detail.repository": "仓库",
|
"market.detail.repository": "仓库",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Avalonia;
|
|||||||
using Avalonia.WebView.Desktop;
|
using Avalonia.WebView.Desktop;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace LanMountainDesktop;
|
namespace LanMountainDesktop;
|
||||||
|
|
||||||
@@ -11,8 +12,27 @@ sealed class Program
|
|||||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||||
// yet and stuff might break.
|
// yet and stuff might break.
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args) => BuildAvaloniaApp(LoadConfiguredRenderMode())
|
public static void Main(string[] args)
|
||||||
.StartWithClassicDesktopLifetime(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.
|
// Avalonia configuration, don't remove; also used by visual designer.
|
||||||
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
|
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
|
||||||
@@ -44,9 +64,26 @@ sealed class Program
|
|||||||
{
|
{
|
||||||
return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode);
|
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;
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
169
LanMountainDesktop/Services/AppLogger.cs
Normal file
169
LanMountainDesktop/Services/AppLogger.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,8 +63,9 @@ public sealed class AppSettingsService
|
|||||||
return loadedSnapshot.Clone();
|
return loadedSnapshot.Clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("AppSettings", $"Failed to load settings from '{_settingsPath}'.", ex);
|
||||||
return new AppSettingsSnapshot();
|
return new AppSettingsSnapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,9 +96,9 @@ public sealed class AppSettingsService
|
|||||||
|
|
||||||
SettingsSaved?.Invoke(InstanceId);
|
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);
|
var json = File.ReadAllText(_settingsPath);
|
||||||
return JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions) ?? new AppSettingsSnapshot();
|
return JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions) ?? new AppSettingsSnapshot();
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("AppSettings", $"Failed to deserialize settings from '{_settingsPath}'.", ex);
|
||||||
return new AppSettingsSnapshot();
|
return new AppSettingsSnapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
return document.DefaultSettings.Clone();
|
return document.DefaultSettings.Clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("ComponentSettings", $"Failed to load component settings from '{_settingsPath}'.", ex);
|
||||||
return new ComponentSettingsSnapshot();
|
return new ComponentSettingsSnapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,9 +77,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
PersistDocumentLocked(document);
|
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();
|
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();
|
return new ComponentSettingsSnapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,9 +129,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
PersistDocumentLocked(document);
|
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<T>(settingsElement.GetRawText(), SerializerOptions) ?? new T();
|
return JsonSerializer.Deserialize<T>(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();
|
return new T();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,9 +212,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
PersistDocumentLocked(document);
|
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)
|
DefaultSettings = NormalizeSnapshot(legacySnapshot)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex);
|
||||||
return new ComponentSettingsDocumentSnapshot();
|
return new ComponentSettingsDocumentSnapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,8 +450,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("ComponentSettings", $"Failed to migrate legacy component settings from '{_legacyAppSettingsPath}'.", ex);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ public sealed class DesktopLayoutSettingsService
|
|||||||
return normalizedSnapshot.Clone();
|
return normalizedSnapshot.Clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("DesktopLayout", $"Failed to load desktop layout settings from '{_settingsPath}'.", ex);
|
||||||
return new DesktopLayoutSettingsSnapshot();
|
return new DesktopLayoutSettingsSnapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,9 +100,9 @@ public sealed class DesktopLayoutSettingsService
|
|||||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
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<DesktopLayoutSettingsSnapshot>(json, SerializerOptions);
|
var snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions);
|
||||||
return NormalizeSnapshot(snapshot);
|
return NormalizeSnapshot(snapshot);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("DesktopLayout", $"Failed to deserialize desktop layout settings from '{_settingsPath}'.", ex);
|
||||||
return new DesktopLayoutSettingsSnapshot();
|
return new DesktopLayoutSettingsSnapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,8 +176,9 @@ public sealed class DesktopLayoutSettingsService
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("DesktopLayout", $"Failed to migrate legacy desktop layout settings from '{_legacyAppSettingsPath}'.", ex);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
98
LanMountainDesktop/Services/FileOperationRetryHelper.cs
Normal file
98
LanMountainDesktop/Services/FileOperationRetryHelper.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,8 +85,9 @@ public sealed class LauncherSettingsService
|
|||||||
return normalizedSnapshot.Clone();
|
return normalizedSnapshot.Clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("LauncherSettings", $"Failed to load launcher settings from '{_settingsPath}'.", ex);
|
||||||
return new LauncherSettingsSnapshot();
|
return new LauncherSettingsSnapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,9 +107,9 @@ public sealed class LauncherSettingsService
|
|||||||
|
|
||||||
SettingsSaved?.Invoke(InstanceId);
|
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<LauncherSettingsSnapshot>(json, SerializerOptions);
|
var snapshot = JsonSerializer.Deserialize<LauncherSettingsSnapshot>(json, SerializerOptions);
|
||||||
return NormalizeSnapshot(snapshot);
|
return NormalizeSnapshot(snapshot);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("LauncherSettings", $"Failed to deserialize launcher settings from '{_settingsPath}'.", ex);
|
||||||
return new LauncherSettingsSnapshot();
|
return new LauncherSettingsSnapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,8 +182,9 @@ public sealed class LauncherSettingsService
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("LauncherSettings", $"Failed to migrate legacy launcher settings from '{_legacyAppSettingsPath}'.", ex);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
186
LanMountainDesktop/Services/PluginsInstallHelperClient.cs
Normal file
186
LanMountainDesktop/Services/PluginsInstallHelperClient.cs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
internal sealed class PluginsInstallHelperClient
|
||||||
|
{
|
||||||
|
private const int UserCanceledUacErrorCode = 1223;
|
||||||
|
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
|
||||||
|
|
||||||
|
public async Task<PluginsInstallHelperResult> InstallPackageAsync(
|
||||||
|
string packagePath,
|
||||||
|
string pluginsDirectory,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsDirectory);
|
||||||
|
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
"Elevated helper install is only supported on Windows.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var helperPath = ResolveHelperPath();
|
||||||
|
if (!File.Exists(helperPath))
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
$"Plugins install helper was not found at '{helperPath}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop",
|
||||||
|
"PluginInstallResults",
|
||||||
|
$"{Guid.NewGuid():N}.json");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = StartHelperProcess(helperPath, packagePath, pluginsDirectory, resultPath);
|
||||||
|
if (process is null)
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(false, null, "Failed to start plugins install helper.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await process.WaitForExitAsync(cancellationToken);
|
||||||
|
var result = await ReadResultAsync(resultPath, cancellationToken);
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
"Plugins install helper exited without producing a result file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PluginsInstallHelperResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"Plugins install helper exited with code {0}.",
|
||||||
|
process.ExitCode));
|
||||||
|
}
|
||||||
|
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
|
||||||
|
{
|
||||||
|
return new PluginsInstallHelperResult(false, null, "Administrator permission request was canceled.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TryDeleteFile(resultPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Process? StartHelperProcess(
|
||||||
|
string helperPath,
|
||||||
|
string packagePath,
|
||||||
|
string pluginsDirectory,
|
||||||
|
string resultPath)
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = helperPath,
|
||||||
|
Verb = "runas",
|
||||||
|
UseShellExecute = true,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(helperPath) ?? AppContext.BaseDirectory,
|
||||||
|
Arguments = string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))}")
|
||||||
|
};
|
||||||
|
|
||||||
|
return Process.Start(startInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HelperResultFile?> ReadResultAsync(string resultPath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!File.Exists(resultPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = File.OpenRead(resultPath);
|
||||||
|
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveHelperPath()
|
||||||
|
{
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string QuoteArgument(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return "\"\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.Append('"');
|
||||||
|
foreach (var ch in value)
|
||||||
|
{
|
||||||
|
if (ch == '"')
|
||||||
|
{
|
||||||
|
builder.Append("\\\"");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append('"');
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteFile(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore temp file cleanup failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HelperResultFile
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
public string? InstalledPackagePath { get; init; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record PluginsInstallHelperResult(
|
||||||
|
bool Success,
|
||||||
|
string? InstalledPackagePath,
|
||||||
|
string? ErrorMessage);
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Buffers;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
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;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Downloader;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
@@ -34,16 +29,11 @@ public sealed record DownloadResult(
|
|||||||
|
|
||||||
public sealed class ResumableDownloadService
|
public sealed class ResumableDownloadService
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions MetadataSerializerOptions = new()
|
private static readonly ConcurrentDictionary<string, SemaphoreSlim> DestinationGates =
|
||||||
{
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
WriteIndented = false
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly HttpClient _httpClient;
|
public ResumableDownloadService(System.Net.Http.HttpClient httpClient)
|
||||||
|
|
||||||
public ResumableDownloadService(HttpClient httpClient)
|
|
||||||
{
|
{
|
||||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DownloadResult> DownloadAsync(
|
public async Task<DownloadResult> DownloadAsync(
|
||||||
@@ -57,13 +47,19 @@ public sealed class ResumableDownloadService
|
|||||||
ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath);
|
||||||
|
|
||||||
var normalizedOptions = NormalizeOptions(options);
|
var normalizedOptions = NormalizeOptions(options);
|
||||||
|
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
|
||||||
|
var destinationGate = DestinationGates.GetOrAdd(
|
||||||
|
fullDestinationPath,
|
||||||
|
static _ => new SemaphoreSlim(1, 1));
|
||||||
|
|
||||||
|
await destinationGate.WaitAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (File.Exists(source))
|
if (File.Exists(source))
|
||||||
{
|
{
|
||||||
return await CopyLocalFileAsync(
|
return await CopyLocalFileAsync(
|
||||||
source,
|
source,
|
||||||
destinationFilePath,
|
fullDestinationPath,
|
||||||
normalizedOptions,
|
normalizedOptions,
|
||||||
progress,
|
progress,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -77,7 +73,7 @@ public sealed class ResumableDownloadService
|
|||||||
|
|
||||||
return await DownloadRemoteFileAsync(
|
return await DownloadRemoteFileAsync(
|
||||||
sourceUri,
|
sourceUri,
|
||||||
destinationFilePath,
|
fullDestinationPath,
|
||||||
normalizedOptions,
|
normalizedOptions,
|
||||||
progress,
|
progress,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -88,8 +84,16 @@ public sealed class ResumableDownloadService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"Downloader",
|
||||||
|
$"Download failed. Source='{source}'; Destination='{fullDestinationPath}'.",
|
||||||
|
ex);
|
||||||
return new DownloadResult(false, null, ex.Message, false, false);
|
return new DownloadResult(false, null, ex.Message, false, false);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
destinationGate.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<DownloadResult> CopyLocalFileAsync(
|
private async Task<DownloadResult> CopyLocalFileAsync(
|
||||||
@@ -104,13 +108,12 @@ public sealed class ResumableDownloadService
|
|||||||
var totalBytes = new FileInfo(fullSourcePath).Length;
|
var totalBytes = new FileInfo(fullSourcePath).Length;
|
||||||
|
|
||||||
var tempFilePath = BuildTempFilePath(fullDestinationPath);
|
var tempFilePath = BuildTempFilePath(fullDestinationPath);
|
||||||
var metadataFilePath = BuildMetadataFilePath(fullDestinationPath);
|
|
||||||
PrepareDestination(fullDestinationPath);
|
PrepareDestination(fullDestinationPath);
|
||||||
|
|
||||||
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
|
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
|
||||||
{
|
{
|
||||||
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, false, false));
|
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, false, false));
|
||||||
CleanupPartialArtifacts(tempFilePath, metadataFilePath);
|
CleanupLocalPartialArtifacts(tempFilePath);
|
||||||
return new DownloadResult(true, fullDestinationPath, null, false, false);
|
return new DownloadResult(true, fullDestinationPath, null, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +123,7 @@ public sealed class ResumableDownloadService
|
|||||||
existingBytes = new FileInfo(tempFilePath).Length;
|
existingBytes = new FileInfo(tempFilePath).Length;
|
||||||
if (existingBytes > totalBytes)
|
if (existingBytes > totalBytes)
|
||||||
{
|
{
|
||||||
ResetPartialArtifacts(tempFilePath, metadataFilePath);
|
CleanupLocalPartialArtifacts(tempFilePath);
|
||||||
existingBytes = 0;
|
existingBytes = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +141,7 @@ public sealed class ResumableDownloadService
|
|||||||
|
|
||||||
if (existingBytes >= totalBytes)
|
if (existingBytes >= totalBytes)
|
||||||
{
|
{
|
||||||
CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath);
|
CompleteLocalCopy(tempFilePath, fullDestinationPath);
|
||||||
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, existingBytes > 0, false));
|
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, existingBytes > 0, false));
|
||||||
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
|
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
|
||||||
}
|
}
|
||||||
@@ -169,13 +172,10 @@ public sealed class ResumableDownloadService
|
|||||||
destinationStream,
|
destinationStream,
|
||||||
existingBytes,
|
existingBytes,
|
||||||
totalBytes,
|
totalBytes,
|
||||||
isResuming: existingBytes > 0,
|
|
||||||
isParallel: false,
|
|
||||||
options.BufferSize,
|
|
||||||
progress,
|
progress,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath);
|
CompleteLocalCopy(tempFilePath, fullDestinationPath);
|
||||||
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
|
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,501 +186,144 @@ public sealed class ResumableDownloadService
|
|||||||
IProgress<DownloadProgressInfo>? progress,
|
IProgress<DownloadProgressInfo>? progress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
|
PrepareDestination(destinationFilePath);
|
||||||
var tempFilePath = BuildTempFilePath(fullDestinationPath);
|
|
||||||
var metadataFilePath = BuildMetadataFilePath(fullDestinationPath);
|
|
||||||
PrepareDestination(fullDestinationPath);
|
|
||||||
|
|
||||||
var probe = await ProbeRemoteFileAsync(sourceUri, cancellationToken);
|
if (CanReuseCompletedDestination(destinationFilePath, options.ExpectedSizeBytes))
|
||||||
var totalBytes = probe.TotalBytes ?? options.ExpectedSizeBytes;
|
|
||||||
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
|
|
||||||
{
|
{
|
||||||
progress?.Report(new DownloadProgressInfo(
|
var existingLength = new FileInfo(destinationFilePath).Length;
|
||||||
totalBytes ?? new FileInfo(fullDestinationPath).Length,
|
progress?.Report(new DownloadProgressInfo(existingLength, options.ExpectedSizeBytes, 1d, false, false));
|
||||||
totalBytes,
|
CleanupDownloaderArtifacts(destinationFilePath);
|
||||||
1d,
|
return new DownloadResult(true, destinationFilePath, null, false, false);
|
||||||
false,
|
|
||||||
false));
|
|
||||||
CleanupPartialArtifacts(tempFilePath, metadataFilePath);
|
|
||||||
return new DownloadResult(true, fullDestinationPath, null, false, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var canUseParallel = probe.SupportsRanges &&
|
var usedResume = HasDownloaderResumeArtifacts(destinationFilePath);
|
||||||
totalBytes is > 0 &&
|
var usedParallelDownload = ShouldUseParallelDownload(options);
|
||||||
totalBytes.Value >= options.ParallelThresholdBytes &&
|
var configuration = CreateConfiguration(options, usedParallelDownload);
|
||||||
options.MaxParallelSegments > 1;
|
using var downloader = new DownloadService(configuration);
|
||||||
|
|
||||||
try
|
downloader.DownloadProgressChanged += (_, args) =>
|
||||||
{
|
{
|
||||||
var result = canUseParallel
|
progress?.Report(MapProgress(args, options.ExpectedSizeBytes, usedResume, usedParallelDownload));
|
||||||
? await DownloadRemoteInParallelAsync(
|
};
|
||||||
sourceUri,
|
|
||||||
fullDestinationPath,
|
|
||||||
tempFilePath,
|
|
||||||
metadataFilePath,
|
|
||||||
totalBytes!.Value,
|
|
||||||
options,
|
|
||||||
progress,
|
|
||||||
cancellationToken)
|
|
||||||
: await DownloadRemoteSequentiallyAsync(
|
|
||||||
sourceUri,
|
|
||||||
fullDestinationPath,
|
|
||||||
tempFilePath,
|
|
||||||
metadataFilePath,
|
|
||||||
totalBytes,
|
|
||||||
probe.SupportsRanges,
|
|
||||||
options,
|
|
||||||
progress,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return result;
|
using var cancellationRegistration = cancellationToken.Register(() =>
|
||||||
}
|
|
||||||
catch (RangeRequestNotSupportedException)
|
|
||||||
{
|
{
|
||||||
ResetPartialArtifacts(tempFilePath, metadataFilePath);
|
try
|
||||||
return await DownloadRemoteSequentiallyAsync(
|
|
||||||
sourceUri,
|
|
||||||
fullDestinationPath,
|
|
||||||
tempFilePath,
|
|
||||||
metadataFilePath,
|
|
||||||
totalBytes,
|
|
||||||
allowResume: false,
|
|
||||||
options,
|
|
||||||
progress,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<DownloadResult> DownloadRemoteSequentiallyAsync(
|
|
||||||
Uri sourceUri,
|
|
||||||
string destinationFilePath,
|
|
||||||
string tempFilePath,
|
|
||||||
string metadataFilePath,
|
|
||||||
long? totalBytes,
|
|
||||||
bool allowResume,
|
|
||||||
DownloadOptions options,
|
|
||||||
IProgress<DownloadProgressInfo>? progress,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
long existingBytes = 0;
|
|
||||||
if (File.Exists(tempFilePath))
|
|
||||||
{
|
|
||||||
existingBytes = new FileInfo(tempFilePath).Length;
|
|
||||||
if (totalBytes is > 0 && existingBytes > totalBytes.Value)
|
|
||||||
{
|
{
|
||||||
ResetPartialArtifacts(tempFilePath, metadataFilePath);
|
downloader.CancelAsync();
|
||||||
existingBytes = 0;
|
|
||||||
}
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
|
AppLogger.Warn(
|
||||||
progress?.Report(new DownloadProgressInfo(totalBytes.Value, totalBytes, 1d, true, false));
|
"Downloader",
|
||||||
return new DownloadResult(true, destinationFilePath, null, true, false);
|
$"Failed to cancel Downloader request for '{destinationFilePath}'.",
|
||||||
|
ex);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
AppLogger.Info(
|
||||||
{
|
"Downloader",
|
||||||
throw new RangeRequestNotSupportedException("The server did not honor the resume range request.");
|
$"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);
|
if (!File.Exists(destinationFilePath))
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
destinationStream.Seek(existingBytes, SeekOrigin.Begin);
|
throw new FileNotFoundException(
|
||||||
|
$"Downloader completed without producing '{destinationFilePath}'.",
|
||||||
|
destinationFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
var effectiveTotalBytes = totalBytes;
|
var finalLength = new FileInfo(destinationFilePath).Length;
|
||||||
if (effectiveTotalBytes is null && response.Content.Headers.ContentLength is > 0)
|
progress?.Report(new DownloadProgressInfo(
|
||||||
{
|
finalLength,
|
||||||
effectiveTotalBytes = existingBytes + response.Content.Headers.ContentLength.Value;
|
options.ExpectedSizeBytes ?? finalLength,
|
||||||
}
|
1d,
|
||||||
|
usedResume,
|
||||||
|
usedParallelDownload));
|
||||||
|
|
||||||
await CopyStreamAsync(
|
AppLogger.Info(
|
||||||
sourceStream,
|
"Downloader",
|
||||||
destinationStream,
|
$"Remote download completed. Source='{sourceUri}'; Destination='{destinationFilePath}'; Size={finalLength}; Parallel={usedParallelDownload}; Resume={usedResume}.");
|
||||||
existingBytes,
|
|
||||||
effectiveTotalBytes,
|
|
||||||
isResuming: existingBytes > 0,
|
|
||||||
isParallel: false,
|
|
||||||
options.BufferSize,
|
|
||||||
progress,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
|
return new DownloadResult(true, destinationFilePath, null, usedResume, usedParallelDownload);
|
||||||
return new DownloadResult(true, destinationFilePath, null, existingBytes > 0, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<DownloadResult> DownloadRemoteInParallelAsync(
|
|
||||||
Uri sourceUri,
|
|
||||||
string destinationFilePath,
|
|
||||||
string tempFilePath,
|
|
||||||
string metadataFilePath,
|
|
||||||
long totalBytes,
|
|
||||||
DownloadOptions options,
|
|
||||||
IProgress<DownloadProgressInfo>? 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<int> 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<byte>.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<byte>.Shared.Return(buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<RemoteProbeResult> 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task CopyStreamAsync(
|
private static async Task CopyStreamAsync(
|
||||||
Stream sourceStream,
|
Stream sourceStream,
|
||||||
Stream destinationStream,
|
Stream destinationStream,
|
||||||
long initialDownloadedBytes,
|
long initialDownloadedBytes,
|
||||||
long? totalBytes,
|
|
||||||
bool isResuming,
|
|
||||||
bool isParallel,
|
|
||||||
int bufferSize,
|
|
||||||
IProgress<DownloadProgressInfo>? progress,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var buffer = ArrayPool<byte>.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<byte>.Shared.Return(buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ReportProgress(
|
|
||||||
IProgress<DownloadProgressInfo>? 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<DownloadMetadata> LoadOrCreateMetadataAsync(
|
|
||||||
Uri sourceUri,
|
|
||||||
string tempFilePath,
|
|
||||||
string metadataFilePath,
|
|
||||||
long totalBytes,
|
long totalBytes,
|
||||||
int segmentCount,
|
IProgress<DownloadProgressInfo>? progress,
|
||||||
CancellationToken cancellationToken)
|
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);
|
break;
|
||||||
var metadata = JsonSerializer.Deserialize<SerializableDownloadMetadata>(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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
await destinationStream.FlushAsync(cancellationToken);
|
||||||
var createdMetadata = DownloadMetadata.Create(sourceUri.ToString(), totalBytes, segmentCount);
|
|
||||||
var serialized = JsonSerializer.Serialize(createdMetadata.ToSerializable(), MetadataSerializerOptions);
|
|
||||||
await File.WriteAllTextAsync(metadataFilePath, serialized, cancellationToken);
|
|
||||||
return createdMetadata;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DownloadOptions NormalizeOptions(DownloadOptions? options)
|
private static DownloadConfiguration CreateConfiguration(DownloadOptions options, bool useParallelDownload)
|
||||||
{
|
{
|
||||||
var normalized = options ?? new DownloadOptions();
|
return new DownloadConfiguration
|
||||||
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
|
|
||||||
{
|
{
|
||||||
MaxParallelSegments = maxParallelSegments,
|
BufferBlockSize = options.BufferSize,
|
||||||
ParallelThresholdBytes = parallelThresholdBytes,
|
ChunkCount = useParallelDownload ? options.MaxParallelSegments : 1,
|
||||||
BufferSize = bufferSize
|
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 new DownloadProgressInfo(
|
||||||
{
|
downloadedBytes,
|
||||||
return 4;
|
totalBytes,
|
||||||
}
|
Math.Clamp(normalizedProgress, 0d, 1d),
|
||||||
|
isResuming,
|
||||||
return 6;
|
isParallel);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool CanReuseCompletedDestination(string destinationFilePath, long? expectedSizeBytes)
|
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))
|
if (!File.Exists(tempFilePath))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
File.Move(tempFilePath, destinationFilePath, overwrite: true);
|
FileOperationRetryHelper.MoveWithOverwriteRetry(tempFilePath, destinationFilePath, "Downloader");
|
||||||
if (File.Exists(metadataFilePath))
|
|
||||||
{
|
|
||||||
File.Delete(metadataFilePath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
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 BuildTempFilePath(string destinationFilePath) => destinationFilePath + ".part";
|
||||||
|
|
||||||
private static string BuildMetadataFilePath(string destinationFilePath) => destinationFilePath + ".part.json";
|
private static string BuildPackageFilePath(string destinationFilePath) => destinationFilePath + ".download";
|
||||||
|
|
||||||
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<DownloadSegmentState> 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<SerializableDownloadSegment> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
193
LanMountainDesktop/Services/StartupDiagnosticsService.cs
Normal file
193
LanMountainDesktop/Services/StartupDiagnosticsService.cs
Normal file
@@ -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<string> FoundLegacyArtifacts { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> DeletedLegacyArtifacts { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> FailedLegacyArtifacts { get; init; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
var failedLegacyArtifacts = new List<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
LanMountainDesktop/Services/WindowsNativeDialogService.cs
Normal file
30
LanMountainDesktop/Services/WindowsNativeDialogService.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -30,8 +30,9 @@ public sealed class WindowsStartupService
|
|||||||
return runKey?.GetValue(ValueName) is string value &&
|
return runKey?.GetValue(ValueName) is string value &&
|
||||||
!string.IsNullOrWhiteSpace(value);
|
!string.IsNullOrWhiteSpace(value);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("WindowsStartup", "Failed to query startup registry state.", ex);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,8 +68,9 @@ public sealed class WindowsStartupService
|
|||||||
|
|
||||||
return IsEnabled() == enabled;
|
return IsEnabled() == enabled;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn("WindowsStartup", $"Failed to set startup registry state. Enabled={enabled}", ex);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using Avalonia.Media;
|
|||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
|
using FluentAvalonia.UI.Controls;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
@@ -1069,12 +1070,12 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private void RenderLauncherHiddenItemsList()
|
private void RenderLauncherHiddenItemsList()
|
||||||
{
|
{
|
||||||
if (LauncherHiddenItemsListPanel is null || LauncherHiddenItemsEmptyTextBlock is null)
|
if (LauncherHiddenItemsSettingsExpander is null || LauncherHiddenItemsEmptyTextBlock is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
LauncherHiddenItemsListPanel.Children.Clear();
|
LauncherHiddenItemsSettingsExpander.Items.Clear();
|
||||||
var hiddenItems = BuildLauncherHiddenItems();
|
var hiddenItems = BuildLauncherHiddenItems();
|
||||||
LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0;
|
LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0;
|
||||||
if (hiddenItems.Count == 0)
|
if (hiddenItems.Count == 0)
|
||||||
@@ -1084,7 +1085,7 @@ public partial class MainWindow
|
|||||||
|
|
||||||
foreach (var hiddenItem in hiddenItems)
|
foreach (var hiddenItem in hiddenItems)
|
||||||
{
|
{
|
||||||
LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem));
|
LauncherHiddenItemsSettingsExpander.Items.Add(CreateLauncherHiddenItemRow(hiddenItem));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1186,92 +1187,58 @@ public partial class MainWindow
|
|||||||
: fileName;
|
: 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
|
var typeText = hiddenItem.Kind == LauncherEntryKind.Folder
|
||||||
? L("settings.launcher.hidden_type_folder", "Folder")
|
? L("settings.launcher.hidden_type_folder", "Folder")
|
||||||
: L("settings.launcher.hidden_type_shortcut", "Shortcut");
|
: 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
|
var restoreButton = new Button
|
||||||
{
|
{
|
||||||
Content = L("settings.launcher.restore_button", "Show Again"),
|
Width = 36,
|
||||||
MinWidth = 110,
|
Height = 36,
|
||||||
Padding = new Thickness(12, 6),
|
Padding = new Thickness(0),
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
|
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
|
||||||
};
|
};
|
||||||
|
restoreButton.Content = new FluentIcons.Avalonia.Fluent.SymbolIcon
|
||||||
|
{
|
||||||
|
Symbol = FluentIcons.Common.Symbol.Eye,
|
||||||
|
IconVariant = FluentIcons.Common.IconVariant.Regular,
|
||||||
|
FontSize = 18,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
ToolTip.SetTip(restoreButton, L("settings.launcher.restore_button", "Unhide"));
|
||||||
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
|
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
|
||||||
|
|
||||||
var row = new Grid
|
return new SettingsExpanderItem
|
||||||
{
|
{
|
||||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
Content = hiddenItem.DisplayName,
|
||||||
ColumnSpacing = 10
|
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" },
|
return new ImageIconSource
|
||||||
BorderThickness = new Thickness(0),
|
{
|
||||||
CornerRadius = new CornerRadius(14),
|
Source = hiddenItem.IconBitmap
|
||||||
Padding = new Thickness(10, 8),
|
};
|
||||||
Child = row
|
}
|
||||||
|
|
||||||
|
return new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||||
|
{
|
||||||
|
Symbol = hiddenItem.Kind == LauncherEntryKind.Folder
|
||||||
|
? FluentIcons.Common.Symbol.Folder
|
||||||
|
: FluentIcons.Common.Symbol.Apps,
|
||||||
|
IconVariant = FluentIcons.Common.IconVariant.Regular
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnNightModeChecked(object? sender, RoutedEventArgs e)
|
private void OnNightModeChecked(object? sender, RoutedEventArgs e)
|
||||||
@@ -344,6 +345,7 @@ public partial class MainWindow
|
|||||||
var placement = GetSelectedWallpaperPlacement();
|
var placement = GetSelectedWallpaperPlacement();
|
||||||
DesktopWallpaperLayer.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, false);
|
DesktopWallpaperLayer.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, false);
|
||||||
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
|
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
|
||||||
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateWallpaperDisplay()
|
private void UpdateWallpaperDisplay()
|
||||||
@@ -628,12 +630,99 @@ public partial class MainWindow
|
|||||||
EnableHardwareDecoding = false
|
EnableHardwareDecoding = false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_previewVideoWallpaperPlayer is null && WallpaperPreviewVideoView is not null)
|
private void EnsureDesktopVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
if (_desktopVideoFrameRefreshTimer is not null)
|
||||||
{
|
{
|
||||||
_previewVideoWallpaperPlayer = new MediaPlayer(_libVlc);
|
return;
|
||||||
WallpaperPreviewVideoView.MediaPlayer = _previewVideoWallpaperPlayer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_desktopVideoFrameRefreshTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(33)
|
||||||
|
};
|
||||||
|
_desktopVideoFrameRefreshTimer.Tick += OnDesktopVideoFrameRefreshTimerTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartDesktopVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
EnsureDesktopVideoFrameRefreshTimer();
|
||||||
|
if (_desktopVideoFrameRefreshTimer?.IsEnabled == false)
|
||||||
|
{
|
||||||
|
_desktopVideoFrameRefreshTimer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopDesktopVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
if (_desktopVideoFrameRefreshTimer?.IsEnabled == true)
|
||||||
|
{
|
||||||
|
_desktopVideoFrameRefreshTimer.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDesktopVideoFrameRefreshTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
PushDesktopVideoFrameToWallpaperImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateVideoWallpaperPreviewVisibility()
|
||||||
|
{
|
||||||
|
var shouldShowPreview =
|
||||||
|
_wallpaperMediaType == WallpaperMediaType.Video &&
|
||||||
|
_isSettingsOpen &&
|
||||||
|
SettingsPage.IsVisible &&
|
||||||
|
WallpaperSettingsPanel.IsVisible &&
|
||||||
|
_wallpaperPreviewSnapshotBitmap is not null;
|
||||||
|
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = shouldShowPreview;
|
||||||
|
if (shouldShowPreview && !ReferenceEquals(WallpaperPreviewVideoImage.Source, _wallpaperPreviewSnapshotBitmap))
|
||||||
|
{
|
||||||
|
WallpaperPreviewVideoImage.Source = _wallpaperPreviewSnapshotBitmap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InvalidateVideoWallpaperPreviewSnapshot()
|
||||||
|
{
|
||||||
|
_wallpaperPreviewSnapshotPending = true;
|
||||||
|
_wallpaperPreviewSnapshotBitmap?.Dispose();
|
||||||
|
_wallpaperPreviewSnapshotBitmap = null;
|
||||||
|
WallpaperPreviewVideoImage.Source = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CaptureVideoWallpaperPreviewSnapshotFromStagingBuffer()
|
||||||
|
{
|
||||||
|
if (!_wallpaperPreviewSnapshotPending ||
|
||||||
|
_desktopVideoStagingBuffer is null ||
|
||||||
|
_desktopVideoFrameWidth <= 0 ||
|
||||||
|
_desktopVideoFrameHeight <= 0 ||
|
||||||
|
_desktopVideoFramePitch <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wallpaperPreviewSnapshotBitmap?.Dispose();
|
||||||
|
_wallpaperPreviewSnapshotBitmap = new WriteableBitmap(
|
||||||
|
new PixelSize(_desktopVideoFrameWidth, _desktopVideoFrameHeight),
|
||||||
|
new Vector(96, 96),
|
||||||
|
PixelFormat.Bgra8888,
|
||||||
|
AlphaFormat.Opaque);
|
||||||
|
|
||||||
|
using var framebuffer = _wallpaperPreviewSnapshotBitmap.Lock();
|
||||||
|
var rows = Math.Min(framebuffer.Size.Height, _desktopVideoFrameHeight);
|
||||||
|
var bytesPerRow = Math.Min(framebuffer.RowBytes, _desktopVideoFramePitch);
|
||||||
|
for (var row = 0; row < rows; row++)
|
||||||
|
{
|
||||||
|
var sourceOffset = row * _desktopVideoFramePitch;
|
||||||
|
var destinationPtr = IntPtr.Add(framebuffer.Address, row * framebuffer.RowBytes);
|
||||||
|
Marshal.Copy(_desktopVideoStagingBuffer, sourceOffset, destinationPtr, bytesPerRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
_wallpaperPreviewSnapshotPending = false;
|
||||||
|
WallpaperPreviewVideoImage.Source = _wallpaperPreviewSnapshotBitmap;
|
||||||
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ConfigureDesktopVideoRenderer()
|
private bool ConfigureDesktopVideoRenderer()
|
||||||
@@ -685,6 +774,7 @@ public partial class MainWindow
|
|||||||
(uint)_desktopVideoFrameHeight,
|
(uint)_desktopVideoFrameHeight,
|
||||||
(uint)_desktopVideoFramePitch);
|
(uint)_desktopVideoFramePitch);
|
||||||
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
|
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
|
||||||
|
InvalidateVideoWallpaperPreviewSnapshot();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -745,31 +835,6 @@ public partial class MainWindow
|
|||||||
private void OnDesktopVideoFrameDisplay(IntPtr opaque, IntPtr picture)
|
private void OnDesktopVideoFrameDisplay(IntPtr opaque, IntPtr picture)
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 1);
|
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 1);
|
||||||
ScheduleDesktopVideoFrameUiRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ScheduleDesktopVideoFrameUiRefresh()
|
|
||||||
{
|
|
||||||
if (Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 1) == 1)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
PushDesktopVideoFrameToWallpaperImage();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0);
|
|
||||||
if (Volatile.Read(ref _desktopVideoFrameDirtyFlag) == 1)
|
|
||||||
{
|
|
||||||
ScheduleDesktopVideoFrameUiRefresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, DispatcherPriority.Render);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PushDesktopVideoFrameToWallpaperImage()
|
private void PushDesktopVideoFrameToWallpaperImage()
|
||||||
@@ -812,18 +877,22 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
|
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CaptureVideoWallpaperPreviewSnapshotFromStagingBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReleaseDesktopVideoRendererResources()
|
private void ReleaseDesktopVideoRendererResources()
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0);
|
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0);
|
||||||
Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0);
|
|
||||||
|
|
||||||
if (DesktopVideoWallpaperImage is not null)
|
if (DesktopVideoWallpaperImage is not null)
|
||||||
{
|
{
|
||||||
DesktopVideoWallpaperImage.Source = null;
|
DesktopVideoWallpaperImage.Source = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InvalidateVideoWallpaperPreviewSnapshot();
|
||||||
|
WallpaperPreviewVideoImage.Source = null;
|
||||||
|
|
||||||
_desktopVideoBitmap?.Dispose();
|
_desktopVideoBitmap?.Dispose();
|
||||||
_desktopVideoBitmap = null;
|
_desktopVideoBitmap = null;
|
||||||
_desktopVideoStagingBuffer = null;
|
_desktopVideoStagingBuffer = null;
|
||||||
@@ -855,10 +924,8 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
EnsureVideoWallpaperPlayers();
|
EnsureVideoWallpaperPlayers();
|
||||||
if (_videoWallpaperPlayer is null ||
|
if (_videoWallpaperPlayer is null ||
|
||||||
_previewVideoWallpaperPlayer is null ||
|
|
||||||
_libVlc is null ||
|
_libVlc is null ||
|
||||||
DesktopVideoWallpaperImage is null ||
|
DesktopVideoWallpaperImage is null)
|
||||||
WallpaperPreviewVideoView is null)
|
|
||||||
{
|
{
|
||||||
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
||||||
StopVideoWallpaper();
|
StopVideoWallpaper();
|
||||||
@@ -873,15 +940,13 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
_videoWallpaperMedia?.Dispose();
|
_videoWallpaperMedia?.Dispose();
|
||||||
_previewVideoWallpaperMedia?.Dispose();
|
|
||||||
_videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
_videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
||||||
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
|
||||||
_videoWallpaperMedia.AddOption(":input-repeat=65535");
|
_videoWallpaperMedia.AddOption(":input-repeat=65535");
|
||||||
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
|
InvalidateVideoWallpaperPreviewSnapshot();
|
||||||
_videoWallpaperPlayer.Play(_videoWallpaperMedia);
|
_videoWallpaperPlayer.Play(_videoWallpaperMedia);
|
||||||
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
|
StartDesktopVideoFrameRefreshTimer();
|
||||||
DesktopVideoWallpaperImage.IsVisible = true;
|
DesktopVideoWallpaperImage.IsVisible = true;
|
||||||
WallpaperPreviewVideoView.IsVisible = true;
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -897,26 +962,17 @@ public partial class MainWindow
|
|||||||
DesktopVideoWallpaperImage.IsVisible = false;
|
DesktopVideoWallpaperImage.IsVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (WallpaperPreviewVideoView is not null)
|
WallpaperPreviewVideoImage.IsVisible = false;
|
||||||
{
|
|
||||||
WallpaperPreviewVideoView.IsVisible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_videoWallpaperPlayer is not null)
|
if (_videoWallpaperPlayer is not null)
|
||||||
{
|
{
|
||||||
_videoWallpaperPlayer.Stop();
|
_videoWallpaperPlayer.Stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_previewVideoWallpaperPlayer is not null)
|
StopDesktopVideoFrameRefreshTimer();
|
||||||
{
|
|
||||||
_previewVideoWallpaperPlayer.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
ReleaseDesktopVideoRendererResources();
|
ReleaseDesktopVideoRendererResources();
|
||||||
_videoWallpaperMedia?.Dispose();
|
_videoWallpaperMedia?.Dispose();
|
||||||
_videoWallpaperMedia = null;
|
_videoWallpaperMedia = null;
|
||||||
_previewVideoWallpaperMedia?.Dispose();
|
|
||||||
_previewVideoWallpaperMedia = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistSettings()
|
private void PersistSettings()
|
||||||
@@ -2379,7 +2435,14 @@ public partial class MainWindow
|
|||||||
_isSettingsOpen = true;
|
_isSettingsOpen = true;
|
||||||
UpdateDesktopPageAwareComponentContext();
|
UpdateDesktopPageAwareComponentContext();
|
||||||
UpdateAdaptiveTextSystem();
|
UpdateAdaptiveTextSystem();
|
||||||
ApplyWallpaperBrush();
|
if (_wallpaperMediaType == WallpaperMediaType.Video)
|
||||||
|
{
|
||||||
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyWallpaperBrush();
|
||||||
|
}
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
if (_settingsContentPanelTransform is not null)
|
if (_settingsContentPanelTransform is not null)
|
||||||
{
|
{
|
||||||
@@ -2387,6 +2450,7 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
SettingsPage.IsVisible = true;
|
SettingsPage.IsVisible = true;
|
||||||
SettingsPage.Opacity = 0;
|
SettingsPage.Opacity = 0;
|
||||||
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
UpdateSettingsViewportInsets(Math.Max(1, _currentDesktopCellSize));
|
UpdateSettingsViewportInsets(Math.Max(1, _currentDesktopCellSize));
|
||||||
|
|
||||||
UpdateWallpaperPreviewLayout();
|
UpdateWallpaperPreviewLayout();
|
||||||
@@ -2416,7 +2480,11 @@ public partial class MainWindow
|
|||||||
_isSettingsOpen = false;
|
_isSettingsOpen = false;
|
||||||
UpdateDesktopPageAwareComponentContext();
|
UpdateDesktopPageAwareComponentContext();
|
||||||
UpdateAdaptiveTextSystem();
|
UpdateAdaptiveTextSystem();
|
||||||
ApplyWallpaperBrush();
|
UpdateVideoWallpaperPreviewVisibility();
|
||||||
|
if (_wallpaperMediaType != WallpaperMediaType.Video)
|
||||||
|
{
|
||||||
|
ApplyWallpaperBrush();
|
||||||
|
}
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
|
||||||
if (immediate)
|
if (immediate)
|
||||||
@@ -2608,7 +2676,7 @@ public partial class MainWindow
|
|||||||
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
|
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
|
||||||
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
|
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
|
||||||
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
|
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
|
||||||
internal LibVLCSharp.Avalonia.VideoView? WallpaperPreviewVideoView => WallpaperSettingsPanel.FindControl<LibVLCSharp.Avalonia.VideoView>("WallpaperPreviewVideoView");
|
internal Image WallpaperPreviewVideoImage => WallpaperSettingsPanel.FindControl<Image>("WallpaperPreviewVideoImage")!;
|
||||||
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
|
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
|
||||||
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
|
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
|
||||||
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
|
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
|
||||||
@@ -2805,7 +2873,6 @@ public partial class MainWindow
|
|||||||
// --- LauncherSettingsPage ---
|
// --- LauncherSettingsPage ---
|
||||||
internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!;
|
internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!;
|
||||||
internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LauncherHiddenItemsSettingsExpander")!;
|
internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LauncherHiddenItemsSettingsExpander")!;
|
||||||
internal StackPanel LauncherHiddenItemsListPanel => LauncherSettingsPanel.FindControl<StackPanel>("LauncherHiddenItemsListPanel")!;
|
|
||||||
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
||||||
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
||||||
|
|
||||||
|
|||||||
@@ -124,21 +124,21 @@ public partial class MainWindow : Window
|
|||||||
private LibVLC? _libVlc;
|
private LibVLC? _libVlc;
|
||||||
private MediaPlayer? _videoWallpaperPlayer;
|
private MediaPlayer? _videoWallpaperPlayer;
|
||||||
private Media? _videoWallpaperMedia;
|
private Media? _videoWallpaperMedia;
|
||||||
private MediaPlayer? _previewVideoWallpaperPlayer;
|
|
||||||
private Media? _previewVideoWallpaperMedia;
|
|
||||||
private readonly object _desktopVideoFrameSync = new();
|
private readonly object _desktopVideoFrameSync = new();
|
||||||
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
|
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
|
||||||
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
|
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
|
||||||
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
|
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
|
||||||
|
private DispatcherTimer? _desktopVideoFrameRefreshTimer;
|
||||||
private IntPtr _desktopVideoFrameBufferPtr;
|
private IntPtr _desktopVideoFrameBufferPtr;
|
||||||
private byte[]? _desktopVideoStagingBuffer;
|
private byte[]? _desktopVideoStagingBuffer;
|
||||||
private WriteableBitmap? _desktopVideoBitmap;
|
private WriteableBitmap? _desktopVideoBitmap;
|
||||||
|
private WriteableBitmap? _wallpaperPreviewSnapshotBitmap;
|
||||||
private int _desktopVideoFrameWidth;
|
private int _desktopVideoFrameWidth;
|
||||||
private int _desktopVideoFrameHeight;
|
private int _desktopVideoFrameHeight;
|
||||||
private int _desktopVideoFramePitch;
|
private int _desktopVideoFramePitch;
|
||||||
private int _desktopVideoFrameBufferSize;
|
private int _desktopVideoFrameBufferSize;
|
||||||
private int _desktopVideoFrameDirtyFlag;
|
private int _desktopVideoFrameDirtyFlag;
|
||||||
private int _desktopVideoFrameUiRefreshScheduledFlag;
|
private bool _wallpaperPreviewSnapshotPending;
|
||||||
private string? _wallpaperPath;
|
private string? _wallpaperPath;
|
||||||
private string _wallpaperStatus = "Current background uses solid color.";
|
private string _wallpaperStatus = "Current background uses solid color.";
|
||||||
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
||||||
@@ -384,15 +384,15 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
StopVideoWallpaper();
|
StopVideoWallpaper();
|
||||||
_previewVideoWallpaperMedia?.Dispose();
|
|
||||||
_previewVideoWallpaperMedia = null;
|
|
||||||
_previewVideoWallpaperPlayer?.Dispose();
|
|
||||||
_previewVideoWallpaperPlayer = null;
|
|
||||||
DisposeLauncherResources();
|
DisposeLauncherResources();
|
||||||
_videoWallpaperMedia?.Dispose();
|
_videoWallpaperMedia?.Dispose();
|
||||||
_videoWallpaperMedia = null;
|
_videoWallpaperMedia = null;
|
||||||
_videoWallpaperPlayer?.Dispose();
|
_videoWallpaperPlayer?.Dispose();
|
||||||
_videoWallpaperPlayer = null;
|
_videoWallpaperPlayer = null;
|
||||||
|
_desktopVideoFrameRefreshTimer?.Stop();
|
||||||
|
_desktopVideoFrameRefreshTimer = null;
|
||||||
|
_wallpaperPreviewSnapshotBitmap?.Dispose();
|
||||||
|
_wallpaperPreviewSnapshotBitmap = null;
|
||||||
_libVlc?.Dispose();
|
_libVlc?.Dispose();
|
||||||
_libVlc = null;
|
_libVlc = null;
|
||||||
if (_weatherDataService is IDisposable weatherServiceDisposable)
|
if (_weatherDataService is IDisposable weatherServiceDisposable)
|
||||||
|
|||||||
@@ -31,12 +31,6 @@
|
|||||||
IsVisible="False"
|
IsVisible="False"
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
Text="No hidden items." />
|
Text="No hidden items." />
|
||||||
<ScrollViewer MaxHeight="420"
|
|
||||||
VerticalScrollBarVisibility="Auto"
|
|
||||||
HorizontalScrollBarVisibility="Disabled">
|
|
||||||
<StackPanel x:Name="LauncherHiddenItemsListPanel"
|
|
||||||
Spacing="8" />
|
|
||||||
</ScrollViewer>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ui:SettingsExpander.Footer>
|
</ui:SettingsExpander.Footer>
|
||||||
</ui:SettingsExpander>
|
</ui:SettingsExpander>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
xmlns:fi="using:FluentIcons.Avalonia"
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
|
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
|
||||||
xmlns:comp="using:LanMountainDesktop.Views.Components"
|
xmlns:comp="using:LanMountainDesktop.Views.Components"
|
||||||
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
|
||||||
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage">
|
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage">
|
||||||
<Grid x:Name="WallpaperSettingsPanel"
|
<Grid x:Name="WallpaperSettingsPanel"
|
||||||
@@ -37,11 +36,12 @@
|
|||||||
CornerRadius="12"
|
CornerRadius="12"
|
||||||
Background="#30111827">
|
Background="#30111827">
|
||||||
<Grid>
|
<Grid>
|
||||||
<vlc:VideoView x:Name="WallpaperPreviewVideoView"
|
<Image x:Name="WallpaperPreviewVideoImage"
|
||||||
IsVisible="False"
|
IsVisible="False"
|
||||||
IsHitTestVisible="False"
|
IsHitTestVisible="False"
|
||||||
HorizontalAlignment="Stretch"
|
Stretch="UniformToFill"
|
||||||
VerticalAlignment="Stretch" />
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch" />
|
||||||
|
|
||||||
<Grid x:Name="WallpaperPreviewGrid"
|
<Grid x:Name="WallpaperPreviewGrid"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using LibVLCSharp.Avalonia;
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
public partial class SettingsWindow
|
public partial class SettingsWindow
|
||||||
@@ -14,7 +13,7 @@ public partial class SettingsWindow
|
|||||||
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
|
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
|
||||||
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
|
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
|
||||||
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
|
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
|
||||||
internal LibVLCSharp.Avalonia.VideoView? WallpaperPreviewVideoView => WallpaperSettingsPanel.FindControl<LibVLCSharp.Avalonia.VideoView>("WallpaperPreviewVideoView");
|
internal Image WallpaperPreviewVideoImage => WallpaperSettingsPanel.FindControl<Image>("WallpaperPreviewVideoImage")!;
|
||||||
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
|
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
|
||||||
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
|
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
|
||||||
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
|
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
|
||||||
@@ -211,7 +210,6 @@ public partial class SettingsWindow
|
|||||||
// --- LauncherSettingsPage ---
|
// --- LauncherSettingsPage ---
|
||||||
internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!;
|
internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!;
|
||||||
internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LauncherHiddenItemsSettingsExpander")!;
|
internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LauncherHiddenItemsSettingsExpander")!;
|
||||||
internal StackPanel LauncherHiddenItemsListPanel => LauncherSettingsPanel.FindControl<StackPanel>("LauncherHiddenItemsListPanel")!;
|
|
||||||
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
||||||
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public partial class SettingsWindow
|
|||||||
_previewVideoWallpaperPlayer = null;
|
_previewVideoWallpaperPlayer = null;
|
||||||
_previewVideoWallpaperMedia?.Dispose();
|
_previewVideoWallpaperMedia?.Dispose();
|
||||||
_previewVideoWallpaperMedia = null;
|
_previewVideoWallpaperMedia = null;
|
||||||
|
_previewVideoFrameRefreshTimer?.Stop();
|
||||||
|
_previewVideoFrameRefreshTimer = null;
|
||||||
_libVlc?.Dispose();
|
_libVlc?.Dispose();
|
||||||
_libVlc = null;
|
_libVlc = null;
|
||||||
|
|
||||||
@@ -254,6 +256,7 @@ public partial class SettingsWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
SyncVideoWallpaperPreviewPlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistSettings()
|
private void PersistSettings()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
@@ -12,6 +13,7 @@ using Avalonia.Media.Imaging;
|
|||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
using LibVLCSharp.Shared;
|
using LibVLCSharp.Shared;
|
||||||
@@ -157,7 +159,7 @@ public partial class SettingsWindow
|
|||||||
{
|
{
|
||||||
DesktopWallpaperLayer.Background = Brushes.Transparent;
|
DesktopWallpaperLayer.Background = Brushes.Transparent;
|
||||||
WallpaperPreviewViewport.Background = GetThemeDefaultDesktopBackground();
|
WallpaperPreviewViewport.Background = GetThemeDefaultDesktopBackground();
|
||||||
PlayVideoWallpaper(_wallpaperVideoPath);
|
SyncVideoWallpaperPreviewPlayback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +177,36 @@ public partial class SettingsWindow
|
|||||||
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
|
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SyncVideoWallpaperPreviewPlayback()
|
||||||
|
{
|
||||||
|
var shouldPlay =
|
||||||
|
_wallpaperMediaType == WallpaperMediaType.Video &&
|
||||||
|
!string.IsNullOrWhiteSpace(_wallpaperVideoPath) &&
|
||||||
|
WallpaperSettingsPanel.IsVisible;
|
||||||
|
|
||||||
|
if (!shouldPlay)
|
||||||
|
{
|
||||||
|
if (_previewVideoWallpaperPlayer?.IsPlaying == true)
|
||||||
|
{
|
||||||
|
StopPreviewVideoCapture(clearSnapshot: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = WallpaperPreviewVideoImage.Source is not null && WallpaperSettingsPanel.IsVisible;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WallpaperPreviewVideoImage.Source is not null)
|
||||||
|
{
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_previewVideoWallpaperMedia is null || _previewVideoSnapshotPending)
|
||||||
|
{
|
||||||
|
PlayVideoWallpaper(_wallpaperVideoPath!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateWallpaperDisplay()
|
private void UpdateWallpaperDisplay()
|
||||||
{
|
{
|
||||||
WallpaperPathTextBlock.Text = string.IsNullOrWhiteSpace(_wallpaperPath)
|
WallpaperPathTextBlock.Text = string.IsNullOrWhiteSpace(_wallpaperPath)
|
||||||
@@ -417,11 +449,239 @@ public partial class SettingsWindow
|
|||||||
private void EnsureVideoWallpaperPlayers()
|
private void EnsureVideoWallpaperPlayers()
|
||||||
{
|
{
|
||||||
Core.Initialize();
|
Core.Initialize();
|
||||||
_libVlc ??= new LibVLC();
|
_libVlc ??= new LibVLC("--quiet");
|
||||||
_previewVideoWallpaperPlayer ??= new MediaPlayer(_libVlc);
|
if (_previewVideoWallpaperPlayer is null)
|
||||||
if (WallpaperPreviewVideoView is not null)
|
|
||||||
{
|
{
|
||||||
WallpaperPreviewVideoView.MediaPlayer = _previewVideoWallpaperPlayer;
|
_previewVideoWallpaperPlayer = new MediaPlayer(_libVlc)
|
||||||
|
{
|
||||||
|
EnableHardwareDecoding = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsurePreviewVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
if (_previewVideoFrameRefreshTimer is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_previewVideoFrameRefreshTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(33)
|
||||||
|
};
|
||||||
|
_previewVideoFrameRefreshTimer.Tick += OnPreviewVideoFrameRefreshTimerTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartPreviewVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
EnsurePreviewVideoFrameRefreshTimer();
|
||||||
|
if (_previewVideoFrameRefreshTimer?.IsEnabled == false)
|
||||||
|
{
|
||||||
|
_previewVideoFrameRefreshTimer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopPreviewVideoFrameRefreshTimer()
|
||||||
|
{
|
||||||
|
if (_previewVideoFrameRefreshTimer?.IsEnabled == true)
|
||||||
|
{
|
||||||
|
_previewVideoFrameRefreshTimer.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPreviewVideoFrameRefreshTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
PushPreviewVideoFrameToWallpaperImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopPreviewVideoCapture(bool clearSnapshot)
|
||||||
|
{
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = false;
|
||||||
|
_previewVideoWallpaperPlayer?.Stop();
|
||||||
|
StopPreviewVideoFrameRefreshTimer();
|
||||||
|
_previewVideoWallpaperMedia?.Dispose();
|
||||||
|
_previewVideoWallpaperMedia = null;
|
||||||
|
_previewVideoSnapshotPending = false;
|
||||||
|
|
||||||
|
if (clearSnapshot)
|
||||||
|
{
|
||||||
|
ReleasePreviewVideoRendererResources();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ConfigurePreviewVideoRenderer()
|
||||||
|
{
|
||||||
|
if (_previewVideoWallpaperPlayer is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostWidth = Math.Max(1, WallpaperPreviewViewport.Bounds.Width);
|
||||||
|
var hostHeight = Math.Max(1, WallpaperPreviewViewport.Bounds.Height);
|
||||||
|
var pixelWidth = Math.Max(1, (int)Math.Round(hostWidth * RenderScaling));
|
||||||
|
var pixelHeight = Math.Max(1, (int)Math.Round(hostHeight * RenderScaling));
|
||||||
|
const int maxPixelCount = 1280 * 720;
|
||||||
|
var pixelCount = (long)pixelWidth * pixelHeight;
|
||||||
|
if (pixelCount > maxPixelCount)
|
||||||
|
{
|
||||||
|
var scale = Math.Sqrt((double)maxPixelCount / pixelCount);
|
||||||
|
pixelWidth = Math.Max(1, (int)Math.Round(pixelWidth * scale));
|
||||||
|
pixelHeight = Math.Max(1, (int)Math.Round(pixelHeight * scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
var pitch = pixelWidth * 4;
|
||||||
|
var bufferSize = pitch * pixelHeight;
|
||||||
|
if (bufferSize <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pixelWidth == _previewVideoFrameWidth &&
|
||||||
|
pixelHeight == _previewVideoFrameHeight &&
|
||||||
|
_previewVideoFrameBufferPtr != IntPtr.Zero &&
|
||||||
|
_previewVideoBitmap is not null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleasePreviewVideoRendererResources();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_previewVideoFrameWidth = pixelWidth;
|
||||||
|
_previewVideoFrameHeight = pixelHeight;
|
||||||
|
_previewVideoFramePitch = pitch;
|
||||||
|
_previewVideoFrameBufferSize = bufferSize;
|
||||||
|
_previewVideoFrameBufferPtr = Marshal.AllocHGlobal(_previewVideoFrameBufferSize);
|
||||||
|
_previewVideoStagingBuffer = new byte[_previewVideoFrameBufferSize];
|
||||||
|
_previewVideoBitmap = new WriteableBitmap(
|
||||||
|
new PixelSize(_previewVideoFrameWidth, _previewVideoFrameHeight),
|
||||||
|
new Vector(96, 96),
|
||||||
|
PixelFormat.Bgra8888,
|
||||||
|
AlphaFormat.Opaque);
|
||||||
|
EnsurePreviewVideoCallbacks();
|
||||||
|
_previewVideoWallpaperPlayer.SetVideoCallbacks(
|
||||||
|
_previewVideoLockCallback!,
|
||||||
|
_previewVideoUnlockCallback!,
|
||||||
|
_previewVideoDisplayCallback!);
|
||||||
|
_previewVideoWallpaperPlayer.SetVideoFormat(
|
||||||
|
"RV32",
|
||||||
|
(uint)_previewVideoFrameWidth,
|
||||||
|
(uint)_previewVideoFrameHeight,
|
||||||
|
(uint)_previewVideoFramePitch);
|
||||||
|
WallpaperPreviewVideoImage.Source = _previewVideoBitmap;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
ReleasePreviewVideoRendererResources();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsurePreviewVideoCallbacks()
|
||||||
|
{
|
||||||
|
_previewVideoLockCallback ??= OnPreviewVideoFrameLock;
|
||||||
|
_previewVideoUnlockCallback ??= OnPreviewVideoFrameUnlock;
|
||||||
|
_previewVideoDisplayCallback ??= OnPreviewVideoFrameDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntPtr OnPreviewVideoFrameLock(IntPtr opaque, IntPtr planes)
|
||||||
|
{
|
||||||
|
Monitor.Enter(_previewVideoFrameSync);
|
||||||
|
if (_previewVideoFrameBufferPtr == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
Marshal.WriteIntPtr(planes, IntPtr.Zero);
|
||||||
|
Monitor.Exit(_previewVideoFrameSync);
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
Marshal.WriteIntPtr(planes, _previewVideoFrameBufferPtr);
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPreviewVideoFrameUnlock(IntPtr opaque, IntPtr picture, IntPtr planes)
|
||||||
|
{
|
||||||
|
if (Monitor.IsEntered(_previewVideoFrameSync))
|
||||||
|
{
|
||||||
|
Monitor.Exit(_previewVideoFrameSync);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPreviewVideoFrameDisplay(IntPtr opaque, IntPtr picture)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PushPreviewVideoFrameToWallpaperImage()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 0) == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_previewVideoBitmap is null ||
|
||||||
|
_previewVideoStagingBuffer is null ||
|
||||||
|
_previewVideoFrameBufferPtr == IntPtr.Zero ||
|
||||||
|
_previewVideoFrameBufferSize <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_previewVideoFrameSync)
|
||||||
|
{
|
||||||
|
if (_previewVideoFrameBufferPtr == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Marshal.Copy(_previewVideoFrameBufferPtr, _previewVideoStagingBuffer, 0, _previewVideoFrameBufferSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var framebuffer = _previewVideoBitmap.Lock();
|
||||||
|
var rows = Math.Min(framebuffer.Size.Height, _previewVideoFrameHeight);
|
||||||
|
var bytesPerRow = Math.Min(framebuffer.RowBytes, _previewVideoFramePitch);
|
||||||
|
for (var row = 0; row < rows; row++)
|
||||||
|
{
|
||||||
|
var sourceOffset = row * _previewVideoFramePitch;
|
||||||
|
var destinationPtr = IntPtr.Add(framebuffer.Address, row * framebuffer.RowBytes);
|
||||||
|
Marshal.Copy(_previewVideoStagingBuffer, sourceOffset, destinationPtr, bytesPerRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ReferenceEquals(WallpaperPreviewVideoImage.Source, _previewVideoBitmap))
|
||||||
|
{
|
||||||
|
WallpaperPreviewVideoImage.Source = _previewVideoBitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_previewVideoSnapshotPending)
|
||||||
|
{
|
||||||
|
_previewVideoSnapshotPending = false;
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = WallpaperSettingsPanel.IsVisible;
|
||||||
|
StopPreviewVideoCapture(clearSnapshot: false);
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = WallpaperSettingsPanel.IsVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleasePreviewVideoRendererResources()
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 0);
|
||||||
|
WallpaperPreviewVideoImage.Source = null;
|
||||||
|
_previewVideoBitmap?.Dispose();
|
||||||
|
_previewVideoBitmap = null;
|
||||||
|
_previewVideoStagingBuffer = null;
|
||||||
|
_previewVideoFrameWidth = 0;
|
||||||
|
_previewVideoFrameHeight = 0;
|
||||||
|
_previewVideoFramePitch = 0;
|
||||||
|
_previewVideoFrameBufferSize = 0;
|
||||||
|
|
||||||
|
lock (_previewVideoFrameSync)
|
||||||
|
{
|
||||||
|
if (_previewVideoFrameBufferPtr != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(_previewVideoFrameBufferPtr);
|
||||||
|
_previewVideoFrameBufferPtr = IntPtr.Zero;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +697,14 @@ public partial class SettingsWindow
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
EnsureVideoWallpaperPlayers();
|
EnsureVideoWallpaperPlayers();
|
||||||
if (_previewVideoWallpaperPlayer is null || _libVlc is null || WallpaperPreviewVideoView is null)
|
if (_previewVideoWallpaperPlayer is null || _libVlc is null)
|
||||||
|
{
|
||||||
|
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
||||||
|
StopVideoWallpaper();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ConfigurePreviewVideoRenderer())
|
||||||
{
|
{
|
||||||
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
||||||
StopVideoWallpaper();
|
StopVideoWallpaper();
|
||||||
@@ -447,8 +714,10 @@ public partial class SettingsWindow
|
|||||||
_previewVideoWallpaperMedia?.Dispose();
|
_previewVideoWallpaperMedia?.Dispose();
|
||||||
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
||||||
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
|
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
|
||||||
|
_previewVideoSnapshotPending = true;
|
||||||
|
WallpaperPreviewVideoImage.IsVisible = false;
|
||||||
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
|
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
|
||||||
WallpaperPreviewVideoView.IsVisible = true;
|
StartPreviewVideoFrameRefreshTimer();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -459,14 +728,7 @@ public partial class SettingsWindow
|
|||||||
|
|
||||||
private void StopVideoWallpaper()
|
private void StopVideoWallpaper()
|
||||||
{
|
{
|
||||||
if (WallpaperPreviewVideoView is not null)
|
StopPreviewVideoCapture(clearSnapshot: true);
|
||||||
{
|
|
||||||
WallpaperPreviewVideoView.IsVisible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_previewVideoWallpaperPlayer?.Stop();
|
|
||||||
_previewVideoWallpaperMedia?.Dispose();
|
|
||||||
_previewVideoWallpaperMedia = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnRecommendedColorClick(object? sender, RoutedEventArgs e)
|
private void OnRecommendedColorClick(object? sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using Avalonia.Layout;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using FluentAvalonia.UI.Controls;
|
||||||
using FluentIcons.Common;
|
using FluentIcons.Common;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
@@ -626,18 +627,18 @@ public partial class SettingsWindow
|
|||||||
return string.Create(CultureInfo.InvariantCulture, $"{temperatureC:0.#}°C");
|
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
|
return weatherCode switch
|
||||||
{
|
{
|
||||||
0 => isNight ? Symbol.WeatherMoon : Symbol.WeatherSunny,
|
0 => isNight ? FluentIcons.Common.Symbol.WeatherMoon : FluentIcons.Common.Symbol.WeatherSunny,
|
||||||
1 or 2 => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay,
|
1 or 2 => isNight ? FluentIcons.Common.Symbol.WeatherPartlyCloudyNight : FluentIcons.Common.Symbol.WeatherPartlyCloudyDay,
|
||||||
3 or 7 => Symbol.WeatherRainShowersDay,
|
3 or 7 => FluentIcons.Common.Symbol.WeatherRainShowersDay,
|
||||||
8 or 9 => Symbol.WeatherRain,
|
8 or 9 => FluentIcons.Common.Symbol.WeatherRain,
|
||||||
4 => Symbol.WeatherThunderstorm,
|
4 => FluentIcons.Common.Symbol.WeatherThunderstorm,
|
||||||
13 or 14 or 15 or 16 => Symbol.WeatherSnow,
|
13 or 14 or 15 or 16 => FluentIcons.Common.Symbol.WeatherSnow,
|
||||||
18 or 32 => Symbol.WeatherFog,
|
18 or 32 => FluentIcons.Common.Symbol.WeatherFog,
|
||||||
_ => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay
|
_ => isNight ? FluentIcons.Common.Symbol.WeatherPartlyCloudyNight : FluentIcons.Common.Symbol.WeatherPartlyCloudyDay
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,7 +783,7 @@ public partial class SettingsWindow
|
|||||||
|
|
||||||
private void RenderLauncherHiddenItemsList()
|
private void RenderLauncherHiddenItemsList()
|
||||||
{
|
{
|
||||||
LauncherHiddenItemsListPanel.Children.Clear();
|
LauncherHiddenItemsSettingsExpander.Items.Clear();
|
||||||
var hiddenItems = BuildLauncherHiddenItems();
|
var hiddenItems = BuildLauncherHiddenItems();
|
||||||
LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0;
|
LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0;
|
||||||
if (hiddenItems.Count == 0)
|
if (hiddenItems.Count == 0)
|
||||||
@@ -792,7 +793,7 @@ public partial class SettingsWindow
|
|||||||
|
|
||||||
foreach (var hiddenItem in hiddenItems)
|
foreach (var hiddenItem in hiddenItems)
|
||||||
{
|
{
|
||||||
LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem));
|
LauncherHiddenItemsSettingsExpander.Items.Add(CreateLauncherHiddenItemRow(hiddenItem));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,82 +865,58 @@ public partial class SettingsWindow
|
|||||||
return string.IsNullOrWhiteSpace(fileName) ? key : fileName;
|
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
|
var typeText = hiddenItem.Kind == LauncherEntryKind.Folder
|
||||||
? L("settings.launcher.hidden_type_folder", "Folder")
|
? L("settings.launcher.hidden_type_folder", "Folder")
|
||||||
: L("settings.launcher.hidden_type_shortcut", "Shortcut");
|
: 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
|
var restoreButton = new Button
|
||||||
{
|
{
|
||||||
Content = L("settings.launcher.restore_button", "Show Again"),
|
Width = 36,
|
||||||
MinWidth = 110,
|
Height = 36,
|
||||||
Padding = new Thickness(12, 6),
|
Padding = new Thickness(0),
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
|
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
|
||||||
};
|
};
|
||||||
|
restoreButton.Content = new FluentIcons.Avalonia.Fluent.SymbolIcon
|
||||||
|
{
|
||||||
|
Symbol = FluentIcons.Common.Symbol.Eye,
|
||||||
|
IconVariant = FluentIcons.Common.IconVariant.Regular,
|
||||||
|
FontSize = 18,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
ToolTip.SetTip(restoreButton, L("settings.launcher.restore_button", "Unhide"));
|
||||||
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
|
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
|
||||||
|
|
||||||
var row = new Grid
|
return new SettingsExpanderItem
|
||||||
{
|
{
|
||||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
Content = hiddenItem.DisplayName,
|
||||||
ColumnSpacing = 10
|
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" },
|
return new ImageIconSource
|
||||||
BorderThickness = new Thickness(0),
|
{
|
||||||
CornerRadius = new CornerRadius(14),
|
Source = hiddenItem.IconBitmap
|
||||||
Padding = new Thickness(10, 8),
|
};
|
||||||
Child = row
|
}
|
||||||
|
|
||||||
|
return new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||||
|
{
|
||||||
|
Symbol = hiddenItem.Kind == LauncherEntryKind.Folder
|
||||||
|
? FluentIcons.Common.Symbol.Folder
|
||||||
|
: FluentIcons.Common.Symbol.Apps,
|
||||||
|
IconVariant = FluentIcons.Common.IconVariant.Regular
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,20 @@ public partial class SettingsWindow : Window
|
|||||||
private MediaPlayer? _previewVideoWallpaperPlayer;
|
private MediaPlayer? _previewVideoWallpaperPlayer;
|
||||||
private Media? _previewVideoWallpaperMedia;
|
private Media? _previewVideoWallpaperMedia;
|
||||||
private LibVLC? _libVlc;
|
private LibVLC? _libVlc;
|
||||||
|
private readonly object _previewVideoFrameSync = new();
|
||||||
|
private MediaPlayer.LibVLCVideoLockCb? _previewVideoLockCallback;
|
||||||
|
private MediaPlayer.LibVLCVideoUnlockCb? _previewVideoUnlockCallback;
|
||||||
|
private MediaPlayer.LibVLCVideoDisplayCb? _previewVideoDisplayCallback;
|
||||||
|
private DispatcherTimer? _previewVideoFrameRefreshTimer;
|
||||||
|
private IntPtr _previewVideoFrameBufferPtr;
|
||||||
|
private byte[]? _previewVideoStagingBuffer;
|
||||||
|
private WriteableBitmap? _previewVideoBitmap;
|
||||||
|
private int _previewVideoFrameWidth;
|
||||||
|
private int _previewVideoFrameHeight;
|
||||||
|
private int _previewVideoFramePitch;
|
||||||
|
private int _previewVideoFrameBufferSize;
|
||||||
|
private int _previewVideoFrameDirtyFlag;
|
||||||
|
private bool _previewVideoSnapshotPending;
|
||||||
private string? _wallpaperPath;
|
private string? _wallpaperPath;
|
||||||
private string _wallpaperStatus = "Current background uses solid color.";
|
private string _wallpaperStatus = "Current background uses solid color.";
|
||||||
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
||||||
|
|||||||
418
LanMountainDesktop/installer/ChineseSimplified.isl
Normal file
418
LanMountainDesktop/installer/ChineseSimplified.isl
Normal file
@@ -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您要继续吗?
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
#define MyAppName "LanMountainDesktop"
|
#define MyAppName "LanMountainDesktop"
|
||||||
#define MyAppPublisher "LanMountainDesktop Team"
|
#define MyAppPublisher "LanMountainDesktop Team"
|
||||||
#define MyAppExeName "LanMountainDesktop.exe"
|
#define MyAppExeName "LanMountainDesktop.exe"
|
||||||
|
#define MyAppId "{{5A058B0D-F95D-4A18-B9A0-93F843655DDB}"
|
||||||
|
#define MyAppRegistryId "{5A058B0D-F95D-4A18-B9A0-93F843655DDB}"
|
||||||
|
|
||||||
#ifndef MyAppVersion
|
#ifndef MyAppVersion
|
||||||
#define MyAppVersion "0.0.0"
|
#define MyAppVersion "0.0.0"
|
||||||
@@ -19,13 +21,16 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
[Setup]
|
[Setup]
|
||||||
AppId={{5A058B0D-F95D-4A18-B9A0-93F843655DDB}
|
AppId={#MyAppId}
|
||||||
AppName={#MyAppName}
|
AppName={#MyAppName}
|
||||||
AppVersion={#MyAppVersion}
|
AppVersion={#MyAppVersion}
|
||||||
AppPublisher={#MyAppPublisher}
|
AppPublisher={#MyAppPublisher}
|
||||||
DefaultDirName={autopf}\{#MyAppName}
|
DefaultDirName={autopf}\{#MyAppName}
|
||||||
DisableDirPage=no
|
DisableDirPage=no
|
||||||
UsePreviousAppDir=no
|
UsePreviousAppDir=no
|
||||||
|
ShowLanguageDialog=yes
|
||||||
|
UsePreviousLanguage=no
|
||||||
|
LanguageDetectionMethod=uilanguage
|
||||||
DefaultGroupName={#MyAppName}
|
DefaultGroupName={#MyAppName}
|
||||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||||
OutputDir={#MyOutputDir}
|
OutputDir={#MyOutputDir}
|
||||||
@@ -34,6 +39,9 @@ Compression=lzma2/ultra64
|
|||||||
SolidCompression=yes
|
SolidCompression=yes
|
||||||
WizardStyle=modern
|
WizardStyle=modern
|
||||||
PrivilegesRequired=admin
|
PrivilegesRequired=admin
|
||||||
|
CloseApplications=yes
|
||||||
|
CloseApplicationsFilter={#MyAppExeName}
|
||||||
|
RestartApplications=no
|
||||||
DisableProgramGroupPage=yes
|
DisableProgramGroupPage=yes
|
||||||
|
|
||||||
#if MyAppArch == "x64"
|
#if MyAppArch == "x64"
|
||||||
@@ -47,10 +55,55 @@ ArchitecturesAllowed=x86compatible
|
|||||||
|
|
||||||
[Languages]
|
[Languages]
|
||||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
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]
|
[Tasks]
|
||||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
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]
|
[Files]
|
||||||
Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
@@ -60,19 +113,316 @@ Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
|||||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||||
|
|
||||||
[Registry]
|
[Registry]
|
||||||
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue
|
Root: HKA; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
[Code]
|
[Code]
|
||||||
const
|
const
|
||||||
|
UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1';
|
||||||
WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
|
WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
|
||||||
WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703';
|
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;
|
function IsWebView2RuntimeInstalled(): Boolean;
|
||||||
var
|
var
|
||||||
VersionValue: string;
|
VersionValue: String;
|
||||||
begin
|
begin
|
||||||
Result :=
|
Result :=
|
||||||
RegQueryStringValue(HKLM64, WebView2RuntimeKeyPath, 'pv', VersionValue) or
|
RegQueryStringValue(HKLM64, WebView2RuntimeKeyPath, 'pv', VersionValue) or
|
||||||
@@ -92,16 +442,16 @@ begin
|
|||||||
end;
|
end;
|
||||||
|
|
||||||
if MsgBox(
|
if MsgBox(
|
||||||
'Microsoft Edge WebView2 Runtime is required for the browser component.'#13#10#13#10 +
|
CustomMessage('WebView2MissingMessage') + #13#10#13#10 +
|
||||||
'Click "Yes" to open the official download page. Install it first, then run this installer again.',
|
CustomMessage('WebView2MissingAction'),
|
||||||
mbConfirmation,
|
mbConfirmation,
|
||||||
MB_YESNO) = IDYES then
|
MB_YESNO) = IDYES then
|
||||||
begin
|
begin
|
||||||
if not ShellExec('open', WebView2RuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then
|
if not ShellExec('open', WebView2RuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then
|
||||||
begin
|
begin
|
||||||
MsgBox(
|
MsgBox(
|
||||||
'Unable to open the download page automatically.'#13#10 +
|
CustomMessage('WebView2OpenFailedMessage') + #13#10 +
|
||||||
'Please open this URL manually:'#13#10 + WebView2RuntimeDownloadUrl,
|
CustomMessage('WebView2OpenFailedAction') + #13#10 + WebView2RuntimeDownloadUrl,
|
||||||
mbError,
|
mbError,
|
||||||
MB_OK);
|
MB_OK);
|
||||||
end;
|
end;
|
||||||
@@ -109,3 +459,84 @@ begin
|
|||||||
|
|
||||||
Result := False;
|
Result := False;
|
||||||
end;
|
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;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public partial class MainWindow
|
|||||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||||
|
internal FluentAvalonia.UI.Controls.SettingsExpander ImportPluginPackageSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("ImportPluginPackageSettingsExpander")!;
|
||||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ public partial class MainWindow
|
|||||||
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||||
InstalledPluginsSettingsExpander.Description = L(
|
InstalledPluginsSettingsExpander.Description = L(
|
||||||
"settings.plugins.installed_desc",
|
"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(
|
PluginRestartHintTextBlock.Text = L(
|
||||||
"settings.plugins.restart_hint",
|
"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.");
|
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||||
PluginSettingsPanel.RefreshFromRuntime();
|
PluginSettingsPanel.RefreshFromRuntime();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using System.Text;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Plugins;
|
namespace LanMountainDesktop.Plugins;
|
||||||
@@ -346,6 +347,9 @@ public sealed class PluginLoader
|
|||||||
private string ExtractPackage(string packagePath, string pluginsRootDirectory)
|
private string ExtractPackage(string packagePath, string pluginsRootDirectory)
|
||||||
{
|
{
|
||||||
var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath);
|
var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath);
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginLoader",
|
||||||
|
$"Extracting package '{packagePath}' to '{extractionDirectory}'.");
|
||||||
RecreateDirectory(extractionDirectory);
|
RecreateDirectory(extractionDirectory);
|
||||||
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
|
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
|
||||||
return extractionDirectory;
|
return extractionDirectory;
|
||||||
@@ -381,7 +385,7 @@ public sealed class PluginLoader
|
|||||||
{
|
{
|
||||||
if (Directory.Exists(directoryPath))
|
if (Directory.Exists(directoryPath))
|
||||||
{
|
{
|
||||||
Directory.Delete(directoryPath, recursive: true);
|
FileOperationRetryHelper.DeleteDirectoryWithRetry(directoryPath, recursive: true, "PluginLoader");
|
||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(directoryPath);
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
47
LanMountainDesktop/plugins/PluginMarketIconService.cs
Normal file
47
LanMountainDesktop/plugins/PluginMarketIconService.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.SettingsPages;
|
||||||
|
|
||||||
|
internal sealed class AirAppMarketIconService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
public AirAppMarketIconService()
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(20)
|
||||||
|
};
|
||||||
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Bitmap> LoadAsync(
|
||||||
|
AirAppMarketPluginEntry plugin,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plugin);
|
||||||
|
|
||||||
|
if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.IconUrl, out var localIconPath))
|
||||||
|
{
|
||||||
|
return new Bitmap(localIconPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await _httpClient.GetAsync(plugin.IconUrl, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
using var memory = new MemoryStream();
|
||||||
|
await stream.CopyToAsync(memory, cancellationToken);
|
||||||
|
memory.Position = 0;
|
||||||
|
return new Bitmap(memory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ namespace LanMountainDesktop.Views.SettingsPages;
|
|||||||
internal sealed class AirAppMarketInstallService : IDisposable
|
internal sealed class AirAppMarketInstallService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly PluginRuntimeService _runtime;
|
private readonly PluginRuntimeService _runtime;
|
||||||
|
private readonly PluginsInstallHelperClient _helperClient = new();
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ResumableDownloadService _downloadService;
|
private readonly ResumableDownloadService _downloadService;
|
||||||
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
|
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
|
||||||
@@ -44,7 +45,13 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
|
||||||
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken);
|
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))
|
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
|
||||||
{
|
{
|
||||||
@@ -83,15 +90,46 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
|
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var manifest = _runtime.InstallPluginPackage(downloadPath);
|
PluginManifest manifest;
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
var helperResult = await _helperClient.InstallPackageAsync(
|
||||||
|
downloadPath,
|
||||||
|
_runtime.PluginsDirectory,
|
||||||
|
cancellationToken);
|
||||||
|
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
|
||||||
|
{
|
||||||
|
return new AirAppMarketInstallResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
helperResult.ErrorMessage ?? "Plugins install helper failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
manifest = _runtime.InstallPluginPackage(downloadPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'.");
|
||||||
return new AirAppMarketInstallResult(true, manifest, null);
|
return new AirAppMarketInstallResult(true, manifest, null);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Error(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Install failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.",
|
||||||
|
ex);
|
||||||
return new AirAppMarketInstallResult(false, null, ex.Message);
|
return new AirAppMarketInstallResult(false, null, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.IO;
|
|||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
@@ -16,6 +17,8 @@ namespace LanMountainDesktop.Services;
|
|||||||
|
|
||||||
public sealed class PluginRuntimeService : IDisposable
|
public sealed class PluginRuntimeService : IDisposable
|
||||||
{
|
{
|
||||||
|
private const string PendingDeletionFileName = ".pending-plugin-deletions.json";
|
||||||
|
|
||||||
private readonly PluginLoader _loader;
|
private readonly PluginLoader _loader;
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
private readonly IServiceProvider _hostServices;
|
private readonly IServiceProvider _hostServices;
|
||||||
@@ -25,6 +28,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
private readonly List<PluginCatalogEntry> _catalog = [];
|
private readonly List<PluginCatalogEntry> _catalog = [];
|
||||||
private readonly List<PluginSettingsPageContribution> _settingsPages = [];
|
private readonly List<PluginSettingsPageContribution> _settingsPages = [];
|
||||||
private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
|
private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
|
||||||
|
private readonly object _packageMutationGate = new();
|
||||||
|
|
||||||
public PluginRuntimeService()
|
public PluginRuntimeService()
|
||||||
{
|
{
|
||||||
@@ -49,6 +53,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
public void LoadInstalledPlugins()
|
public void LoadInstalledPlugins()
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(PluginsDirectory);
|
Directory.CreateDirectory(PluginsDirectory);
|
||||||
|
ApplyPendingPluginDeletions();
|
||||||
UnloadInstalledPlugins();
|
UnloadInstalledPlugins();
|
||||||
|
|
||||||
var disabledPluginIds = GetDisabledPluginIds();
|
var disabledPluginIds = GetDisabledPluginIds();
|
||||||
@@ -170,6 +175,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
_appSettingsService.Save(snapshot);
|
_appSettingsService.Save(snapshot);
|
||||||
|
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||||
|
|
||||||
for (var i = 0; i < _catalog.Count; i++)
|
for (var i = 0; i < _catalog.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -184,7 +190,58 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
|
|
||||||
public PluginManifest InstallPluginPackage(string packagePath)
|
public PluginManifest InstallPluginPackage(string packagePath)
|
||||||
{
|
{
|
||||||
return InstallPluginPackageCore(packagePath).Manifest;
|
lock (_packageMutationGate)
|
||||||
|
{
|
||||||
|
return InstallPluginPackageCore(packagePath).Manifest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginManifest RegisterInstalledPluginPackage(string packagePath)
|
||||||
|
{
|
||||||
|
lock (_packageMutationGate)
|
||||||
|
{
|
||||||
|
return RegisterInstalledPluginPackageCore(packagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<InstalledPluginInfo> GetInstalledPluginsSnapshot()
|
internal IReadOnlyList<InstalledPluginInfo> GetInstalledPluginsSnapshot()
|
||||||
@@ -219,20 +276,45 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
Directory.CreateDirectory(PluginsDirectory);
|
Directory.CreateDirectory(PluginsDirectory);
|
||||||
|
|
||||||
var manifest = ReadManifestFromPackage(fullPackagePath);
|
var manifest = ReadManifestFromPackage(fullPackagePath);
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginRuntime",
|
||||||
|
$"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'.");
|
||||||
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||||
|
|
||||||
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||||
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
|
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);
|
UpdateCatalogAfterPackageInstall(manifest, destinationPath);
|
||||||
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginRuntime",
|
||||||
|
$"Package staged. PluginId='{manifest.Id}'; Destination='{destinationPath}'; ReplacedExisting={replacedExisting}.");
|
||||||
|
|
||||||
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
|
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private PluginManifest RegisterInstalledPluginPackageCore(string packagePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||||
|
|
||||||
|
var fullPackagePath = Path.GetFullPath(packagePath);
|
||||||
|
if (!File.Exists(fullPackagePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = ReadManifestFromPackage(fullPackagePath);
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginRuntime",
|
||||||
|
$"Registering externally installed package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'.");
|
||||||
|
UpdateCatalogAfterPackageInstall(manifest, fullPackagePath);
|
||||||
|
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
UnloadInstalledPlugins();
|
UnloadInstalledPlugins();
|
||||||
@@ -349,7 +431,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
File.Delete(existingPackagePath);
|
FileOperationRetryHelper.DeleteFileWithRetry(existingPackagePath, "PluginRuntime");
|
||||||
replacedExisting = true;
|
replacedExisting = true;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -445,6 +527,150 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ApplyPendingPluginDeletions()
|
||||||
|
{
|
||||||
|
var pendingPaths = ReadPendingPluginDeletions();
|
||||||
|
if (pendingPaths.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainingPaths = new List<string>();
|
||||||
|
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<string> ReadPendingPluginDeletions()
|
||||||
|
{
|
||||||
|
var pendingDeletionFilePath = GetPendingDeletionFilePath();
|
||||||
|
if (!File.Exists(pendingDeletionFilePath))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(pendingDeletionFilePath);
|
||||||
|
var paths = JsonSerializer.Deserialize<List<string>>(json);
|
||||||
|
return paths?
|
||||||
|
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList() ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SavePendingPluginDeletions(IEnumerable<string> 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(
|
private sealed record PluginCandidate(
|
||||||
string SourcePath,
|
string SourcePath,
|
||||||
PluginManifest Manifest,
|
PluginManifest Manifest,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using FluentIcons.Avalonia.Fluent;
|
||||||
|
using FluentIcons.Common;
|
||||||
using FluentAvalonia.UI.Controls;
|
using FluentAvalonia.UI.Controls;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
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 SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
|
||||||
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
|
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 AppSettingsService _appSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = 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.");
|
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available.");
|
||||||
PluginRuntimeSummaryPanel.Children.Clear();
|
PluginRuntimeSummaryPanel.Children.Clear();
|
||||||
PluginCatalogItemsHost.Children.Clear();
|
InstalledPluginsSettingsExpander.Items.Clear();
|
||||||
PluginRestartHintTextBlock.IsVisible = false;
|
PluginRestartHintTextBlock.IsVisible = false;
|
||||||
|
PluginCatalogEmptyTextBlock.IsVisible = false;
|
||||||
return;
|
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}.",
|
"Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
|
||||||
runtime.Catalog.Count,
|
runtime.Catalog.Count,
|
||||||
enabledCount,
|
enabledCount,
|
||||||
runtime.LoadedPlugins.Count,
|
runtime.Catalog.Count(entry => entry.IsLoaded),
|
||||||
runtime.SettingsPages.Count,
|
runtime.SettingsPages.Count,
|
||||||
runtime.DesktopComponents.Count,
|
runtime.DesktopComponents.Count,
|
||||||
failures.Length);
|
failures.Length);
|
||||||
@@ -99,7 +103,7 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
|
|
||||||
private void BuildPluginCatalog(PluginRuntimeService runtime)
|
private void BuildPluginCatalog(PluginRuntimeService runtime)
|
||||||
{
|
{
|
||||||
PluginCatalogItemsHost.Children.Clear();
|
InstalledPluginsSettingsExpander.Items.Clear();
|
||||||
|
|
||||||
var plugins = runtime.Catalog
|
var plugins = runtime.Catalog
|
||||||
.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -110,86 +114,20 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
|
|
||||||
foreach (var plugin in plugins)
|
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,
|
Content = entry.Manifest.Name,
|
||||||
FontSize = 16,
|
Description = BuildPluginSubtitle(entry),
|
||||||
FontWeight = FontWeight.SemiBold,
|
IconSource = CreatePluginCatalogIconSource(),
|
||||||
TextWrapping = TextWrapping.Wrap
|
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)
|
private async void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
@@ -247,6 +185,7 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
|
|
||||||
var manifest = runtime.InstallPluginPackage(temporaryPackagePath);
|
var manifest = runtime.InstallPluginPackage(temporaryPackagePath);
|
||||||
RefreshFromRuntime();
|
RefreshFromRuntime();
|
||||||
|
RefreshPluginNavigation(TopLevel.GetTopLevel(this));
|
||||||
SetPackageImportStatus(
|
SetPackageImportStatus(
|
||||||
F(
|
F(
|
||||||
"settings.plugins.install_success_format",
|
"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)
|
private void RefreshPluginNavigation(TopLevel? topLevel)
|
||||||
{
|
{
|
||||||
switch (topLevel)
|
switch (topLevel)
|
||||||
@@ -301,32 +313,13 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
|
|
||||||
private string BuildPluginSubtitle(PluginCatalogEntry entry)
|
private string BuildPluginSubtitle(PluginCatalogEntry entry)
|
||||||
{
|
{
|
||||||
var source = entry.IsPackage
|
var publisher = string.IsNullOrWhiteSpace(entry.Manifest.Author)
|
||||||
? L("settings.plugins.source_package", ".laapp package")
|
? L("settings.plugins.publisher_unknown", "Unknown publisher")
|
||||||
: L("settings.plugins.source_manifest", "Loose manifest");
|
: entry.Manifest.Author;
|
||||||
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");
|
|
||||||
return F(
|
return F(
|
||||||
"settings.plugins.subtitle_format",
|
"settings.plugins.publisher_format",
|
||||||
"{0} | {1} | {2}",
|
"Publisher: {0}",
|
||||||
state,
|
publisher);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private TextBlock CreateSummaryLine(string text)
|
private TextBlock CreateSummaryLine(string text)
|
||||||
@@ -350,6 +343,75 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args);
|
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<string?> CopyPackageToTemporaryFileAsync(IStorageFile file)
|
private static async Task<string?> CopyPackageToTemporaryFileAsync(IStorageFile file)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -44,31 +44,13 @@
|
|||||||
<Border Classes="settings-expander-shell">
|
<Border Classes="settings-expander-shell">
|
||||||
<ui:SettingsExpander x:Name="InstalledPluginsSettingsExpander"
|
<ui:SettingsExpander x:Name="InstalledPluginsSettingsExpander"
|
||||||
Header="Installed Plugins"
|
Header="Installed Plugins"
|
||||||
Description="Enable plugins here. Detailed settings appear as separate settings pages."
|
Description="Manage installed plugins here."
|
||||||
IsExpanded="True">
|
IsExpanded="True">
|
||||||
<ui:SettingsExpander.IconSource>
|
<ui:SettingsExpander.IconSource>
|
||||||
<ui:FontIconSource Glyph="" FontFamily="{StaticResource SymbolThemeFontFamily}" />
|
<ui:FontIconSource Glyph="" FontFamily="{StaticResource SymbolThemeFontFamily}" />
|
||||||
</ui:SettingsExpander.IconSource>
|
</ui:SettingsExpander.IconSource>
|
||||||
<ui:SettingsExpander.Footer>
|
<ui:SettingsExpander.Footer>
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
|
||||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
|
||||||
Padding="14">
|
|
||||||
<StackPanel Spacing="10">
|
|
||||||
<Button x:Name="InstallPluginPackageButton"
|
|
||||||
HorizontalAlignment="Left"
|
|
||||||
Click="OnInstallPluginPackageClick"
|
|
||||||
Content="Open .laapp package" />
|
|
||||||
<TextBlock x:Name="PluginPackageImportHintTextBlock"
|
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Text="Open a .laapp package to install it into the local plugin directory." />
|
|
||||||
<TextBlock x:Name="PluginPackageImportStatusTextBlock"
|
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
IsVisible="False" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
<TextBlock x:Name="PluginRestartHintTextBlock"
|
<TextBlock x:Name="PluginRestartHintTextBlock"
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
@@ -77,7 +59,33 @@
|
|||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
Text="No plugins found."
|
Text="No plugins found."
|
||||||
IsVisible="False" />
|
IsVisible="False" />
|
||||||
<StackPanel x:Name="PluginCatalogItemsHost" Spacing="12" />
|
</StackPanel>
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settings-expander-shell">
|
||||||
|
<ui:SettingsExpander x:Name="ImportPluginPackageSettingsExpander"
|
||||||
|
Header="Install From Package"
|
||||||
|
Description="Open a .laapp package and stage it into the local plugin directory."
|
||||||
|
IsExpanded="False">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<ui:FontIconSource Glyph="" FontFamily="{StaticResource SymbolThemeFontFamily}" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<Button x:Name="InstallPluginPackageButton"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Click="OnInstallPluginPackageClick"
|
||||||
|
Content="Open .laapp package" />
|
||||||
|
<TextBlock x:Name="PluginPackageImportHintTextBlock"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Open a .laapp package to install it into the local plugin directory." />
|
||||||
|
<TextBlock x:Name="PluginPackageImportStatusTextBlock"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="False" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ui:SettingsExpander.Footer>
|
</ui:SettingsExpander.Footer>
|
||||||
</ui:SettingsExpander>
|
</ui:SettingsExpander>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public partial class SettingsWindow
|
|||||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||||
|
internal FluentAvalonia.UI.Controls.SettingsExpander ImportPluginPackageSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("ImportPluginPackageSettingsExpander")!;
|
||||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
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.");
|
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.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.");
|
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Review installed plugins and remove them here.");
|
||||||
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
|
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.");
|
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||||
PluginSettingsPanel.RefreshFromRuntime();
|
PluginSettingsPanel.RefreshFromRuntime();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,37 @@ function Create-PackageArchive {
|
|||||||
return $archivePath
|
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 {
|
function Add-LinuxDesktopAssets {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
|
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
|
||||||
@@ -187,7 +218,7 @@ if (-not $PublishDir) {
|
|||||||
if (-not [System.IO.Path]::IsPathRooted($PublishDir)) {
|
if (-not [System.IO.Path]::IsPathRooted($PublishDir)) {
|
||||||
$PublishDir = Join-Path $repoRoot $PublishDir
|
$PublishDir = Join-Path $repoRoot $PublishDir
|
||||||
}
|
}
|
||||||
[System.IO.Directory]::CreateDirectory($PublishDir) | Out-Null
|
Clear-DirectoryContents -TargetDirectory $PublishDir
|
||||||
|
|
||||||
Write-Host "Publishing project..."
|
Write-Host "Publishing project..."
|
||||||
$publishArgs = @(
|
$publishArgs = @(
|
||||||
@@ -210,6 +241,7 @@ if ($LASTEXITCODE -ne 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
|
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
|
||||||
|
Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir
|
||||||
|
|
||||||
if ($RuntimeIdentifier -like "linux-*") {
|
if ($RuntimeIdentifier -like "linux-*") {
|
||||||
Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot
|
Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot
|
||||||
|
|||||||
Reference in New Issue
Block a user