Compare commits

...

3 Commits

Author SHA1 Message Date
lincube
5003ff1be2 0.5.12 2026-03-10 21:25:47 +08:00
lincube
e1be072b97 0.5.11
插件市场UI优化
2026-03-10 16:35:43 +08:00
lincube
4df740e3df 0.5.10
多线程
2026-03-10 14:56:05 +08:00
58 changed files with 3323 additions and 2159 deletions

View File

@@ -1,41 +0,0 @@
namespace LanMountainDesktop.PluginMarketplace;
internal sealed class AirAppMarketCacheService
{
private readonly string _cacheDirectory;
public AirAppMarketCacheService(string dataDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
_cacheDirectory = Path.Combine(dataDirectory, "cache");
}
public string CacheFilePath => Path.Combine(_cacheDirectory, "index.json");
public void SaveIndexJson(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
Directory.CreateDirectory(_cacheDirectory);
File.WriteAllText(CacheFilePath, json);
}
public bool TryReadIndexJson(out string json)
{
try
{
if (!File.Exists(CacheFilePath))
{
json = string.Empty;
return false;
}
json = File.ReadAllText(CacheFilePath);
return !string.IsNullOrWhiteSpace(json);
}
catch
{
json = string.Empty;
return false;
}
}
}

View File

@@ -1,77 +0,0 @@
using System.Net.Http.Headers;
namespace LanMountainDesktop.PluginMarketplace;
internal sealed class AirAppMarketIndexService : IDisposable
{
private readonly AirAppMarketCacheService _cacheService;
private readonly HttpClient _httpClient;
public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
{
_cacheService = cacheService;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default)
{
Exception? networkError = null;
try
{
using var response = await _httpClient.GetAsync(
AirAppMarketDefaults.DefaultIndexUrl,
cancellationToken);
var json = await response.Content.ReadAsStringAsync(cancellationToken);
response.EnsureSuccessStatusCode();
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
_cacheService.SaveIndexJson(json);
return new AirAppMarketLoadResult(true, document, AirAppMarketLoadSource.Network, null, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
networkError = ex;
}
if (_cacheService.TryReadIndexJson(out var cachedJson))
{
try
{
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
return new AirAppMarketLoadResult(
true,
cachedDocument,
AirAppMarketLoadSource.Cache,
networkError?.Message,
null);
}
catch (Exception cacheEx)
{
return new AirAppMarketLoadResult(
false,
null,
null,
null,
$"{networkError?.Message ?? "Unknown network error"} | Cached index invalid: {cacheEx.Message}");
}
}
return new AirAppMarketLoadResult(false, null, null, null, networkError?.Message ?? "Unknown network error");
}
public void Dispose()
{
_httpClient.Dispose();
}
}

View File

@@ -1,83 +0,0 @@
using System.Security.Cryptography;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
internal sealed class AirAppMarketInstallService : IDisposable
{
private readonly IPluginPackageManager _packageManager;
private readonly HttpClient _httpClient;
private readonly string _downloadsDirectory;
public AirAppMarketInstallService(IPluginPackageManager packageManager, string dataDirectory)
{
_packageManager = packageManager;
_downloadsDirectory = Path.Combine(dataDirectory, "downloads");
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromMinutes(2)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
}
public async Task<AirAppMarketInstallResult> InstallAsync(
AirAppMarketPluginEntry plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
Directory.CreateDirectory(_downloadsDirectory);
var downloadPath = Path.Combine(
_downloadsDirectory,
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
try
{
using var response = await _httpClient.GetAsync(
plugin.DownloadUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using (var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken))
await using (var destinationStream = File.Create(downloadPath))
{
await responseStream.CopyToAsync(destinationStream, cancellationToken);
}
await using var hashStream = File.OpenRead(downloadPath);
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(downloadPath);
return new AirAppMarketInstallResult(
false,
null,
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
}
var installResult = _packageManager.InstallPackage(downloadPath);
return new AirAppMarketInstallResult(true, installResult, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new AirAppMarketInstallResult(false, null, ex.Message);
}
}
public void Dispose()
{
_httpClient.Dispose();
}
private static string SanitizeFileName(string value)
{
var invalidChars = Path.GetInvalidFileNameChars();
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
}
}

View File

@@ -1,299 +0,0 @@
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
internal static class AirAppMarketDefaults
{
public const string DefaultIndexUrl =
"https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/index.json";
}
internal enum AirAppMarketLoadSource
{
Network = 0,
Cache = 1
}
internal enum AirAppMarketInstallState
{
NotInstalled = 0,
UpdateAvailable = 1,
Installed = 2
}
internal sealed record AirAppMarketLoadResult(
bool Success,
AirAppMarketIndexDocument? Document,
AirAppMarketLoadSource? Source,
string? WarningMessage,
string? ErrorMessage);
internal sealed record AirAppMarketInstallResult(
bool Success,
PluginPackageInstallResult? InstallResult,
string? ErrorMessage);
internal sealed class AirAppMarketIndexDocument
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public string SchemaVersion { get; init; } = string.Empty;
public string SourceId { get; init; } = string.Empty;
public string SourceName { get; init; } = string.Empty;
public DateTimeOffset GeneratedAt { get; init; }
public List<AirAppMarketPluginEntry> Plugins { get; init; } = [];
public static AirAppMarketIndexDocument Load(string json, string sourceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
var document = JsonSerializer.Deserialize<AirAppMarketIndexDocument>(
json.TrimStart('\uFEFF'),
SerializerOptions);
if (document is null)
{
throw new InvalidOperationException($"Failed to parse market index '{sourceName}'.");
}
return document.ValidateAndNormalize(sourceName);
}
private AirAppMarketIndexDocument ValidateAndNormalize(string sourceName)
{
var plugins = Plugins ?? [];
var normalizedPlugins = new List<AirAppMarketPluginEntry>(plugins.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var plugin in plugins)
{
var normalizedPlugin = plugin.ValidateAndNormalize(sourceName);
if (!seenIds.Add(normalizedPlugin.Id))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' contains duplicate plugin id '{normalizedPlugin.Id}'.");
}
normalizedPlugins.Add(normalizedPlugin);
}
return new AirAppMarketIndexDocument
{
SchemaVersion = RequireValue(SchemaVersion, nameof(SchemaVersion), sourceName),
SourceId = RequireValue(SourceId, nameof(SourceId), sourceName),
SourceName = RequireValue(SourceName, nameof(SourceName), sourceName),
GeneratedAt = GeneratedAt == default
? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.")
: GeneratedAt,
Plugins = normalizedPlugins
.OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
private static string RequireValue(string? value, string propertyName, string sourceName)
{
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
throw new InvalidOperationException($"Market index '{sourceName}' is missing required property '{propertyName}'.");
}
return normalized;
}
internal static string? NormalizeValue(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal static string NormalizeVersion(string? value, string propertyName, string sourceName)
{
var normalized = RequireValue(value, propertyName, sourceName);
if (!TryParseVersion(normalized, out _))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid version '{normalized}' for '{propertyName}'.");
}
return normalized;
}
internal static void EnsureUrl(string url, string propertyName, string sourceName)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid URL '{url}' for '{propertyName}'.");
}
}
internal static bool TryParseVersion(string? value, out Version? version)
{
version = null;
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
if (normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[1..];
}
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
if (!Version.TryParse(normalized, out var parsed))
{
return false;
}
version = new Version(
Math.Max(0, parsed.Major),
Math.Max(0, parsed.Minor),
Math.Max(0, parsed.Build));
return true;
}
}
internal sealed class AirAppMarketPluginEntry
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Author { get; init; } = string.Empty;
public string Version { get; init; } = string.Empty;
public string ApiVersion { get; init; } = string.Empty;
public string MinHostVersion { get; init; } = string.Empty;
public string DownloadUrl { get; init; } = string.Empty;
public string Sha256 { get; init; } = string.Empty;
public long PackageSizeBytes { get; init; }
public string IconUrl { get; init; } = string.Empty;
public string HomepageUrl { get; init; } = string.Empty;
public string RepositoryUrl { get; init; } = string.Empty;
public List<string> Tags { get; init; } = [];
public DateTimeOffset PublishedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public string ReleaseNotes { get; init; } = string.Empty;
public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName)
{
var normalizedTags = (Tags ?? [])
.Select(tag => AirAppMarketIndexDocument.NormalizeValue(tag))
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.ToList();
var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(Sha256)}'.");
if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch)))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for plugin '{Id}'.");
}
var normalizedDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(DownloadUrl)}'.");
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(IconUrl)}'.");
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(HomepageUrl)}'.");
var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(RepositoryUrl)}'.");
AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedRepositoryUrl, nameof(RepositoryUrl), sourceName);
if (PackageSizeBytes <= 0)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{Id}'.");
}
if (PublishedAt == default || UpdatedAt == default)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{Id}'.");
}
return new AirAppMarketPluginEntry
{
Id = AirAppMarketIndexDocument.NormalizeValue(Id)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id."),
Name = AirAppMarketIndexDocument.NormalizeValue(Name)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin name."),
Description = AirAppMarketIndexDocument.NormalizeValue(Description)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin description."),
Author = AirAppMarketIndexDocument.NormalizeValue(Author)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin author."),
Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName),
ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName),
MinHostVersion = AirAppMarketIndexDocument.NormalizeVersion(MinHostVersion, nameof(MinHostVersion), sourceName),
DownloadUrl = normalizedDownloadUrl,
Sha256 = normalizedSha,
PackageSizeBytes = PackageSizeBytes,
IconUrl = normalizedIconUrl,
HomepageUrl = normalizedHomepageUrl,
RepositoryUrl = normalizedRepositoryUrl,
Tags = normalizedTags,
PublishedAt = PublishedAt,
UpdatedAt = UpdatedAt,
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing release notes for plugin '{Id}'.")
};
}
public string GetVersionSummary()
{
return string.Format(
CultureInfo.InvariantCulture,
"v{0} | API {1} | Host >= {2}",
Version,
ApiVersion,
MinHostVersion);
}
}

View File

@@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
<PluginReleaseOutputDirectory>..\..\releases\</PluginReleaseOutputDirectory>
<PluginReleasePackagePath>$(PluginReleaseOutputDirectory)$(AssemblyName).$(Version).laapp</PluginReleasePackagePath>
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\PluginMarketplace\</LegacyLoosePluginOutputDirectory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="CreateLaappPackage" AfterTargets="Build">
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
<MakeDir Directories="$(PluginReleaseOutputDirectory)" />
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
<Delete Files="$(PluginReleasePackagePath)" TreatErrorsAsWarnings="true" />
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
<Copy SourceFiles="$(PluginPackagePath)" DestinationFiles="$(PluginReleasePackagePath)" />
</Target>
</Project>

View File

@@ -1,37 +0,0 @@
{
"market.page_title": "Plugin Marketplace",
"market.toolbar.search_placeholder": "Search plugins",
"market.toolbar.refresh": "Refresh",
"market.status.loading": "Loading the official plugin marketplace...",
"market.status.loaded_network_format": "Loaded {0} plugin(s) from the official source.",
"market.status.loaded_cache_format": "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}",
"market.status.load_failed_format": "Failed to load plugin marketplace: {0}",
"market.status.installing_format": "Downloading and staging plugin '{0}'...",
"market.status.install_success_format": "Plugin '{0}' has been staged. Restart the app to apply it.",
"market.status.install_failed_format": "Failed to install plugin: {0}",
"market.status.host_incompatible_format": "This host is too old. Version {0} or newer is required.",
"market.list.empty": "The plugin marketplace has not been loaded yet.",
"market.list.no_results": "No plugins match the current search.",
"market.card.subtitle_format": "{0} · v{1}",
"market.card.loaded": "Loaded",
"market.card.pending_restart": "Restart required",
"market.detail.placeholder": "Select a plugin from the left to inspect details.",
"market.detail.author": "Author",
"market.detail.version": "Version",
"market.detail.api_version": "API Version",
"market.detail.min_host_version": "Minimum Host Version",
"market.detail.installed_version": "Installed Version",
"market.detail.not_installed": "Not installed",
"market.detail.market_source": "Market Source",
"market.detail.homepage": "Homepage",
"market.detail.repository": "Repository",
"market.detail.release_notes": "Release Notes",
"market.detail.state.not_installed": "Not installed",
"market.detail.state.update_available": "Update available",
"market.detail.state.installed": "Installed",
"market.detail.unknown": "Unknown",
"market.button.install": "Install",
"market.button.update": "Update",
"market.button.installed": "Installed",
"market.button.installing": "Installing..."
}

View File

@@ -1,37 +0,0 @@
{
"market.page_title": "插件市场",
"market.toolbar.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新",
"market.status.loading": "正在加载官方插件市场…",
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
"market.status.loaded_cache_format": "官方源不可用,已从缓存加载 {0} 个插件。原因:{1}",
"market.status.load_failed_format": "加载插件市场失败:{0}",
"market.status.installing_format": "正在下载并暂存插件“{0}”…",
"market.status.install_success_format": "插件“{0}”已暂存完成,重启应用后生效。",
"market.status.install_failed_format": "安装插件失败:{0}",
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
"market.list.empty": "插件市场尚未加载。",
"market.list.no_results": "没有匹配的插件。",
"market.card.subtitle_format": "{0} · v{1}",
"market.card.loaded": "已加载",
"market.card.pending_restart": "需重启",
"market.detail.placeholder": "从左侧选择一个插件以查看详情。",
"market.detail.author": "作者",
"market.detail.version": "版本",
"market.detail.api_version": "API 版本",
"market.detail.min_host_version": "最低宿主版本",
"market.detail.installed_version": "当前已安装版本",
"market.detail.not_installed": "未安装",
"market.detail.market_source": "市场源",
"market.detail.homepage": "主页",
"market.detail.repository": "仓库",
"market.detail.release_notes": "发布说明",
"market.detail.state.not_installed": "未安装",
"market.detail.state.update_available": "可更新",
"market.detail.state.installed": "已安装",
"market.detail.unknown": "未知",
"market.button.install": "安装",
"market.button.update": "更新",
"market.button.installed": "已安装",
"market.button.installing": "安装中…"
}

View File

@@ -1,47 +0,0 @@
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
[PluginEntrance]
public sealed class PluginMarketplacePlugin : PluginBase, IDisposable
{
private AirAppMarketIndexService? _indexService;
private AirAppMarketInstallService? _installService;
public override void Initialize(IPluginContext context)
{
Directory.CreateDirectory(context.DataDirectory);
var localizer = PluginLocalizer.Create(context);
var packageManager = context.GetService<IPluginPackageManager>()
?? throw new InvalidOperationException(
"The host does not expose IPluginPackageManager. LanMountainDesktop.PluginMarketplace requires a newer host build.");
var cacheService = new AirAppMarketCacheService(context.DataDirectory);
_indexService = new AirAppMarketIndexService(cacheService);
_installService = new AirAppMarketInstallService(packageManager, context.DataDirectory);
context.RegisterService(cacheService);
context.RegisterService(_indexService);
context.RegisterService(_installService);
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
"marketplace",
localizer.GetString("market.page_title", "插件市场"),
() => new PluginMarketplaceSettingsView(
context,
localizer,
packageManager,
_indexService,
_installService),
sortOrder: -100));
}
public void Dispose()
{
_installService?.Dispose();
_indexService?.Dispose();
_installService = null;
_indexService = null;
}
}

View File

@@ -1,727 +0,0 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
internal sealed class PluginMarketplaceSettingsView : UserControl
{
private static readonly IBrush SurfaceBrush = new SolidColorBrush(Color.Parse("#14000000"));
private static readonly IBrush SelectedSurfaceBrush = new SolidColorBrush(Color.Parse("#1F0EA5E9"));
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
private static readonly IBrush WarningBrush = new SolidColorBrush(Color.Parse("#FF9A6700"));
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
private readonly PluginLocalizer _localizer;
private readonly IPluginPackageManager _packageManager;
private readonly AirAppMarketIndexService _indexService;
private readonly AirAppMarketInstallService _installService;
private readonly Version? _hostVersion;
private readonly TextBox _searchTextBox;
private readonly Button _refreshButton;
private readonly TextBlock _statusTextBlock;
private readonly StackPanel _pluginListHost;
private readonly Border _detailBorder;
private AirAppMarketIndexDocument? _document;
private AirAppMarketPluginEntry? _selectedPlugin;
private Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
private bool _isRefreshing;
private bool _isInstalling;
private bool _hasLoadedOnce;
public PluginMarketplaceSettingsView(
IPluginContext context,
PluginLocalizer localizer,
IPluginPackageManager packageManager,
AirAppMarketIndexService indexService,
AirAppMarketInstallService installService)
{
_localizer = localizer;
_packageManager = packageManager;
_indexService = indexService;
_installService = installService;
_hostVersion = context.TryGetProperty<string>(PluginHostPropertyKeys.HostVersion, out var hostVersionText) &&
AirAppMarketIndexDocument.TryParseVersion(hostVersionText, out var parsedHostVersion)
? parsedHostVersion
: null;
_searchTextBox = new TextBox
{
MinWidth = 240,
Watermark = T("market.toolbar.search_placeholder", "搜索插件")
};
_searchTextBox.PropertyChanged += (_, e) =>
{
if (e.Property == TextBox.TextProperty)
{
RebuildSurface();
}
};
_refreshButton = new Button
{
Content = T("market.toolbar.refresh", "刷新"),
HorizontalAlignment = HorizontalAlignment.Left
};
_refreshButton.Click += OnRefreshClick;
_statusTextBlock = new TextBlock
{
Text = T("market.status.loading", "正在加载官方插件市场…"),
TextWrapping = TextWrapping.Wrap,
Foreground = WarningBrush
};
_pluginListHost = new StackPanel
{
Spacing = 10
};
_detailBorder = CreatePanelShell();
Content = BuildLayout();
AttachedToVisualTree += async (_, _) =>
{
if (_hasLoadedOnce)
{
return;
}
_hasLoadedOnce = true;
await RefreshAsync();
};
}
private Control BuildLayout()
{
var root = new Grid
{
RowDefinitions = new RowDefinitions("Auto,*"),
RowSpacing = 16
};
var toolbar = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
ColumnSpacing = 12
};
toolbar.Children.Add(new StackPanel
{
Spacing = 8,
Children =
{
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
Children =
{
_searchTextBox,
_refreshButton
}
},
_statusTextBlock
}
});
var contentGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("360,*"),
ColumnSpacing = 16
};
var listShell = CreatePanelShell();
listShell.Child = new ScrollViewer
{
Content = _pluginListHost
};
contentGrid.Children.Add(listShell);
contentGrid.Children.Add(_detailBorder);
Grid.SetColumn(_detailBorder, 1);
root.Children.Add(toolbar);
root.Children.Add(contentGrid);
Grid.SetRow(contentGrid, 1);
return root;
}
private async void OnRefreshClick(object? sender, RoutedEventArgs e)
{
await RefreshAsync();
}
private async Task RefreshAsync()
{
if (_isRefreshing)
{
return;
}
_isRefreshing = true;
_refreshButton.IsEnabled = false;
SetStatus(T("market.status.loading", "正在加载官方插件市场…"), WarningBrush);
try
{
_installedPlugins = _packageManager
.GetInstalledPlugins()
.ToDictionary(plugin => plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase);
var result = await _indexService.LoadAsync();
if (!result.Success || result.Document is null)
{
_document = null;
_selectedPlugin = null;
SetStatus(
F("market.status.load_failed_format", "加载插件市场失败:{0}", result.ErrorMessage ?? T("market.detail.unknown", "未知错误")),
ErrorBrush);
RebuildSurface();
return;
}
_document = result.Document;
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, result.Document.Plugins);
var statusMessage = result.Source == AirAppMarketLoadSource.Cache
? F(
"market.status.loaded_cache_format",
"官方源不可用,已从缓存加载 {0} 个插件。原因:{1}",
result.Document.Plugins.Count,
result.WarningMessage ?? T("market.detail.unknown", "未知错误"))
: F(
"market.status.loaded_network_format",
"已从官方源加载 {0} 个插件。",
result.Document.Plugins.Count);
SetStatus(statusMessage, result.Source == AirAppMarketLoadSource.Cache ? WarningBrush : SuccessBrush);
RebuildSurface();
}
finally
{
_isRefreshing = false;
_refreshButton.IsEnabled = true;
}
}
private void RebuildSurface()
{
var filteredPlugins = GetFilteredPlugins();
if (filteredPlugins.Count > 0)
{
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins);
}
else
{
_selectedPlugin = null;
}
BuildPluginList(filteredPlugins);
BuildDetailPanel();
}
private List<AirAppMarketPluginEntry> GetFilteredPlugins()
{
if (_document is null)
{
return [];
}
var query = (_searchTextBox.Text ?? string.Empty).Trim();
var source = _document.Plugins;
if (string.IsNullOrWhiteSpace(query))
{
return source.ToList();
}
return source
.Where(plugin =>
plugin.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Description.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Author.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Tags.Any(tag => tag.Contains(query, StringComparison.OrdinalIgnoreCase)))
.ToList();
}
private void BuildPluginList(IReadOnlyList<AirAppMarketPluginEntry> plugins)
{
_pluginListHost.Children.Clear();
if (_document is null)
{
_pluginListHost.Children.Add(CreateEmptyState(T("market.list.empty", "插件市场尚未加载。")));
return;
}
if (plugins.Count == 0)
{
_pluginListHost.Children.Add(CreateEmptyState(T("market.list.no_results", "没有匹配的插件。")));
return;
}
foreach (var plugin in plugins)
{
_pluginListHost.Children.Add(CreatePluginCard(plugin));
}
}
private Control CreatePluginCard(AirAppMarketPluginEntry plugin)
{
var installState = ResolveInstallState(plugin, out var installedPlugin);
var isSelected = string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase);
var button = new Button
{
HorizontalContentAlignment = HorizontalAlignment.Stretch,
Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Content = new Border
{
Background = isSelected ? SelectedSurfaceBrush : SurfaceBrush,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14),
Child = new StackPanel
{
Spacing = 10,
Children =
{
new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 12,
Children =
{
CreateMonogramIcon(plugin.Name, 42),
new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = plugin.Name,
FontSize = 16,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = F("market.card.subtitle_format", "{0} · v{1}", plugin.Author, plugin.Version),
Foreground = Brushes.Gray,
TextWrapping = TextWrapping.Wrap
}
}
}
}
},
new TextBlock
{
Text = plugin.Description,
TextWrapping = TextWrapping.Wrap,
MaxHeight = 56
},
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
Children =
{
CreateStateChip(T(StateKey(installState), StateFallback(installState))),
CreateStateChip(installedPlugin?.IsLoaded == true
? T("market.card.loaded", "已加载")
: T("market.card.pending_restart", "需重启")),
new TextBlock
{
Text = string.Join(" ", plugin.Tags.Take(3)),
VerticalAlignment = VerticalAlignment.Center,
Foreground = Brushes.Gray
}
}
}
}
}
}
};
button.Click += (_, _) =>
{
_selectedPlugin = plugin;
RebuildSurface();
};
return button;
}
private void BuildDetailPanel()
{
if (_selectedPlugin is null)
{
_detailBorder.Child = CreateEmptyState(T("market.detail.placeholder", "从左侧选择一个插件以查看详情。"));
return;
}
var plugin = _selectedPlugin;
var installState = ResolveInstallState(plugin, out var installedPlugin);
var isCompatible = IsCompatibleWithHost(plugin);
var installButton = new Button
{
Content = _isInstalling
? T("market.button.installing", "安装中…")
: T(ButtonKey(installState), ButtonFallback(installState)),
IsEnabled = !_isInstalling && isCompatible && installState != AirAppMarketInstallState.Installed,
HorizontalAlignment = HorizontalAlignment.Left,
MinWidth = 120
};
installButton.Click += async (_, _) => await InstallSelectedPluginAsync(plugin);
var detailPanel = new StackPanel
{
Spacing = 14,
Children =
{
new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 14,
Children =
{
CreateMonogramIcon(plugin.Name, 64),
new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = plugin.Name,
FontSize = 24,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = plugin.Description,
TextWrapping = TextWrapping.Wrap
}
}
}
}
},
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
Children =
{
CreateStateChip(T(StateKey(installState), StateFallback(installState))),
CreateStateChip(plugin.GetVersionSummary()),
CreateStateChip(string.Join(", ", plugin.Tags))
}
},
installButton,
CreateInfoRow(T("market.detail.author", "作者"), plugin.Author),
CreateInfoRow(T("market.detail.version", "版本"), plugin.Version),
CreateInfoRow(T("market.detail.api_version", "API 版本"), plugin.ApiVersion),
CreateInfoRow(T("market.detail.min_host_version", "最低宿主版本"), plugin.MinHostVersion),
CreateInfoRow(T("market.detail.installed_version", "当前已安装版本"), installedPlugin?.Manifest.Version ?? T("market.detail.not_installed", "未安装")),
CreateInfoRow(T("market.detail.market_source", "市场源"), AirAppMarketDefaults.DefaultIndexUrl),
CreateInfoRow(T("market.detail.homepage", "主页"), plugin.HomepageUrl),
CreateInfoRow(T("market.detail.repository", "仓库"), plugin.RepositoryUrl),
new TextBlock
{
Text = T("market.detail.release_notes", "发布说明"),
FontSize = 18,
FontWeight = FontWeight.SemiBold
},
new Border
{
Background = SurfaceBrush,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14),
Child = new TextBlock
{
Text = plugin.ReleaseNotes,
TextWrapping = TextWrapping.Wrap
}
}
}
};
if (!isCompatible)
{
detailPanel.Children.Insert(
3,
new TextBlock
{
Text = F(
"market.status.host_incompatible_format",
"当前宿主版本过低,至少需要 {0}。",
plugin.MinHostVersion),
Foreground = ErrorBrush,
TextWrapping = TextWrapping.Wrap
});
}
_detailBorder.Child = new ScrollViewer
{
Content = detailPanel
};
}
private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin)
{
if (_isInstalling)
{
return;
}
_isInstalling = true;
BuildDetailPanel();
SetStatus(
F("market.status.installing_format", "正在下载并暂存插件“{0}”…", plugin.Name),
WarningBrush);
try
{
var result = await _installService.InstallAsync(plugin);
if (!result.Success || result.InstallResult is null)
{
SetStatus(
F(
"market.status.install_failed_format",
"安装插件失败:{0}",
result.ErrorMessage ?? T("market.detail.unknown", "未知错误")),
ErrorBrush);
return;
}
_installedPlugins = _packageManager
.GetInstalledPlugins()
.ToDictionary(item => item.Manifest.Id, StringComparer.OrdinalIgnoreCase);
SetStatus(
F(
"market.status.install_success_format",
"插件“{0}”已暂存完成,重启应用后生效。",
result.InstallResult.Manifest.Name),
SuccessBrush);
RebuildSurface();
}
finally
{
_isInstalling = false;
BuildDetailPanel();
}
}
private AirAppMarketPluginEntry? ResolveSelectedPlugin(
string? selectedPluginId,
IReadOnlyList<AirAppMarketPluginEntry> plugins)
{
if (plugins.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(selectedPluginId))
{
var existing = plugins.FirstOrDefault(plugin =>
string.Equals(plugin.Id, selectedPluginId, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
return existing;
}
}
return plugins[0];
}
private AirAppMarketInstallState ResolveInstallState(
AirAppMarketPluginEntry plugin,
out InstalledPluginInfo? installedPlugin)
{
if (!_installedPlugins.TryGetValue(plugin.Id, out installedPlugin))
{
return AirAppMarketInstallState.NotInstalled;
}
return CompareVersions(plugin.Version, installedPlugin.Manifest.Version) > 0
? AirAppMarketInstallState.UpdateAvailable
: AirAppMarketInstallState.Installed;
}
private bool IsCompatibleWithHost(AirAppMarketPluginEntry plugin)
{
if (_hostVersion is null ||
!AirAppMarketIndexDocument.TryParseVersion(plugin.MinHostVersion, out var minHostVersion) ||
minHostVersion is null)
{
return true;
}
return _hostVersion >= minHostVersion;
}
private void SetStatus(string message, IBrush foreground)
{
_statusTextBlock.Text = message;
_statusTextBlock.Foreground = foreground;
}
private static int CompareVersions(string? left, string? right)
{
if (!AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion))
{
leftVersion = new Version(0, 0, 0);
}
if (!AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion))
{
rightVersion = new Version(0, 0, 0);
}
return (leftVersion ?? new Version(0, 0, 0)).CompareTo(rightVersion ?? new Version(0, 0, 0));
}
private Border CreatePanelShell()
{
return new Border
{
Background = SurfaceBrush,
CornerRadius = new CornerRadius(18),
Padding = new Thickness(16)
};
}
private Control CreateEmptyState(string text)
{
return new Border
{
Background = SurfaceBrush,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(18),
Child = new TextBlock
{
Text = text,
TextWrapping = TextWrapping.Wrap
}
};
}
private Border CreateMonogramIcon(string text, double size)
{
var glyph = string.IsNullOrWhiteSpace(text) ? "?" : text.Trim()[0].ToString().ToUpperInvariant();
return new Border
{
Width = size,
Height = size,
CornerRadius = new CornerRadius(size / 2),
Background = new SolidColorBrush(Color.Parse("#FF0EA5E9")),
Child = new TextBlock
{
Text = glyph,
FontSize = Math.Max(16, size * 0.36),
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center
}
};
}
private Border CreateStateChip(string text)
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#22000000")),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(10, 4),
Child = new TextBlock
{
Text = text,
FontSize = 12
}
};
}
private Control CreateInfoRow(string label, string value)
{
return new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = label,
FontSize = 12,
Foreground = Brushes.Gray
},
new TextBlock
{
Text = string.IsNullOrWhiteSpace(value) ? T("market.detail.unknown", "未知") : value,
TextWrapping = TextWrapping.Wrap
}
}
};
}
private string T(string key, string fallback)
{
return _localizer.GetString(key, fallback);
}
private string F(string key, string fallback, params object[] args)
{
return string.Format(CultureInfo.CurrentCulture, T(key, fallback), args);
}
private static string StateKey(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "market.detail.state.update_available",
AirAppMarketInstallState.Installed => "market.detail.state.installed",
_ => "market.detail.state.not_installed"
};
}
private static string StateFallback(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "可更新",
AirAppMarketInstallState.Installed => "已安装",
_ => "未安装"
};
}
private static string ButtonKey(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "market.button.update",
AirAppMarketInstallState.Installed => "market.button.installed",
_ => "market.button.install"
};
}
private static string ButtonFallback(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "更新",
AirAppMarketInstallState.Installed => "已安装",
_ => "安装"
};
}
}

View File

@@ -1,9 +0,0 @@
{
"id": "LanMountainDesktop.PluginMarketplace",
"name": "LanMountain Plugin Marketplace",
"description": "Official plugin marketplace for browsing and installing LanMountainDesktop plugins.",
"author": "LanMountainDesktop",
"version": "1.0.0",
"apiVersion": "1.0.0",
"entranceAssembly": "LanMountainDesktop.PluginMarketplace.dll"
}

View File

@@ -16,23 +16,6 @@
<local:ViewLocator/>
</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>
<sty:FluentAvaloniaTheme />
<mi:MaterialIconStyles />

View File

@@ -1,11 +1,12 @@
using Avalonia;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System;
using System.Diagnostics;
using System.Linq;
using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using Avalonia.Threading;
using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels;
@@ -16,13 +17,18 @@ namespace LanMountainDesktop;
public partial class App : Application
{
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private SettingsWindow? _traySettingsWindow;
private TrayIcons? _trayIcons;
private PluginRuntimeService? _pluginRuntimeService;
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public override void Initialize()
{
AppLogger.Info("App", "Initializing application resources.");
ConfigureWebViewUserDataFolder();
AvaloniaWebViewBuilder.Initialize(default);
AvaloniaXamlLoader.Load(this);
@@ -30,19 +36,29 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
AppLogger.Info("App", "Framework initialization completed.");
LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePluginRuntime();
AppSettingsService.SettingsSaved += OnAppSettingsSaved;
InitializeTrayIcon();
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
DisableAvaloniaDataAnnotationValidation();
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
desktop.Exit += (_, _) =>
{
AppLogger.Info("App", "Desktop lifetime exit triggered.");
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
DisposeTrayIcon();
};
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
}
base.OnFrameworkInitializationCompleted();
@@ -50,6 +66,8 @@ public partial class App : Application
private void OnTrayExitClick(object? sender, EventArgs e)
{
DisposeTrayIcon();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
@@ -89,7 +107,7 @@ public partial class App : Application
}
catch (Exception ex)
{
Debug.WriteLine($"[TraySettings] Failed to open settings window: {ex}");
AppLogger.Warn("TraySettings", "Failed to open settings window.", ex);
}
}, DispatcherPriority.Normal);
}
@@ -99,6 +117,17 @@ public partial class App : Application
AppRestartService.TryRestartApplication();
}
private void OnAppSettingsSaved(string _)
{
Dispatcher.UIThread.Post(() =>
{
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
}, DispatcherPriority.Background);
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
@@ -133,9 +162,10 @@ public partial class App : Application
userDataFolder,
EnvironmentVariableTarget.Process);
}
catch
catch (Exception ex)
{
// Keep startup resilient if user profile folders are unavailable.
AppLogger.Warn("WebView2", "Failed to configure WebView2 user data folder.", ex);
}
}
@@ -149,7 +179,77 @@ public partial class App : Application
}
catch (Exception ex)
{
Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}");
AppLogger.Warn("PluginRuntime", "Failed to initialize plugin runtime.", ex);
}
}
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);
}
}

View File

@@ -40,6 +40,7 @@
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.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="FluentIcons.Avalonia" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />

View File

@@ -1,5 +1,9 @@
{
"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",
"tooltip.back_to_windows": "Back to Windows",
"tooltip.open_settings": "Settings",
@@ -302,15 +306,17 @@
"settings.launcher.hidden_empty": "No hidden items.",
"settings.launcher.hidden_type_folder": "Folder",
"settings.launcher.hidden_type_shortcut": "Shortcut",
"settings.launcher.restore_button": "Show Again",
"settings.launcher.restore_button": "Unhide",
"settings.plugins.title": "Plugins",
"settings.plugins.runtime_header": "Plugin Runtime",
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
"settings.plugins.installed_header": "Installed Plugins",
"settings.plugins.installed_desc": "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.",
"settings.plugins.restart_hint": "Plugin enable state changes take effect after restarting the app.",
"settings.plugins.installed_desc": "Review installed plugins and remove them here.",
"settings.plugins.import_header": "Install From Package",
"settings.plugins.import_desc": "Open a .laapp package and stage it into the local plugin directory.",
"settings.plugins.restart_hint": "Plugin installation and deletion changes take effect after restarting the app.",
"settings.plugins.empty": "No plugins found.",
"settings.plugins.runtime_unavailable": "Plugin runtime is not available.",
"settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
@@ -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_state_enabled": "enabled",
"settings.plugins.toggle_state_disabled": "disabled",
"settings.plugins.toggle_failed_detail_format": "Failed to update plugin '{0}': {1}",
"settings.plugins.install_button": "Open .laapp package",
"settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.",
"settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}",
@@ -334,14 +341,20 @@
"settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.",
"settings.plugins.install_success_format": "Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
"settings.plugins.install_failed_format": "Failed to install plugin package: {0}",
"settings.plugins.delete_button": "Delete plugin",
"settings.plugins.delete_success_format": "Plugin '{0}' was staged for deletion. Restart the app to finish removing it.",
"settings.plugins.delete_failed_format": "Failed to delete plugin: {0}",
"settings.plugins.delete_failed_detail_format": "Failed to delete plugin '{0}': {1}",
"settings.plugins.publisher_format": "Publisher: {0}",
"settings.plugins.publisher_unknown": "Unknown publisher",
"settings.plugins.source_package": ".laapp package",
"settings.plugins.source_manifest": "Loose manifest",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
"settings.plugins.market_header": "Official Market",
"settings.plugins.market_desc": "Browse plugins from the official LanAirApp source and stage installs.",
"settings.plugins.market_hint": "Use the official market source hosted in LanAirApp to discover and stage plugin installs.",
"settings.plugins.market_unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
"settings.nav.plugin_market": "Plugin Market",
"settings.plugin_market.title": "Plugin Market",
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
"market.toolbar.search_placeholder": "Search plugins",
"market.toolbar.refresh": "Refresh",
"market.status.loading": "Loading the official plugin market...",
@@ -364,6 +377,15 @@
"market.detail.min_host_version": "Minimum Host Version",
"market.detail.installed_version": "Installed Version",
"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.homepage": "Homepage",
"market.detail.repository": "Repository",

View File

@@ -1,5 +1,9 @@
{
"app.title": "LanMountainDesktop",
"tray.tooltip": "LanMountainDesktop",
"tray.menu.settings": "设置",
"tray.menu.restart": "重启应用",
"tray.menu.exit": "退出应用",
"button.back_to_windows": "回到Windows",
"tooltip.back_to_windows": "回到Windows",
"tooltip.open_settings": "设置",
@@ -302,15 +306,17 @@
"settings.launcher.hidden_empty": "暂无隐藏项目。",
"settings.launcher.hidden_type_folder": "文件夹",
"settings.launcher.hidden_type_shortcut": "快捷方式",
"settings.launcher.restore_button": "重新显示",
"settings.launcher.restore_button": "取消隐藏",
"settings.plugins.title": "插件",
"settings.plugins.runtime_header": "插件运行时",
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
"settings.plugins.installed_header": "已安装插件",
"settings.plugins.installed_desc": "在这里启用或禁用插件。插件自己的详细设置会作为独立设置页出现。",
"settings.plugins.restart_hint": "插件启用状态变更会在重启应用后生效。",
"settings.plugins.installed_desc": "在这里查看和删除已安装的插件。",
"settings.plugins.import_header": "从安装包导入",
"settings.plugins.import_desc": "打开一个 .laapp 插件包,并将其暂存到本地插件目录。",
"settings.plugins.restart_hint": "插件安装和删除变更会在重启应用后生效。",
"settings.plugins.empty": "未找到插件。",
"settings.plugins.runtime_unavailable": "插件运行时不可用。",
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
@@ -325,6 +331,7 @@
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
"settings.plugins.toggle_state_enabled": "启用",
"settings.plugins.toggle_state_disabled": "禁用",
"settings.plugins.toggle_failed_detail_format": "更新插件“{0}”状态失败:{1}",
"settings.plugins.install_button": "打开 .laapp 插件包",
"settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。",
"settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}",
@@ -334,14 +341,20 @@
"settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。",
"settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。",
"settings.plugins.install_failed_format": "安装插件包失败:{0}",
"settings.plugins.delete_button": "删除插件",
"settings.plugins.delete_success_format": "插件“{0}”已暂存删除。重启应用后会完成移除。",
"settings.plugins.delete_failed_format": "删除插件失败:{0}",
"settings.plugins.delete_failed_detail_format": "删除插件“{0}”失败:{1}",
"settings.plugins.publisher_format": "发布者:{0}",
"settings.plugins.publisher_unknown": "未知发布者",
"settings.plugins.source_package": ".laapp 包",
"settings.plugins.source_manifest": "散装清单",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
"settings.plugins.market_header": "官方市场",
"settings.plugins.market_desc": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
"settings.plugins.market_hint": "这里使用托管在 LanAirApp 仓库中的官方市场索引来发现插件并暂存安装。",
"settings.plugins.market_unavailable": "插件运行时不可用,暂时无法打开官方市场。",
"settings.nav.plugin_market": "插件市场",
"settings.plugin_market.title": "插件市场",
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
"market.toolbar.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新",
"market.status.loading": "正在加载官方插件市场...",
@@ -364,6 +377,15 @@
"market.detail.min_host_version": "最低宿主版本",
"market.detail.installed_version": "已安装版本",
"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.homepage": "主页",
"market.detail.repository": "仓库",

View File

@@ -2,6 +2,7 @@ using Avalonia;
using Avalonia.WebView.Desktop;
using LanMountainDesktop.Services;
using System;
using System.Threading.Tasks;
namespace LanMountainDesktop;
@@ -11,8 +12,27 @@ sealed class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp(LoadConfiguredRenderMode())
.StartWithClassicDesktopLifetime(args);
public static void Main(string[] args)
{
AppLogger.Initialize();
RegisterGlobalExceptionLogging();
var diagnostics = StartupDiagnosticsService.Run(args);
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
try
{
var renderMode = LoadConfiguredRenderMode();
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally.");
}
catch (Exception ex)
{
AppLogger.Critical("Startup", "Application terminated during startup.", ex);
throw;
}
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
@@ -44,9 +64,26 @@ sealed class Program
{
return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode);
}
catch
catch (Exception ex)
{
AppLogger.Warn("Startup", "Failed to load configured render mode. Falling back to default.", ex);
return AppRenderingModeHelper.Default;
}
}
private static void RegisterGlobalExceptionLogging()
{
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
{
AppLogger.Critical(
"UnhandledException",
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
eventArgs.ExceptionObject as Exception);
};
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
{
AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception);
};
}
}

View 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;
}
}
}

View File

@@ -63,8 +63,9 @@ public sealed class AppSettingsService
return loadedSnapshot.Clone();
}
}
catch
catch (Exception ex)
{
AppLogger.Warn("AppSettings", $"Failed to load settings from '{_settingsPath}'.", ex);
return new AppSettingsSnapshot();
}
}
@@ -95,9 +96,9 @@ public sealed class AppSettingsService
SettingsSaved?.Invoke(InstanceId);
}
catch
catch (Exception ex)
{
// Swallow persistence errors to keep UI interactions uninterrupted.
AppLogger.Warn("AppSettings", $"Failed to save settings to '{_settingsPath}'.", ex);
}
}
@@ -136,8 +137,9 @@ public sealed class AppSettingsService
var json = File.ReadAllText(_settingsPath);
return JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions) ?? new AppSettingsSnapshot();
}
catch
catch (Exception ex)
{
AppLogger.Warn("AppSettings", $"Failed to deserialize settings from '{_settingsPath}'.", ex);
return new AppSettingsSnapshot();
}
}

View File

@@ -51,8 +51,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return document.DefaultSettings.Clone();
}
}
catch
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to load component settings from '{_settingsPath}'.", ex);
return new ComponentSettingsSnapshot();
}
}
@@ -76,9 +77,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
PersistDocumentLocked(document);
}
}
catch
catch (Exception ex)
{
// Swallow persistence errors to keep UI interactions uninterrupted.
AppLogger.Warn("ComponentSettings", $"Failed to save default component settings to '{_settingsPath}'.", ex);
}
}
@@ -99,8 +100,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return document.DefaultSettings.Clone();
}
}
catch
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to load component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
return new ComponentSettingsSnapshot();
}
}
@@ -124,9 +129,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
PersistDocumentLocked(document);
}
}
catch
catch (Exception ex)
{
// Swallow persistence errors to keep UI interactions uninterrupted.
AppLogger.Warn(
"ComponentSettings",
$"Failed to save component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
}
}
@@ -151,9 +159,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
}
}
}
catch
catch (Exception ex)
{
// Swallow persistence errors to keep UI interactions uninterrupted.
AppLogger.Warn(
"ComponentSettings",
$"Failed to delete component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
}
}
@@ -174,8 +185,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return JsonSerializer.Deserialize<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();
}
}
@@ -197,9 +212,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
PersistDocumentLocked(document);
}
}
catch
catch (Exception ex)
{
// Swallow persistence errors to keep UI interactions uninterrupted.
AppLogger.Warn(
"ComponentSettings",
$"Failed to save plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
}
}
@@ -222,9 +240,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
}
}
}
catch
catch (Exception ex)
{
// Swallow persistence errors to keep UI interactions uninterrupted.
AppLogger.Warn(
"ComponentSettings",
$"Failed to delete plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
}
}
@@ -373,8 +394,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
DefaultSettings = NormalizeSnapshot(legacySnapshot)
};
}
catch
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex);
return new ComponentSettingsDocumentSnapshot();
}
}
@@ -428,8 +450,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return true;
}
catch
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to migrate legacy component settings from '{_legacyAppSettingsPath}'.", ex);
return false;
}
}

View File

@@ -80,8 +80,9 @@ public sealed class DesktopLayoutSettingsService
return normalizedSnapshot.Clone();
}
}
catch
catch (Exception ex)
{
AppLogger.Warn("DesktopLayout", $"Failed to load desktop layout settings from '{_settingsPath}'.", ex);
return new DesktopLayoutSettingsSnapshot();
}
}
@@ -99,9 +100,9 @@ public sealed class DesktopLayoutSettingsService
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
}
}
catch
catch (Exception ex)
{
// Swallow persistence errors to keep UI interactions uninterrupted.
AppLogger.Warn("DesktopLayout", $"Failed to save desktop layout settings to '{_settingsPath}'.", ex);
}
}
@@ -141,8 +142,9 @@ public sealed class DesktopLayoutSettingsService
var snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions);
return NormalizeSnapshot(snapshot);
}
catch
catch (Exception ex)
{
AppLogger.Warn("DesktopLayout", $"Failed to deserialize desktop layout settings from '{_settingsPath}'.", ex);
return new DesktopLayoutSettingsSnapshot();
}
}
@@ -174,8 +176,9 @@ public sealed class DesktopLayoutSettingsService
return true;
}
catch
catch (Exception ex)
{
AppLogger.Warn("DesktopLayout", $"Failed to migrate legacy desktop layout settings from '{_legacyAppSettingsPath}'.", ex);
return false;
}
}

View 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;
}
}

View File

@@ -45,6 +45,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
private readonly string _owner;
private readonly string _repo;
private readonly HttpClient _httpClient;
private readonly ResumableDownloadService _downloadService;
private readonly bool _ownsHttpClient;
public GitHubReleaseUpdateService(
@@ -69,6 +70,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
_ownsHttpClient = false;
}
_downloadService = new ResumableDownloadService(_httpClient);
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
@@ -187,59 +190,20 @@ public sealed class GitHubReleaseUpdateService : IDisposable
return new UpdateDownloadResult(false, null, "Destination file path is empty.");
}
try
{
var directory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var progressAdapter = progress is null
? null
: new Progress<DownloadProgressInfo>(info => progress.Report(info.Progress));
using var response = await _httpClient.GetAsync(
asset.BrowserDownloadUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
var result = await _downloadService.DownloadAsync(
asset.BrowserDownloadUrl,
destinationFilePath,
new DownloadOptions(ExpectedSizeBytes: asset.SizeBytes > 0 ? asset.SizeBytes : null),
progressAdapter,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new UpdateDownloadResult(
false,
null,
$"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}");
}
var contentLength = response.Content.Headers.ContentLength ??
(asset.SizeBytes > 0 ? asset.SizeBytes : -1);
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destinationStream = File.Create(destinationFilePath);
var buffer = new byte[81920];
long totalRead = 0;
int read;
while ((read = await sourceStream.ReadAsync(buffer, cancellationToken)) > 0)
{
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
totalRead += read;
if (contentLength > 0)
{
progress?.Report(Math.Clamp(totalRead / (double)contentLength, 0d, 1d));
}
}
progress?.Report(1d);
return new UpdateDownloadResult(true, destinationFilePath, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateDownloadResult(false, null, ex.Message);
}
return result.Success
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null)
: new UpdateDownloadResult(false, null, result.ErrorMessage);
}
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(

View File

@@ -85,8 +85,9 @@ public sealed class LauncherSettingsService
return normalizedSnapshot.Clone();
}
}
catch
catch (Exception ex)
{
AppLogger.Warn("LauncherSettings", $"Failed to load launcher settings from '{_settingsPath}'.", ex);
return new LauncherSettingsSnapshot();
}
}
@@ -106,9 +107,9 @@ public sealed class LauncherSettingsService
SettingsSaved?.Invoke(InstanceId);
}
catch
catch (Exception ex)
{
// Swallow persistence errors to keep UI interactions uninterrupted.
AppLogger.Warn("LauncherSettings", $"Failed to save launcher settings to '{_settingsPath}'.", ex);
}
}
@@ -148,8 +149,9 @@ public sealed class LauncherSettingsService
var snapshot = JsonSerializer.Deserialize<LauncherSettingsSnapshot>(json, SerializerOptions);
return NormalizeSnapshot(snapshot);
}
catch
catch (Exception ex)
{
AppLogger.Warn("LauncherSettings", $"Failed to deserialize launcher settings from '{_settingsPath}'.", ex);
return new LauncherSettingsSnapshot();
}
}
@@ -180,8 +182,9 @@ public sealed class LauncherSettingsService
return true;
}
catch
catch (Exception ex)
{
AppLogger.Warn("LauncherSettings", $"Failed to migrate legacy launcher settings from '{_legacyAppSettingsPath}'.", ex);
return false;
}
}

View File

@@ -0,0 +1,412 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Downloader;
namespace LanMountainDesktop.Services;
public sealed record DownloadProgressInfo(
long DownloadedBytes,
long? TotalBytes,
double Progress,
bool IsResuming,
bool IsParallel);
public sealed record DownloadOptions(
long? ExpectedSizeBytes = null,
int MaxParallelSegments = 4,
int ParallelThresholdBytes = 8 * 1024 * 1024,
int BufferSize = 128 * 1024);
public sealed record DownloadResult(
bool Success,
string? FilePath,
string? ErrorMessage,
bool UsedResume,
bool UsedParallelDownload);
public sealed class ResumableDownloadService
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> DestinationGates =
new(StringComparer.OrdinalIgnoreCase);
public ResumableDownloadService(System.Net.Http.HttpClient httpClient)
{
}
public async Task<DownloadResult> DownloadAsync(
string source,
string destinationFilePath,
DownloadOptions? options = null,
IProgress<DownloadProgressInfo>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(source);
ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath);
var normalizedOptions = NormalizeOptions(options);
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
var destinationGate = DestinationGates.GetOrAdd(
fullDestinationPath,
static _ => new SemaphoreSlim(1, 1));
await destinationGate.WaitAsync(cancellationToken);
try
{
if (File.Exists(source))
{
return await CopyLocalFileAsync(
source,
fullDestinationPath,
normalizedOptions,
progress,
cancellationToken);
}
if (!Uri.TryCreate(source, UriKind.Absolute, out var sourceUri) ||
(sourceUri.Scheme != Uri.UriSchemeHttp && sourceUri.Scheme != Uri.UriSchemeHttps))
{
return new DownloadResult(false, null, $"Unsupported download source '{source}'.", false, false);
}
return await DownloadRemoteFileAsync(
sourceUri,
fullDestinationPath,
normalizedOptions,
progress,
cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
AppLogger.Warn(
"Downloader",
$"Download failed. Source='{source}'; Destination='{fullDestinationPath}'.",
ex);
return new DownloadResult(false, null, ex.Message, false, false);
}
finally
{
destinationGate.Release();
}
}
private async Task<DownloadResult> CopyLocalFileAsync(
string sourceFilePath,
string destinationFilePath,
DownloadOptions options,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
var fullSourcePath = Path.GetFullPath(sourceFilePath);
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
var totalBytes = new FileInfo(fullSourcePath).Length;
var tempFilePath = BuildTempFilePath(fullDestinationPath);
PrepareDestination(fullDestinationPath);
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
{
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, false, false));
CleanupLocalPartialArtifacts(tempFilePath);
return new DownloadResult(true, fullDestinationPath, null, false, false);
}
long existingBytes = 0;
if (File.Exists(tempFilePath))
{
existingBytes = new FileInfo(tempFilePath).Length;
if (existingBytes > totalBytes)
{
CleanupLocalPartialArtifacts(tempFilePath);
existingBytes = 0;
}
}
if (!File.Exists(tempFilePath))
{
await using var tempCreateStream = new FileStream(
tempFilePath,
FileMode.Create,
FileAccess.Write,
FileShare.Read,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
}
if (existingBytes >= totalBytes)
{
CompleteLocalCopy(tempFilePath, fullDestinationPath);
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, existingBytes > 0, false));
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
}
await using var sourceStream = new FileStream(
fullSourcePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
await using var destinationStream = new FileStream(
tempFilePath,
FileMode.Open,
FileAccess.Write,
FileShare.Read,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
if (existingBytes > 0)
{
sourceStream.Seek(existingBytes, SeekOrigin.Begin);
destinationStream.Seek(existingBytes, SeekOrigin.Begin);
}
await CopyStreamAsync(
sourceStream,
destinationStream,
existingBytes,
totalBytes,
progress,
cancellationToken);
CompleteLocalCopy(tempFilePath, fullDestinationPath);
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
}
private async Task<DownloadResult> DownloadRemoteFileAsync(
Uri sourceUri,
string destinationFilePath,
DownloadOptions options,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
PrepareDestination(destinationFilePath);
if (CanReuseCompletedDestination(destinationFilePath, options.ExpectedSizeBytes))
{
var existingLength = new FileInfo(destinationFilePath).Length;
progress?.Report(new DownloadProgressInfo(existingLength, options.ExpectedSizeBytes, 1d, false, false));
CleanupDownloaderArtifacts(destinationFilePath);
return new DownloadResult(true, destinationFilePath, null, false, false);
}
var usedResume = HasDownloaderResumeArtifacts(destinationFilePath);
var usedParallelDownload = ShouldUseParallelDownload(options);
var configuration = CreateConfiguration(options, usedParallelDownload);
using var downloader = new DownloadService(configuration);
downloader.DownloadProgressChanged += (_, args) =>
{
progress?.Report(MapProgress(args, options.ExpectedSizeBytes, usedResume, usedParallelDownload));
};
using var cancellationRegistration = cancellationToken.Register(() =>
{
try
{
downloader.CancelAsync();
}
catch (Exception ex)
{
AppLogger.Warn(
"Downloader",
$"Failed to cancel Downloader request for '{destinationFilePath}'.",
ex);
}
});
AppLogger.Info(
"Downloader",
$"Starting remote download. Source='{sourceUri}'; Destination='{destinationFilePath}'; Parallel={usedParallelDownload}; ChunkCount={configuration.ChunkCount}; Resume={usedResume}.");
await downloader.DownloadFileTaskAsync(sourceUri.AbsoluteUri, destinationFilePath);
if (!File.Exists(destinationFilePath))
{
throw new FileNotFoundException(
$"Downloader completed without producing '{destinationFilePath}'.",
destinationFilePath);
}
var finalLength = new FileInfo(destinationFilePath).Length;
progress?.Report(new DownloadProgressInfo(
finalLength,
options.ExpectedSizeBytes ?? finalLength,
1d,
usedResume,
usedParallelDownload));
AppLogger.Info(
"Downloader",
$"Remote download completed. Source='{sourceUri}'; Destination='{destinationFilePath}'; Size={finalLength}; Parallel={usedParallelDownload}; Resume={usedResume}.");
return new DownloadResult(true, destinationFilePath, null, usedResume, usedParallelDownload);
}
private static async Task CopyStreamAsync(
Stream sourceStream,
Stream destinationStream,
long initialDownloadedBytes,
long totalBytes,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
var buffer = new byte[128 * 1024];
var downloadedBytes = initialDownloadedBytes;
progress?.Report(new DownloadProgressInfo(downloadedBytes, totalBytes, downloadedBytes / (double)totalBytes, initialDownloadedBytes > 0, false));
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;
progress?.Report(new DownloadProgressInfo(
downloadedBytes,
totalBytes,
Math.Clamp(downloadedBytes / (double)totalBytes, 0d, 1d),
initialDownloadedBytes > 0,
false));
}
await destinationStream.FlushAsync(cancellationToken);
}
private static DownloadConfiguration CreateConfiguration(DownloadOptions options, bool useParallelDownload)
{
return new DownloadConfiguration
{
BufferBlockSize = options.BufferSize,
ChunkCount = useParallelDownload ? options.MaxParallelSegments : 1,
ParallelCount = useParallelDownload ? options.MaxParallelSegments : 1,
ParallelDownload = useParallelDownload,
MinimumSizeOfChunking = options.ParallelThresholdBytes,
MaxTryAgainOnFailure = 3,
ResumeDownloadIfCan = true,
ClearPackageOnCompletionWithFailure = false,
FileExistPolicy = FileExistPolicy.Delete,
DownloadFileExtension = ".part"
};
}
private static DownloadProgressInfo MapProgress(
DownloadProgressChangedEventArgs args,
long? expectedSizeBytes,
bool isResuming,
bool isParallel)
{
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)
{
normalizedProgress = downloadedBytes / (double)totalBytes.Value;
}
return new DownloadProgressInfo(
downloadedBytes,
totalBytes,
Math.Clamp(normalizedProgress, 0d, 1d),
isResuming,
isParallel);
}
private static bool CanReuseCompletedDestination(string destinationFilePath, long? expectedSizeBytes)
{
if (!File.Exists(destinationFilePath))
{
return false;
}
if (expectedSizeBytes is not > 0)
{
return false;
}
return new FileInfo(destinationFilePath).Length == expectedSizeBytes.Value;
}
private static void PrepareDestination(string destinationFilePath)
{
var directory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
}
private static void CleanupLocalPartialArtifacts(string tempFilePath)
{
if (File.Exists(tempFilePath))
{
FileOperationRetryHelper.DeleteFileWithRetry(tempFilePath, "Downloader");
}
}
private static void CompleteLocalCopy(string tempFilePath, string destinationFilePath)
{
if (!File.Exists(tempFilePath))
{
return;
}
FileOperationRetryHelper.MoveWithOverwriteRetry(tempFilePath, destinationFilePath, "Downloader");
}
private static void CleanupDownloaderArtifacts(string destinationFilePath)
{
var transientFilePath = BuildTempFilePath(destinationFilePath);
var metadataFilePath = BuildPackageFilePath(destinationFilePath);
if (File.Exists(transientFilePath))
{
FileOperationRetryHelper.DeleteFileWithRetry(transientFilePath, "Downloader");
}
if (File.Exists(metadataFilePath))
{
FileOperationRetryHelper.DeleteFileWithRetry(metadataFilePath, "Downloader");
}
}
private static bool HasDownloaderResumeArtifacts(string destinationFilePath)
{
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 BuildPackageFilePath(string destinationFilePath) => destinationFilePath + ".download";
}

View 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;
}
}
}

View 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);
}

View File

@@ -30,8 +30,9 @@ public sealed class WindowsStartupService
return runKey?.GetValue(ValueName) is string value &&
!string.IsNullOrWhiteSpace(value);
}
catch
catch (Exception ex)
{
AppLogger.Warn("WindowsStartup", "Failed to query startup registry state.", ex);
return false;
}
}
@@ -67,8 +68,9 @@ public sealed class WindowsStartupService
return IsEnabled() == enabled;
}
catch
catch (Exception ex)
{
AppLogger.Warn("WindowsStartup", $"Failed to set startup registry state. Enabled={enabled}", ex);
return false;
}
}

View File

@@ -13,6 +13,7 @@ using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -1069,12 +1070,12 @@ public partial class MainWindow
private void RenderLauncherHiddenItemsList()
{
if (LauncherHiddenItemsListPanel is null || LauncherHiddenItemsEmptyTextBlock is null)
if (LauncherHiddenItemsSettingsExpander is null || LauncherHiddenItemsEmptyTextBlock is null)
{
return;
}
LauncherHiddenItemsListPanel.Children.Clear();
LauncherHiddenItemsSettingsExpander.Items.Clear();
var hiddenItems = BuildLauncherHiddenItems();
LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0;
if (hiddenItems.Count == 0)
@@ -1084,7 +1085,7 @@ public partial class MainWindow
foreach (var hiddenItem in hiddenItems)
{
LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem));
LauncherHiddenItemsSettingsExpander.Items.Add(CreateLauncherHiddenItemRow(hiddenItem));
}
}
@@ -1186,92 +1187,47 @@ public partial class MainWindow
: fileName;
}
private Control CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem)
private SettingsExpanderItem CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem)
{
Control icon = hiddenItem.IconBitmap is not null
? new Image
{
Source = hiddenItem.IconBitmap,
Width = 24,
Height = 24,
Stretch = Stretch.Uniform
}
: new Border
{
Width = 24,
Height = 24,
CornerRadius = new CornerRadius(999),
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
Child = new TextBlock
{
Text = hiddenItem.Monogram,
FontSize = 10,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
}
};
var typeText = hiddenItem.Kind == LauncherEntryKind.Folder
? L("settings.launcher.hidden_type_folder", "Folder")
: L("settings.launcher.hidden_type_shortcut", "Shortcut");
var infoPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
};
infoPanel.Children.Add(icon);
infoPanel.Children.Add(new StackPanel
{
Spacing = 2,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Left,
Children =
{
new TextBlock
{
Text = hiddenItem.DisplayName,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxLines = 1
},
new TextBlock
{
Text = typeText,
FontSize = 11,
Opacity = 0.7
}
}
});
var restoreButton = new Button
{
Content = L("settings.launcher.restore_button", "Show Again"),
Content = L("settings.launcher.restore_button", "Unhide"),
MinWidth = 110,
Padding = new Thickness(12, 6),
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
};
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
var row = new Grid
return new SettingsExpanderItem
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
ColumnSpacing = 10
Content = hiddenItem.DisplayName,
Description = typeText,
IconSource = CreateLauncherHiddenItemIconSource(hiddenItem),
IsClickEnabled = false,
Footer = restoreButton
};
row.Children.Add(infoPanel);
Grid.SetColumn(infoPanel, 0);
row.Children.Add(restoreButton);
Grid.SetColumn(restoreButton, 1);
}
return new Border
private IconSource CreateLauncherHiddenItemIconSource(LauncherHiddenItemView hiddenItem)
{
if (hiddenItem.IconBitmap is not null)
{
Classes = { "glass-panel" },
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(10, 8),
Child = row
return new ImageIconSource
{
Source = hiddenItem.IconBitmap
};
}
return new FluentIcons.Avalonia.Fluent.SymbolIconSource
{
Symbol = hiddenItem.Kind == LauncherEntryKind.Folder
? FluentIcons.Common.Symbol.Folder
: FluentIcons.Common.Symbol.Apps,
IconVariant = FluentIcons.Common.IconVariant.Regular
};
}

View File

@@ -119,6 +119,7 @@ public partial class MainWindow
SettingsNavUpdateItem.Content = L("settings.nav.update", "Update");
SettingsNavLauncherItem.Content = L("settings.nav.launcher", "App Launcher");
SettingsNavPluginsItem.Content = L("settings.nav.plugins", "Plugins");
SettingsNavPluginMarketItem.Content = L("settings.nav.plugin_market", "Plugin Market");
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
@@ -283,6 +284,7 @@ public partial class MainWindow
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
ApplyPluginSettingsLocalization();
ApplyPluginMarketSettingsLocalization();
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");

View File

@@ -115,7 +115,8 @@ public partial class MainWindow
UpdateSettingsPanel is null ||
LauncherSettingsPanel is null ||
AboutSettingsPanel is null ||
PluginSettingsPanel is null)
PluginSettingsPanel is null ||
PluginMarketSettingsPanel is null)
{
return;
}
@@ -133,6 +134,7 @@ public partial class MainWindow
AboutSettingsPanel.IsVisible = tag == "About";
LauncherSettingsPanel.IsVisible = tag == "Launcher";
PluginSettingsPanel.IsVisible = tag == "Plugins";
PluginMarketSettingsPanel.IsVisible = tag == "PluginMarket";
UpdatePluginSettingsPageVisibility(tag);
if (tag == "Launcher")
@@ -140,6 +142,16 @@ public partial class MainWindow
RenderLauncherHiddenItemsList();
}
if (tag == "Plugins")
{
PluginSettingsPanel.RefreshFromRuntime();
}
if (tag == "PluginMarket")
{
PluginMarketSettingsPanel.RefreshFromRuntime();
}
if (tag == "Grid")
{
UpdateGridPreviewLayout();
@@ -2793,7 +2805,6 @@ public partial class MainWindow
// --- LauncherSettingsPage ---
internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!;
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 LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;

View File

@@ -436,6 +436,11 @@
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavPluginMarketItem" Content="插件市场" Tag="PluginMarket" ToolTip.Tip="插件市场">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
</ui:NavigationView.MenuItems>
<ScrollViewer x:Name="SettingsContentScrollViewer"
@@ -459,6 +464,7 @@
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
<pages:PluginMarketSettingsPage x:Name="PluginMarketSettingsPanel" IsVisible="False" />
</Grid>
</ScrollViewer>
</ui:NavigationView>

View File

@@ -31,12 +31,6 @@
IsVisible="False"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No hidden items." />
<ScrollViewer MaxHeight="420"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel x:Name="LauncherHiddenItemsListPanel"
Spacing="8" />
</ScrollViewer>
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>

View File

@@ -211,7 +211,6 @@ public partial class SettingsWindow
// --- LauncherSettingsPage ---
internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!;
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 LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;

View File

@@ -69,6 +69,7 @@ public partial class SettingsWindow
AddSettingsNavItem(SettingsSecondaryNavHost, "Update", Symbol.ArrowSync, "Update");
AddSettingsNavItem(SettingsSecondaryNavHost, "About", Symbol.Info, "About");
AddSettingsNavItem(SettingsSecondaryNavHost, "Plugins", Symbol.PuzzlePiece, "Plugins");
AddSettingsNavItem(SettingsSecondaryNavHost, "PluginMarket", Symbol.PuzzlePiece, "Plugin Market");
}
private void OnSettingsNavItemClick(object? sender, RoutedEventArgs e)
@@ -229,6 +230,7 @@ public partial class SettingsWindow
AboutSettingsPanel.IsVisible = tag == "About";
LauncherSettingsPanel.IsVisible = tag == "Launcher";
PluginSettingsPanel.IsVisible = tag == "Plugins";
PluginMarketSettingsPanel.IsVisible = tag == "PluginMarket";
UpdatePluginSettingsPageVisibility(tag);
if (tag == "Launcher")
@@ -236,6 +238,16 @@ public partial class SettingsWindow
RenderLauncherHiddenItemsList();
}
if (tag == "Plugins")
{
PluginSettingsPanel.RefreshFromRuntime();
}
if (tag == "PluginMarket")
{
PluginMarketSettingsPanel.RefreshFromRuntime();
}
if (tag == "Grid")
{
UpdateGridPreviewLayout();

View File

@@ -74,6 +74,7 @@ public partial class SettingsWindow
SetSettingsNavItemLabel(GetSettingsNavItem("About"), L("settings.nav.about", "About"));
SetSettingsNavItemLabel(GetSettingsNavItem("Launcher"), L("settings.nav.launcher", "App Launcher"));
SetSettingsNavItemLabel(GetSettingsNavItem("Plugins"), L("settings.nav.plugins", "Plugins"));
SetSettingsNavItemLabel(GetSettingsNavItem("PluginMarket"), L("settings.nav.plugin_market", "Plugin Market"));
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
@@ -177,6 +178,7 @@ public partial class SettingsWindow
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
ApplyPluginSettingsLocalization();
ApplyPluginMarketSettingsLocalization();
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());

View File

@@ -11,6 +11,7 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using FluentIcons.Common;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -626,18 +627,18 @@ public partial class SettingsWindow
return string.Create(CultureInfo.InvariantCulture, $"{temperatureC:0.#}°C");
}
private static Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight)
private static FluentIcons.Common.Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight)
{
return weatherCode switch
{
0 => isNight ? Symbol.WeatherMoon : Symbol.WeatherSunny,
1 or 2 => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay,
3 or 7 => Symbol.WeatherRainShowersDay,
8 or 9 => Symbol.WeatherRain,
4 => Symbol.WeatherThunderstorm,
13 or 14 or 15 or 16 => Symbol.WeatherSnow,
18 or 32 => Symbol.WeatherFog,
_ => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay
0 => isNight ? FluentIcons.Common.Symbol.WeatherMoon : FluentIcons.Common.Symbol.WeatherSunny,
1 or 2 => isNight ? FluentIcons.Common.Symbol.WeatherPartlyCloudyNight : FluentIcons.Common.Symbol.WeatherPartlyCloudyDay,
3 or 7 => FluentIcons.Common.Symbol.WeatherRainShowersDay,
8 or 9 => FluentIcons.Common.Symbol.WeatherRain,
4 => FluentIcons.Common.Symbol.WeatherThunderstorm,
13 or 14 or 15 or 16 => FluentIcons.Common.Symbol.WeatherSnow,
18 or 32 => FluentIcons.Common.Symbol.WeatherFog,
_ => isNight ? FluentIcons.Common.Symbol.WeatherPartlyCloudyNight : FluentIcons.Common.Symbol.WeatherPartlyCloudyDay
};
}
@@ -782,7 +783,7 @@ public partial class SettingsWindow
private void RenderLauncherHiddenItemsList()
{
LauncherHiddenItemsListPanel.Children.Clear();
LauncherHiddenItemsSettingsExpander.Items.Clear();
var hiddenItems = BuildLauncherHiddenItems();
LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0;
if (hiddenItems.Count == 0)
@@ -792,7 +793,7 @@ public partial class SettingsWindow
foreach (var hiddenItem in hiddenItems)
{
LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem));
LauncherHiddenItemsSettingsExpander.Items.Add(CreateLauncherHiddenItemRow(hiddenItem));
}
}
@@ -864,82 +865,47 @@ public partial class SettingsWindow
return string.IsNullOrWhiteSpace(fileName) ? key : fileName;
}
private Control CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem)
private SettingsExpanderItem CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem)
{
Control icon = hiddenItem.IconBitmap is not null
? new Image
{
Source = hiddenItem.IconBitmap,
Width = 24,
Height = 24,
Stretch = Stretch.Uniform
}
: new Border
{
Width = 24,
Height = 24,
CornerRadius = new CornerRadius(999),
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
Child = new TextBlock
{
Text = hiddenItem.Monogram,
FontSize = 10,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
}
};
var typeText = hiddenItem.Kind == LauncherEntryKind.Folder
? L("settings.launcher.hidden_type_folder", "Folder")
: L("settings.launcher.hidden_type_shortcut", "Shortcut");
var infoPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
};
infoPanel.Children.Add(icon);
infoPanel.Children.Add(new StackPanel
{
Spacing = 2,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Left,
Children =
{
new TextBlock { Text = hiddenItem.DisplayName, TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },
new TextBlock { Text = typeText, FontSize = 11, Opacity = 0.7 }
}
});
var restoreButton = new Button
{
Content = L("settings.launcher.restore_button", "Show Again"),
Content = L("settings.launcher.restore_button", "Unhide"),
MinWidth = 110,
Padding = new Thickness(12, 6),
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
};
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
var row = new Grid
return new SettingsExpanderItem
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
ColumnSpacing = 10
Content = hiddenItem.DisplayName,
Description = typeText,
IconSource = CreateLauncherHiddenItemIconSource(hiddenItem),
IsClickEnabled = false,
Footer = restoreButton
};
row.Children.Add(infoPanel);
Grid.SetColumn(infoPanel, 0);
row.Children.Add(restoreButton);
Grid.SetColumn(restoreButton, 1);
}
return new Border
private IconSource CreateLauncherHiddenItemIconSource(LauncherHiddenItemView hiddenItem)
{
if (hiddenItem.IconBitmap is not null)
{
Classes = { "glass-panel" },
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(10, 8),
Child = row
return new ImageIconSource
{
Source = hiddenItem.IconBitmap
};
}
return new FluentIcons.Avalonia.Fluent.SymbolIconSource
{
Symbol = hiddenItem.Kind == LauncherEntryKind.Folder
? FluentIcons.Common.Symbol.Folder
: FluentIcons.Common.Symbol.Apps,
IconVariant = FluentIcons.Common.IconVariant.Regular
};
}

View File

@@ -253,6 +253,7 @@
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
<pages:PluginMarketSettingsPage x:Name="PluginMarketSettingsPanel" IsVisible="False" />
</Grid>
</ScrollViewer>
</Border>

View 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您要继续吗

View File

@@ -1,6 +1,8 @@
#define MyAppName "LanMountainDesktop"
#define MyAppName "LanMountainDesktop"
#define MyAppPublisher "LanMountainDesktop Team"
#define MyAppExeName "LanMountainDesktop.exe"
#define MyAppId "{{5A058B0D-F95D-4A18-B9A0-93F843655DDB}"
#define MyAppRegistryId "{5A058B0D-F95D-4A18-B9A0-93F843655DDB}"
#ifndef MyAppVersion
#define MyAppVersion "0.0.0"
@@ -19,13 +21,16 @@
#endif
[Setup]
AppId={{5A058B0D-F95D-4A18-B9A0-93F843655DDB}
AppId={#MyAppId}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DisableDirPage=no
UsePreviousAppDir=no
ShowLanguageDialog=yes
UsePreviousLanguage=no
LanguageDetectionMethod=uilanguage
DefaultGroupName={#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
OutputDir={#MyOutputDir}
@@ -34,6 +39,9 @@ Compression=lzma2/ultra64
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
CloseApplications=yes
CloseApplicationsFilter={#MyAppExeName}
RestartApplications=no
DisableProgramGroupPage=yes
#if MyAppArch == "x64"
@@ -47,10 +55,55 @@ ArchitecturesAllowed=x86compatible
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "chinesesimplified"; MessagesFile: "{#SourcePath}\ChineseSimplified.isl"
[CustomMessages]
english.StartupTaskDescription=Launch LanMountainDesktop when you sign in to Windows
chinesesimplified.StartupTaskDescription=登录 Windows 时启动 LanMountainDesktop
english.WebView2MissingMessage=Microsoft Edge WebView2 Runtime is required for the browser component.
chinesesimplified.WebView2MissingMessage=浏览器组件需要 Microsoft Edge WebView2 Runtime。
english.WebView2MissingAction=Click "Yes" to open the official download page. Install it first, then run this installer again.
chinesesimplified.WebView2MissingAction=单击“是”打开官方下载页面。请先完成安装,然后重新运行此安装程序。
english.WebView2OpenFailedMessage=Unable to open the download page automatically.
chinesesimplified.WebView2OpenFailedMessage=无法自动打开下载页面。
english.WebView2OpenFailedAction=Please open this URL manually:
chinesesimplified.WebView2OpenFailedAction=请手动打开以下链接:
english.UpgradePageCaption=Upgrade Existing Installation
chinesesimplified.UpgradePageCaption=升级现有安装
english.UpgradePageDescription=LanMountainDesktop is already installed on this computer. Choose how to upgrade it.
chinesesimplified.UpgradePageDescription=此计算机上已安装 LanMountainDesktop。请选择升级方式。
english.UpgradeDetectedVersionLabel=Detected version:
chinesesimplified.UpgradeDetectedVersionLabel=检测到的版本:
english.UpgradeCurrentLocationLabel=Current location:
chinesesimplified.UpgradeCurrentLocationLabel=当前安装位置:
english.UpgradePageSubCaption=Choose "Upgrade existing installation" to reuse the current location, or choose "Change installation location and migrate installation" to move the app without leaving a duplicate copy behind.
chinesesimplified.UpgradePageSubCaption=选择“升级现有安装”可复用当前安装位置;选择“更改安装位置并迁移安装”可移动应用,同时避免留下重复安装。
english.UpgradeOptionInPlace=Upgrade existing installation
chinesesimplified.UpgradeOptionInPlace=升级现有安装
english.UpgradeOptionRelocate=Change installation location and migrate installation
chinesesimplified.UpgradeOptionRelocate=更改安装位置并迁移安装
english.UpgradeUnknownVersion=Unknown
chinesesimplified.UpgradeUnknownVersion=未知
english.UpgradeCleanupMissingUninstaller=Setup found an existing installation, but its uninstaller is unavailable. Please uninstall the current version manually and run this installer again.
chinesesimplified.UpgradeCleanupMissingUninstaller=安装程序发现了现有安装,但无法找到它的卸载程序。请先手动卸载当前版本,再重新运行此安装程序。
english.UpgradeCleanupFailedPrefix=Setup could not remove the existing installation automatically. Error code:
chinesesimplified.UpgradeCleanupFailedPrefix=安装程序无法自动移除现有安装。错误代码:
english.UpgradeCleanupFailedSuffix=Please close LanMountainDesktop, uninstall the current version manually, and then run this installer again.
chinesesimplified.UpgradeCleanupFailedSuffix=请关闭 LanMountainDesktop手动卸载当前版本然后重新运行此安装程序。
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
Name: "startup"; Description: "Launch LanMountainDesktop when you sign in to Windows"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
Name: "startup"; Description: "{cm:StartupTaskDescription}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Dirs]
Name: "{app}\log"; Permissions: users-modify
[InstallDelete]
Type: files; Name: "{app}\LanMontainDesktop.exe"
Type: files; Name: "{app}\LanMontainDesktop.dll"
Type: files; Name: "{app}\LanMontainDesktop.deps.json"
Type: files; Name: "{app}\LanMontainDesktop.runtimeconfig.json"
Type: files; Name: "{app}\LanMontainDesktop.pdb"
[Files]
Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
@@ -67,12 +120,309 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChang
[Code]
const
UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1';
WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703';
UpgradeChoiceInPlace = 0;
UpgradeChoiceRelocate = 1;
var
UpgradeModePage: TInputOptionWizardPage;
ExistingInstallFound: Boolean;
ExistingInstallPath: String;
ExistingInstallVersion: String;
ExistingUninstallCommand: String;
ExistingInstallWas64Bit: Boolean;
ExistingInstallIsPerUser: Boolean;
ExistingInstallRemoved: Boolean;
function NormalizePathValue(const Value: String): String;
begin
Result := RemoveBackslashUnlessRoot(Trim(Value));
end;
function CombinePath(const BasePath: String; const ChildName: String): String;
begin
if BasePath = '' then
begin
Result := ChildName;
end
else
begin
Result := NormalizePathValue(BasePath) + '\' + ChildName;
end;
end;
function SamePath(const LeftPath: String; const RightPath: String): Boolean;
begin
Result := CompareText(NormalizePathValue(LeftPath), NormalizePathValue(RightPath)) = 0;
end;
function ExtractExecutableFromCommand(const CommandLine: String): String;
var
CommandText: String;
ClosingQuotePos: Integer;
ExePos: Integer;
begin
Result := '';
CommandText := Trim(CommandLine);
if CommandText = '' then
begin
exit;
end;
if CommandText[1] = '"' then
begin
Delete(CommandText, 1, 1);
ClosingQuotePos := Pos('"', CommandText);
if ClosingQuotePos > 0 then
begin
Result := Copy(CommandText, 1, ClosingQuotePos - 1);
end
else
begin
Result := CommandText;
end;
end
else
begin
ExePos := Pos('.exe', LowerCase(CommandText));
if ExePos > 0 then
begin
Result := Copy(CommandText, 1, ExePos + 3);
end
else
begin
Result := CommandText;
end;
end;
Result := NormalizePathValue(RemoveQuotes(Result));
end;
function GetExistingExecutablePath(): String;
begin
if ExistingInstallPath = '' then
begin
Result := '';
end
else
begin
Result := CombinePath(ExistingInstallPath, '{#MyAppExeName}');
end;
end;
function GetDefaultInstallPath(): String;
begin
Result := NormalizePathValue(ExpandConstant('{autopf}\{#MyAppName}'));
end;
function GetExistingInstallVersionText(): String;
begin
Result := Trim(ExistingInstallVersion);
if Result = '' then
begin
Result := CustomMessage('UpgradeUnknownVersion');
end;
end;
procedure ShowUpgradeCleanupError(const MessageText: String);
begin
Log(MessageText);
if not WizardSilent then
begin
MsgBox(MessageText, mbError, MB_OK);
end;
end;
function TryLoadExistingInstallation(
const RootKey: Integer;
const Is64BitView: Boolean;
const IsPerUser: Boolean): Boolean;
var
InstallLocation: String;
AppPath: String;
UninstallString: String;
DisplayVersion: String;
ResolvedPath: String;
begin
Result := False;
InstallLocation := '';
AppPath := '';
UninstallString := '';
DisplayVersion := '';
if not RegKeyExists(RootKey, UninstallRegSubkey) then
begin
exit;
end;
RegQueryStringValue(RootKey, UninstallRegSubkey, 'InstallLocation', InstallLocation);
RegQueryStringValue(RootKey, UninstallRegSubkey, 'Inno Setup: App Path', AppPath);
RegQueryStringValue(RootKey, UninstallRegSubkey, 'UninstallString', UninstallString);
RegQueryStringValue(RootKey, UninstallRegSubkey, 'DisplayVersion', DisplayVersion);
ResolvedPath := NormalizePathValue(InstallLocation);
if ResolvedPath = '' then
begin
ResolvedPath := NormalizePathValue(AppPath);
end;
if (ResolvedPath = '') and (UninstallString <> '') then
begin
ResolvedPath := NormalizePathValue(ExtractFileDir(ExtractExecutableFromCommand(UninstallString)));
end;
if (ResolvedPath = '') or
(not DirExists(ResolvedPath)) or
(not FileExists(CombinePath(ResolvedPath, '{#MyAppExeName}'))) then
begin
exit;
end;
ExistingInstallFound := True;
ExistingInstallPath := ResolvedPath;
ExistingInstallVersion := Trim(DisplayVersion);
ExistingUninstallCommand := Trim(UninstallString);
ExistingInstallWas64Bit := Is64BitView;
ExistingInstallIsPerUser := IsPerUser;
Result := True;
end;
procedure DetectExistingInstallation;
begin
ExistingInstallFound := False;
ExistingInstallPath := '';
ExistingInstallVersion := '';
ExistingUninstallCommand := '';
ExistingInstallWas64Bit := False;
ExistingInstallIsPerUser := False;
ExistingInstallRemoved := False;
if IsWin64 then
begin
if TryLoadExistingInstallation(HKLM64, True, False) then
begin
exit;
end;
if TryLoadExistingInstallation(HKCU64, True, True) then
begin
exit;
end;
end;
if TryLoadExistingInstallation(HKLM32, False, False) then
begin
exit;
end;
TryLoadExistingInstallation(HKCU32, False, True);
end;
function SelectedUpgradeChoice(): Integer;
begin
if UpgradeModePage <> nil then
begin
Result := UpgradeModePage.SelectedValueIndex;
end
else
begin
Result := UpgradeChoiceInPlace;
end;
end;
procedure ApplySelectedInstallDirectory;
var
CurrentDir: String;
begin
if not ExistingInstallFound then
begin
exit;
end;
if SelectedUpgradeChoice() = UpgradeChoiceInPlace then
begin
WizardForm.DirEdit.Text := ExistingInstallPath;
exit;
end;
CurrentDir := NormalizePathValue(WizardDirValue);
if (CurrentDir = '') or SamePath(CurrentDir, GetDefaultInstallPath()) then
begin
WizardForm.DirEdit.Text := ExistingInstallPath;
end;
end;
function GetSelectedInstallPath(): String;
begin
Result := NormalizePathValue(ExpandConstant('{app}'));
if Result = '' then
begin
Result := NormalizePathValue(WizardDirValue);
end;
if Result = '' then
begin
Result := ExistingInstallPath;
end;
end;
function ExistingInstallRequiresCleanup(): Boolean;
var
TargetPath: String;
begin
Result := False;
if not ExistingInstallFound or ExistingInstallRemoved then
begin
exit;
end;
TargetPath := GetSelectedInstallPath();
Result := ExistingInstallIsPerUser or
(not SamePath(TargetPath, ExistingInstallPath)) or
(ExistingInstallWas64Bit <> Is64BitInstallMode);
end;
function RemoveExistingInstallation(): Boolean;
var
UninstallerPath: String;
ResultCode: Integer;
begin
Result := True;
if not ExistingInstallRequiresCleanup() then
begin
exit;
end;
UninstallerPath := ExtractExecutableFromCommand(ExistingUninstallCommand);
if (UninstallerPath = '') or (not FileExists(UninstallerPath)) then
begin
ShowUpgradeCleanupError(CustomMessage('UpgradeCleanupMissingUninstaller'));
Result := False;
exit;
end;
ResultCode := -1;
if not Exec(
UninstallerPath,
'/VERYSILENT /SUPPRESSMSGBOXES /NORESTART',
ExtractFileDir(UninstallerPath),
SW_SHOWNORMAL,
ewWaitUntilTerminated,
ResultCode) or (ResultCode <> 0) then
begin
ShowUpgradeCleanupError(
CustomMessage('UpgradeCleanupFailedPrefix') + ' ' + IntToStr(ResultCode) + '. ' +
CustomMessage('UpgradeCleanupFailedSuffix'));
Result := False;
exit;
end;
ExistingInstallRemoved := True;
end;
function IsWebView2RuntimeInstalled(): Boolean;
var
VersionValue: string;
VersionValue: String;
begin
Result :=
RegQueryStringValue(HKLM64, WebView2RuntimeKeyPath, 'pv', VersionValue) or
@@ -92,16 +442,16 @@ begin
end;
if MsgBox(
'Microsoft Edge WebView2 Runtime is required for the browser component.'#13#10#13#10 +
'Click "Yes" to open the official download page. Install it first, then run this installer again.',
CustomMessage('WebView2MissingMessage') + #13#10#13#10 +
CustomMessage('WebView2MissingAction'),
mbConfirmation,
MB_YESNO) = IDYES then
begin
if not ShellExec('open', WebView2RuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then
begin
MsgBox(
'Unable to open the download page automatically.'#13#10 +
'Please open this URL manually:'#13#10 + WebView2RuntimeDownloadUrl,
CustomMessage('WebView2OpenFailedMessage') + #13#10 +
CustomMessage('WebView2OpenFailedAction') + #13#10 + WebView2RuntimeDownloadUrl,
mbError,
MB_OK);
end;
@@ -109,3 +459,84 @@ begin
Result := False;
end;
procedure InitializeWizard;
var
DetailsText: String;
begin
DetectExistingInstallation;
if not ExistingInstallFound then
begin
exit;
end;
DetailsText :=
CustomMessage('UpgradeDetectedVersionLabel') + ' ' + GetExistingInstallVersionText() + #13#10 +
CustomMessage('UpgradeCurrentLocationLabel') + ' ' + ExistingInstallPath + #13#10#13#10 +
CustomMessage('UpgradePageSubCaption');
UpgradeModePage := CreateInputOptionPage(
wpWelcome,
CustomMessage('UpgradePageCaption'),
CustomMessage('UpgradePageDescription'),
DetailsText,
True,
False);
UpgradeModePage.Add(CustomMessage('UpgradeOptionInPlace'));
UpgradeModePage.Add(CustomMessage('UpgradeOptionRelocate'));
UpgradeModePage.SelectedValueIndex := UpgradeChoiceInPlace;
end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
Result := True;
if (UpgradeModePage <> nil) and (CurPageID = UpgradeModePage.ID) then
begin
ApplySelectedInstallDirectory;
end;
end;
function ShouldSkipPage(PageID: Integer): Boolean;
begin
Result := False;
if (UpgradeModePage <> nil) and (PageID = UpgradeModePage.ID) then
begin
Result := not ExistingInstallFound;
exit;
end;
if PageID = wpSelectDir then
begin
Result := ExistingInstallFound and (SelectedUpgradeChoice() = UpgradeChoiceInPlace);
end;
end;
procedure RegisterExtraCloseApplicationsResources;
var
ExistingExecutablePath: String;
begin
if not ExistingInstallFound then
begin
exit;
end;
ExistingExecutablePath := GetExistingExecutablePath();
if (ExistingExecutablePath <> '') and FileExists(ExistingExecutablePath) then
begin
RegisterExtraCloseApplicationsResource(False, ExistingExecutablePath);
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssInstall then
begin
if not RemoveExistingInstallation() then
begin
Abort;
end;
end;
end;

View File

@@ -0,0 +1,9 @@
namespace LanMountainDesktop.Views;
public partial class MainWindow
{
private void ApplyPluginMarketSettingsLocalization()
{
PluginMarketSettingsPanel.RefreshFromRuntime();
}
}

View File

@@ -9,6 +9,7 @@ public partial class MainWindow
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
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 ImportPluginPackageSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("ImportPluginPackageSettingsExpander")!;
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
}

View File

@@ -39,7 +39,7 @@ public partial class MainWindow
.GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginsItem) + 1;
var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginMarketItem) + 1;
foreach (var contribution in contributions)
{
var tag = BuildPluginSettingsTag(contribution);

View File

@@ -18,10 +18,14 @@ public partial class MainWindow
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
InstalledPluginsSettingsExpander.Description = L(
"settings.plugins.installed_desc",
"Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
"Review installed plugins and remove them here.");
ImportPluginPackageSettingsExpander.Header = L("settings.plugins.import_header", "Install From Package");
ImportPluginPackageSettingsExpander.Description = L(
"settings.plugins.import_desc",
"Open a .laapp package and stage it into the local plugin directory.");
PluginRestartHintTextBlock.Text = L(
"settings.plugins.restart_hint",
"Plugin enable state changes take effect after restarting the app.");
"Plugin installation and deletion changes take effect after restarting the app.");
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
PluginSettingsPanel.RefreshFromRuntime();
}

View File

@@ -10,6 +10,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Services;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Plugins;
@@ -346,6 +347,9 @@ public sealed class PluginLoader
private string ExtractPackage(string packagePath, string pluginsRootDirectory)
{
var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath);
AppLogger.Info(
"PluginLoader",
$"Extracting package '{packagePath}' to '{extractionDirectory}'.");
RecreateDirectory(extractionDirectory);
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
return extractionDirectory;
@@ -381,7 +385,7 @@ public sealed class PluginLoader
{
if (Directory.Exists(directoryPath))
{
Directory.Delete(directoryPath, recursive: true);
FileOperationRetryHelper.DeleteDirectoryWithRetry(directoryPath, recursive: true, "PluginLoader");
}
Directory.CreateDirectory(directoryPath);

File diff suppressed because it is too large Load Diff

View 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();
}
}

View File

@@ -14,6 +14,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
{
private readonly PluginRuntimeService _runtime;
private readonly HttpClient _httpClient;
private readonly ResumableDownloadService _downloadService;
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
private readonly string _downloadsDirectory;
@@ -26,6 +27,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
Timeout = TimeSpan.FromMinutes(2)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_downloadService = new ResumableDownloadService(_httpClient);
_releaseResolverService = new AirAppMarketReleaseResolverService(_httpClient);
}
@@ -42,25 +44,37 @@ internal sealed class AirAppMarketInstallService : IDisposable
try
{
AppLogger.Info(
"PluginMarket",
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken);
AppLogger.Info(
"PluginMarket",
$"Resolved download url for '{plugin.Id}' to '{resolvedDownloadUrl}'.");
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
{
await using var sourceStream = File.OpenRead(localPackagePath);
await using var destinationStream = File.Create(downloadPath);
await sourceStream.CopyToAsync(destinationStream, cancellationToken);
var localCopyResult = await _downloadService.DownloadAsync(
localPackagePath,
downloadPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken);
if (!localCopyResult.Success)
{
return new AirAppMarketInstallResult(false, null, localCopyResult.ErrorMessage);
}
}
else
{
using var response = await _httpClient.GetAsync(
var downloadResult = await _downloadService.DownloadAsync(
resolvedDownloadUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destinationStream = File.Create(downloadPath);
await responseStream.CopyToAsync(destinationStream, cancellationToken);
downloadPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken);
if (!downloadResult.Success)
{
return new AirAppMarketInstallResult(false, null, downloadResult.ErrorMessage);
}
}
await using var hashStream = File.OpenRead(downloadPath);
@@ -76,14 +90,24 @@ internal sealed class AirAppMarketInstallService : IDisposable
}
var manifest = _runtime.InstallPluginPackage(downloadPath);
AppLogger.Info(
"PluginMarket",
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'.");
return new AirAppMarketInstallResult(true, manifest, null);
}
catch (OperationCanceledException)
{
AppLogger.Warn(
"PluginMarket",
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
throw;
}
catch (Exception ex)
{
AppLogger.Error(
"PluginMarket",
$"Install failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.",
ex);
return new AirAppMarketInstallResult(false, null, ex.Message);
}
}

View File

@@ -0,0 +1,25 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="960"
d:DesignHeight="1000"
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketSettingsPage">
<StackPanel x:Name="PluginMarketPanel"
Spacing="16">
<TextBlock x:Name="PluginMarketPanelTitleTextBlock"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="Plugin Market" />
<TextBlock x:Name="PluginMarketPanelSubtitleTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
Text="Browse plugins from the official LanAirApp source and stage installs." />
<ContentControl x:Name="PluginMarketContentHost" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,71 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.SettingsPages;
public partial class PluginMarketSettingsPage : UserControl
{
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private PluginMarketEmbeddedView? _pluginMarketView;
public PluginMarketSettingsPage()
{
InitializeComponent();
AttachedToVisualTree += (_, _) => RefreshFromRuntime();
}
public void RefreshFromRuntime()
{
PluginMarketPanelTitleTextBlock.Text = L("settings.plugin_market.title", "Plugin Market");
PluginMarketPanelSubtitleTextBlock.Text = L(
"settings.plugin_market.subtitle",
"Browse plugins from the official LanAirApp source and stage installs.");
var runtime = (Application.Current as App)?.PluginRuntimeService;
if (runtime is null)
{
PluginMarketContentHost.Content = CreateUnavailableState();
return;
}
if (_pluginMarketView is null)
{
_pluginMarketView = new PluginMarketEmbeddedView(runtime);
}
_pluginMarketView.RefreshLocalization();
_pluginMarketView.RefreshInstalledSnapshot();
if (!ReferenceEquals(PluginMarketContentHost.Content, _pluginMarketView))
{
PluginMarketContentHost.Content = _pluginMarketView;
}
}
private Control CreateUnavailableState()
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#14000000")),
CornerRadius = new CornerRadius(16),
Padding = new Thickness(16),
Child = new TextBlock
{
Text = L(
"settings.plugin_market.unavailable",
"Plugin runtime is not available, so the official market cannot be opened right now."),
TextWrapping = TextWrapping.Wrap,
Foreground = PluginMarketPanelSubtitleTextBlock.Foreground
}
};
}
private string L(string key, string fallback)
{
var snapshot = _appSettingsService.Load();
return _localizationService.GetString(snapshot.LanguageCode, key, fallback);
}
}

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
@@ -16,6 +17,8 @@ namespace LanMountainDesktop.Services;
public sealed class PluginRuntimeService : IDisposable
{
private const string PendingDeletionFileName = ".pending-plugin-deletions.json";
private readonly PluginLoader _loader;
private readonly AppSettingsService _appSettingsService = new();
private readonly IServiceProvider _hostServices;
@@ -25,6 +28,7 @@ public sealed class PluginRuntimeService : IDisposable
private readonly List<PluginCatalogEntry> _catalog = [];
private readonly List<PluginSettingsPageContribution> _settingsPages = [];
private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
private readonly object _packageMutationGate = new();
public PluginRuntimeService()
{
@@ -49,6 +53,7 @@ public sealed class PluginRuntimeService : IDisposable
public void LoadInstalledPlugins()
{
Directory.CreateDirectory(PluginsDirectory);
ApplyPendingPluginDeletions();
UnloadInstalledPlugins();
var disabledPluginIds = GetDisabledPluginIds();
@@ -170,6 +175,7 @@ public sealed class PluginRuntimeService : IDisposable
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToList();
_appSettingsService.Save(snapshot);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
for (var i = 0; i < _catalog.Count; i++)
{
@@ -184,7 +190,50 @@ public sealed class PluginRuntimeService : IDisposable
public PluginManifest InstallPluginPackage(string packagePath)
{
return InstallPluginPackageCore(packagePath).Manifest;
lock (_packageMutationGate)
{
return InstallPluginPackageCore(packagePath).Manifest;
}
}
public bool DeleteInstalledPlugin(string pluginId)
{
lock (_packageMutationGate)
{
return DeleteInstalledPluginCore(pluginId);
}
}
private bool DeleteInstalledPluginCore(string pluginId)
{
if (string.IsNullOrWhiteSpace(pluginId))
{
return false;
}
var entry = _catalog.FirstOrDefault(candidate =>
string.Equals(candidate.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
if (entry is null)
{
return false;
}
var targetPath = ResolvePluginRemovalTargetPath(entry);
if (string.IsNullOrWhiteSpace(targetPath))
{
return false;
}
var fullTargetPath = Path.GetFullPath(targetPath);
if (!TryDeletePluginTarget(fullTargetPath))
{
RegisterPendingPluginDeletion(fullTargetPath);
}
RemovePluginFromSnapshot(pluginId);
RemovePluginFromCatalog(pluginId);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
return true;
}
internal IReadOnlyList<InstalledPluginInfo> GetInstalledPluginsSnapshot()
@@ -219,16 +268,22 @@ public sealed class PluginRuntimeService : IDisposable
Directory.CreateDirectory(PluginsDirectory);
var manifest = ReadManifestFromPackage(fullPackagePath);
AppLogger.Info(
"PluginRuntime",
$"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'.");
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
{
File.Copy(fullPackagePath, destinationPath, overwrite: true);
FileOperationRetryHelper.CopyWithRetry(fullPackagePath, destinationPath, overwrite: true, "PluginRuntime");
}
UpdateCatalogAfterPackageInstall(manifest, destinationPath);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
AppLogger.Info(
"PluginRuntime",
$"Package staged. PluginId='{manifest.Id}'; Destination='{destinationPath}'; ReplacedExisting={replacedExisting}.");
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
}
@@ -349,7 +404,7 @@ public sealed class PluginRuntimeService : IDisposable
continue;
}
File.Delete(existingPackagePath);
FileOperationRetryHelper.DeleteFileWithRetry(existingPackagePath, "PluginRuntime");
replacedExisting = true;
}
catch
@@ -445,6 +500,150 @@ public sealed class PluginRuntimeService : IDisposable
}
}
private void ApplyPendingPluginDeletions()
{
var pendingPaths = ReadPendingPluginDeletions();
if (pendingPaths.Count == 0)
{
return;
}
var remainingPaths = new List<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(
string SourcePath,
PluginManifest Manifest,

View File

@@ -8,6 +8,8 @@ using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using FluentIcons.Avalonia.Fluent;
using FluentIcons.Common;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
@@ -18,10 +20,10 @@ public partial class PluginSettingsPage : UserControl
{
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
private static readonly IBrush DestructiveBrush = new SolidColorBrush(Color.Parse("#FFF87171"));
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private PluginMarketEmbeddedView? _pluginMarketView;
private string? _packageImportStatusMessage;
private bool _packageImportStatusIsError;
@@ -34,47 +36,21 @@ public partial class PluginSettingsPage : UserControl
public void RefreshFromRuntime()
{
var runtime = (Application.Current as App)?.PluginRuntimeService;
PluginMarketSettingsExpander.Header = L("settings.plugins.market_header", "Official Market");
PluginMarketSettingsExpander.Description = L(
"settings.plugins.market_desc",
"Browse plugins from the official LanAirApp source and stage installs.");
PluginMarketDescriptionTextBlock.Text = L(
"settings.plugins.market_hint",
"Use the official market source hosted in LanAirApp to discover and stage plugin installs.");
UpdateInstallerUi(runtime);
if (runtime is null)
{
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available.");
PluginRuntimeSummaryPanel.Children.Clear();
PluginCatalogItemsHost.Children.Clear();
InstalledPluginsSettingsExpander.Items.Clear();
PluginRestartHintTextBlock.IsVisible = false;
PluginMarketContentHost.Content = CreateSummaryLine(
L("settings.plugins.market_unavailable", "Plugin runtime is not available, so the official market cannot be opened right now."));
PluginCatalogEmptyTextBlock.IsVisible = false;
return;
}
EnsurePluginMarketView(runtime);
_pluginMarketView?.RefreshLocalization();
_pluginMarketView?.RefreshInstalledSnapshot();
BuildRuntimeSummary(runtime);
BuildPluginCatalog(runtime);
}
private void EnsurePluginMarketView(PluginRuntimeService runtime)
{
if (_pluginMarketView is null)
{
_pluginMarketView = new PluginMarketEmbeddedView(runtime);
PluginMarketContentHost.Content = _pluginMarketView;
return;
}
if (!ReferenceEquals(PluginMarketContentHost.Content, _pluginMarketView))
{
PluginMarketContentHost.Content = _pluginMarketView;
}
}
private void UpdateInstallerUi(PluginRuntimeService? runtime)
{
InstallPluginPackageButton.Content = L("settings.plugins.install_button", "Open .laapp package");
@@ -102,7 +78,7 @@ public partial class PluginSettingsPage : UserControl
"Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
runtime.Catalog.Count,
enabledCount,
runtime.LoadedPlugins.Count,
runtime.Catalog.Count(entry => entry.IsLoaded),
runtime.SettingsPages.Count,
runtime.DesktopComponents.Count,
failures.Length);
@@ -127,7 +103,7 @@ public partial class PluginSettingsPage : UserControl
private void BuildPluginCatalog(PluginRuntimeService runtime)
{
PluginCatalogItemsHost.Children.Clear();
InstalledPluginsSettingsExpander.Items.Clear();
var plugins = runtime.Catalog
.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase)
@@ -138,86 +114,20 @@ public partial class PluginSettingsPage : UserControl
foreach (var plugin in plugins)
{
PluginCatalogItemsHost.Children.Add(CreatePluginCatalogItem(runtime, plugin));
InstalledPluginsSettingsExpander.Items.Add(CreatePluginCatalogItem(runtime, plugin));
}
}
private Control CreatePluginCatalogItem(PluginRuntimeService runtime, PluginCatalogEntry entry)
private SettingsExpanderItem CreatePluginCatalogItem(PluginRuntimeService runtime, PluginCatalogEntry entry)
{
var title = new TextBlock
return new SettingsExpanderItem
{
Text = entry.Manifest.Name,
FontSize = 16,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
Content = entry.Manifest.Name,
Description = BuildPluginSubtitle(entry),
IconSource = CreatePluginCatalogIconSource(),
IsClickEnabled = false,
Footer = CreatePluginCatalogActions(runtime, entry)
};
var subtitle = new TextBlock
{
Text = BuildPluginSubtitle(entry),
Foreground = PluginSystemDescriptionTextBlock.Foreground,
TextWrapping = TextWrapping.Wrap
};
var enabledToggle = new ToggleSwitch
{
IsChecked = entry.IsEnabled,
OnContent = L("settings.plugins.toggle_on", "Enabled"),
OffContent = L("settings.plugins.toggle_off", "Disabled"),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center
};
enabledToggle.IsCheckedChanged += (_, _) => OnPluginEnableChanged(runtime, entry, enabledToggle.IsChecked == true);
var header = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
ColumnSpacing = 12,
Children =
{
new StackPanel
{
Spacing = 4,
Children = { title, subtitle }
},
enabledToggle
}
};
Grid.SetColumn(enabledToggle, 1);
var details = new TextBlock
{
Text = BuildPluginDetails(entry),
Foreground = PluginSystemDescriptionTextBlock.Foreground,
TextWrapping = TextWrapping.Wrap
};
return new Border
{
Background = new SolidColorBrush(Color.Parse("#14000000")),
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14),
Child = new StackPanel
{
Spacing = 10,
Children = { header, details }
}
};
}
private void OnPluginEnableChanged(PluginRuntimeService runtime, PluginCatalogEntry entry, bool isEnabled)
{
runtime.SetPluginEnabled(entry.Manifest.Id, isEnabled);
BuildRuntimeSummary(runtime);
BuildPluginCatalog(runtime);
PluginSystemStatusTextBlock.Text = F(
"settings.plugins.toggle_result_format",
"Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
entry.Manifest.Name,
isEnabled
? L("settings.plugins.toggle_state_enabled", "enabled")
: L("settings.plugins.toggle_state_disabled", "disabled"));
}
private async void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
@@ -275,6 +185,7 @@ public partial class PluginSettingsPage : UserControl
var manifest = runtime.InstallPluginPackage(temporaryPackagePath);
RefreshFromRuntime();
RefreshPluginNavigation(TopLevel.GetTopLevel(this));
SetPackageImportStatus(
F(
"settings.plugins.install_success_format",
@@ -307,6 +218,79 @@ public partial class PluginSettingsPage : UserControl
}
}
private void OnDeletePluginClick(PluginRuntimeService runtime, PluginCatalogEntry entry)
{
try
{
if (!runtime.DeleteInstalledPlugin(entry.Manifest.Id))
{
SetPackageImportStatus(
F(
"settings.plugins.delete_failed_format",
"Failed to delete plugin: {0}",
entry.Manifest.Name),
isError: true);
return;
}
RefreshFromRuntime();
RefreshPluginNavigation(TopLevel.GetTopLevel(this));
PluginSystemStatusTextBlock.Text = F(
"settings.plugins.delete_success_format",
"Plugin '{0}' was staged for deletion. Restart the app to finish removing it.",
entry.Manifest.Name);
SetPackageImportStatus(
F(
"settings.plugins.delete_success_format",
"Plugin '{0}' was staged for deletion. Restart the app to finish removing it.",
entry.Manifest.Name),
isError: false);
}
catch (Exception ex)
{
SetPackageImportStatus(
F(
"settings.plugins.delete_failed_detail_format",
"Failed to delete plugin '{0}': {1}",
entry.Manifest.Name,
ex.Message),
isError: true);
}
}
private void OnPluginEnabledChanged(PluginRuntimeService runtime, PluginCatalogEntry entry, bool isEnabled)
{
try
{
if (!runtime.SetPluginEnabled(entry.Manifest.Id, isEnabled))
{
return;
}
RefreshFromRuntime();
var toggleState = isEnabled
? L("settings.plugins.toggle_state_enabled", "enabled")
: L("settings.plugins.toggle_state_disabled", "disabled");
SetPackageImportStatus(
F(
"settings.plugins.toggle_result_format",
"Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
entry.Manifest.Name,
toggleState),
isError: false);
}
catch (Exception ex)
{
SetPackageImportStatus(
F(
"settings.plugins.toggle_failed_detail_format",
"Failed to update plugin '{0}': {1}",
entry.Manifest.Name,
ex.Message),
isError: true);
}
}
private void RefreshPluginNavigation(TopLevel? topLevel)
{
switch (topLevel)
@@ -329,32 +313,13 @@ public partial class PluginSettingsPage : UserControl
private string BuildPluginSubtitle(PluginCatalogEntry entry)
{
var source = entry.IsPackage
? L("settings.plugins.source_package", ".laapp package")
: L("settings.plugins.source_manifest", "Loose manifest");
var state = entry.IsEnabled
? entry.IsLoaded
? L("settings.plugins.state.loaded", "Loaded")
: L("settings.plugins.state.load_failed", "Load failed")
: L("settings.plugins.state.disabled", "Disabled");
var publisher = string.IsNullOrWhiteSpace(entry.Manifest.Author)
? L("settings.plugins.publisher_unknown", "Unknown publisher")
: entry.Manifest.Author;
return F(
"settings.plugins.subtitle_format",
"{0} | {1} | {2}",
state,
source,
entry.Manifest.Id);
}
private string BuildPluginDetails(PluginCatalogEntry entry)
{
var detail = F(
"settings.plugins.detail_format",
"Settings pages: {0} | Widgets: {1}",
entry.SettingsPageCount,
entry.WidgetCount);
return string.IsNullOrWhiteSpace(entry.ErrorMessage)
? detail
: detail + Environment.NewLine + entry.ErrorMessage;
"settings.plugins.publisher_format",
"Publisher: {0}",
publisher);
}
private TextBlock CreateSummaryLine(string text)
@@ -378,6 +343,75 @@ public partial class PluginSettingsPage : UserControl
return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args);
}
private FluentIcons.Avalonia.Fluent.SymbolIconSource CreatePluginCatalogIconSource()
{
return new FluentIcons.Avalonia.Fluent.SymbolIconSource
{
Symbol = FluentIcons.Common.Symbol.PuzzlePiece,
IconVariant = FluentIcons.Common.IconVariant.Regular
};
}
private Control CreatePluginCatalogActions(PluginRuntimeService runtime, PluginCatalogEntry entry)
{
return new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
CreateEnablePluginToggle(runtime, entry),
CreateDeletePluginButton(runtime, entry)
}
};
}
private ToggleSwitch CreateEnablePluginToggle(PluginRuntimeService runtime, PluginCatalogEntry entry)
{
var toggle = new ToggleSwitch
{
IsChecked = entry.IsEnabled,
VerticalAlignment = VerticalAlignment.Center
};
ToolTip.SetTip(
toggle,
entry.IsEnabled
? L("settings.plugins.toggle_off", "Disable")
: L("settings.plugins.toggle_on", "Enable"));
toggle.IsCheckedChanged += (_, _) => OnPluginEnabledChanged(runtime, entry, toggle.IsChecked == true);
return toggle;
}
private Button CreateDeletePluginButton(PluginRuntimeService runtime, PluginCatalogEntry entry)
{
var button = new Button
{
Width = 36,
Height = 36,
Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Content = new FluentIcons.Avalonia.Fluent.SymbolIcon
{
Symbol = FluentIcons.Common.Symbol.Delete,
IconVariant = FluentIcons.Common.IconVariant.Regular,
FontSize = 18,
Foreground = DestructiveBrush,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
}
};
ToolTip.SetTip(button, L("settings.plugins.delete_button", "Delete plugin"));
button.Click += (_, _) => OnDeletePluginClick(runtime, entry);
return button;
}
private static async Task<string?> CopyPackageToTemporaryFileAsync(IStorageFile file)
{
try

View File

@@ -44,31 +44,13 @@
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="InstalledPluginsSettingsExpander"
Header="Installed Plugins"
Description="Enable plugins here. Detailed settings appear as separate settings pages."
Description="Manage installed plugins here."
IsExpanded="True">
<ui:SettingsExpander.IconSource>
<ui:FontIconSource Glyph="&#xe8fd;" FontFamily="{StaticResource SymbolThemeFontFamily}" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<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"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
@@ -77,30 +59,37 @@
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No plugins found."
IsVisible="False" />
<StackPanel x:Name="PluginCatalogItemsHost" Spacing="12" />
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="PluginMarketSettingsExpander"
Header="Official Market"
Description="Browse plugins from the official LanAirApp source and stage installs."
IsExpanded="True">
<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="&#xe719;" FontFamily="{StaticResource SymbolThemeFontFamily}" />
<ui:FontIconSource Glyph="&#xe8b7;" FontFamily="{StaticResource SymbolThemeFontFamily}" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<StackPanel Spacing="10">
<TextBlock x:Name="PluginMarketDescriptionTextBlock"
<Button x:Name="InstallPluginPackageButton"
HorizontalAlignment="Left"
Click="OnInstallPluginPackageClick"
Content="Open .laapp package" />
<TextBlock x:Name="PluginPackageImportHintTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
Text="Use the official market source hosted in LanAirApp to discover and stage plugin installs." />
<ContentControl x:Name="PluginMarketContentHost" />
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>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,9 @@
namespace LanMountainDesktop.Views;
public partial class SettingsWindow
{
private void ApplyPluginMarketSettingsLocalization()
{
PluginMarketSettingsPanel.RefreshFromRuntime();
}
}

View File

@@ -9,6 +9,7 @@ public partial class SettingsWindow
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
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 ImportPluginPackageSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("ImportPluginPackageSettingsExpander")!;
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
}

View File

@@ -10,8 +10,10 @@ public partial class SettingsWindow
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Review installed plugins and remove them here.");
ImportPluginPackageSettingsExpander.Header = L("settings.plugins.import_header", "Install From Package");
ImportPluginPackageSettingsExpander.Description = L("settings.plugins.import_desc", "Open a .laapp package and stage it into the local plugin directory.");
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin installation and deletion changes take effect after restarting the app.");
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
PluginSettingsPanel.RefreshFromRuntime();
}

View File

@@ -141,6 +141,37 @@ function Create-PackageArchive {
return $archivePath
}
function Clear-DirectoryContents {
param([Parameter(Mandatory = $true)][string]$TargetDirectory)
[System.IO.Directory]::CreateDirectory($TargetDirectory) | Out-Null
Get-ChildItem -LiteralPath $TargetDirectory -Force -ErrorAction SilentlyContinue | ForEach-Object {
Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop
}
}
function Remove-LegacyOutputArtifacts {
param([Parameter(Mandatory = $true)][string]$TargetDirectory)
$legacyArtifacts = @(
"LanMontainDesktop.exe",
"LanMontainDesktop.dll",
"LanMontainDesktop.deps.json",
"LanMontainDesktop.runtimeconfig.json",
"LanMontainDesktop.pdb"
)
foreach ($artifactName in $legacyArtifacts) {
$artifactPath = Join-Path $TargetDirectory $artifactName
if (-not (Test-Path -LiteralPath $artifactPath)) {
continue
}
Remove-Item -LiteralPath $artifactPath -Force -ErrorAction Stop
Write-Host "Removed legacy artifact: $artifactPath"
}
}
function Add-LinuxDesktopAssets {
param(
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
@@ -187,7 +218,7 @@ if (-not $PublishDir) {
if (-not [System.IO.Path]::IsPathRooted($PublishDir)) {
$PublishDir = Join-Path $repoRoot $PublishDir
}
[System.IO.Directory]::CreateDirectory($PublishDir) | Out-Null
Clear-DirectoryContents -TargetDirectory $PublishDir
Write-Host "Publishing project..."
$publishArgs = @(
@@ -210,6 +241,7 @@ if ($LASTEXITCODE -ne 0) {
}
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir
if ($RuntimeIdentifier -like "linux-*") {
Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot

View File

@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Plugin Marketplace">
<defs>
<linearGradient id="marketBg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#0EA5E9"/>
<stop offset="100%" stop-color="#22C55E"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#marketBg)"/>
<path d="M43 36h42c3.866 0 7 3.134 7 7v42c0 3.866-3.134 7-7 7H43c-3.866 0-7-3.134-7-7V43c0-3.866 3.134-7 7-7Z" fill="#FFFFFF" fill-opacity="0.16"/>
<path d="M52 52h24a12 12 0 0 1 0 24H52a8 8 0 0 1 0-16h4" fill="none" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
<circle cx="84" cy="84" r="14" fill="#FFFFFF"/>
<path d="M84 76v16M76 84h16" stroke="#0EA5E9" stroke-linecap="round" stroke-width="8"/>
</svg>

Before

Width:  |  Height:  |  Size: 835 B

View File

@@ -2,31 +2,8 @@
"schemaVersion": "1.0.0",
"sourceId": "official.lanmountaindesktop",
"sourceName": "LanMountainDesktop Official Market",
"generatedAt": "2026-03-10T01:30:00Z",
"generatedAt": "2026-03-10T11:10:00Z",
"plugins": [
{
"id": "LanMountainDesktop.PluginMarketplace",
"name": "LanMountain Plugin Marketplace",
"description": "Official plugin marketplace for browsing and installing LanMountainDesktop plugins.",
"author": "LanMountainDesktop",
"version": "1.0.0",
"apiVersion": "1.0.0",
"minHostVersion": "1.0.0",
"downloadUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/LanAirApp/releases/LanMountainDesktop.PluginMarketplace.1.0.0.laapp",
"sha256": "51e73bf834c4c3f8a32bc711e9db62b954e24a3577e580d6faa4c3986ce70e0b",
"packageSizeBytes": 1704353,
"iconUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/assets/plugin-marketplace.svg",
"homepageUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace",
"repositoryUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace",
"tags": [
"official",
"market",
"settings"
],
"publishedAt": "2026-03-10T01:30:00Z",
"updatedAt": "2026-03-10T01:30:00Z",
"releaseNotes": "Bootstrap plugin for the official LanMountainDesktop marketplace. Install it once from a local .laapp package, then use it to discover and stage future plugin installs and updates."
},
{
"id": "LanMountainDesktop.SamplePlugin",
"name": "LanMountain Sample Plugin",