mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e97db00999 | ||
|
|
8bb6b01236 | ||
|
|
103b215e35 | ||
|
|
cab35f4c22 | ||
|
|
c9f92a4755 | ||
|
|
854deae801 | ||
|
|
d72cd42483 | ||
|
|
3aee31c6c0 | ||
|
|
435b96c50c | ||
|
|
49b18d6af1 | ||
|
|
d6ec159af4 | ||
|
|
0d14675cc0 | ||
|
|
1f509959a9 | ||
|
|
382d1baaf1 | ||
|
|
72a0be16b3 | ||
|
|
de40471af6 | ||
|
|
5d35e0d21c | ||
|
|
e917a1e4af | ||
|
|
b8643a2959 | ||
|
|
3b71486423 |
32
.github/workflows/release.yml
vendored
32
.github/workflows/release.yml
vendored
@@ -275,6 +275,8 @@ jobs:
|
||||
package_name="LanMountainDesktop"
|
||||
package_version="${version}"
|
||||
arch="amd64"
|
||||
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
|
||||
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
|
||||
|
||||
# Verify source directory exists
|
||||
if [ ! -d "$source" ]; then
|
||||
@@ -288,6 +290,7 @@ jobs:
|
||||
mkdir -p "build-deb/usr/local/bin"
|
||||
mkdir -p "build-deb/usr/share/applications"
|
||||
mkdir -p "build-deb/usr/share/pixmaps"
|
||||
mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps"
|
||||
|
||||
# Copy application files
|
||||
cp -r "$source"/* "build-deb/usr/local/bin/"
|
||||
@@ -300,6 +303,31 @@ jobs:
|
||||
echo "Error: DEB package is empty after copy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
|
||||
echo "Error: Linux desktop resources are missing"
|
||||
ls -la "LanMountainDesktop/packaging/linux" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed \
|
||||
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \
|
||||
-e "s|@@ICON@@|lanmountaindesktop|g" \
|
||||
"$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop"
|
||||
|
||||
cp "$icon_source" "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
|
||||
cp "$icon_source" "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
||||
|
||||
{
|
||||
printf '%s\n' '#!/bin/sh'
|
||||
printf '%s\n' 'set -e'
|
||||
printf '%s\n' 'if command -v update-desktop-database >/dev/null 2>&1; then'
|
||||
printf '%s\n' ' update-desktop-database /usr/share/applications >/dev/null 2>&1 || true'
|
||||
printf '%s\n' 'fi'
|
||||
printf '%s\n' 'if command -v gtk-update-icon-cache >/dev/null 2>&1; then'
|
||||
printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true'
|
||||
printf '%s\n' 'fi'
|
||||
} > "build-deb/DEBIAN/postinst"
|
||||
|
||||
# Create control file (NOTE: No leading spaces in control file)
|
||||
{
|
||||
@@ -313,6 +341,10 @@ jobs:
|
||||
|
||||
# Set proper permissions
|
||||
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/*
|
||||
chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop"
|
||||
chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
|
||||
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
||||
chmod 755 "build-deb/DEBIAN/postinst"
|
||||
|
||||
# Create DEB file
|
||||
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -482,3 +482,12 @@ $RECYCLE.BIN/
|
||||
*.swp
|
||||
nul
|
||||
/publish-test
|
||||
/_build_verify
|
||||
/_build_verify_tray
|
||||
/_build_verify_plugin
|
||||
/_build_verify_plugin_tabs
|
||||
/_build_verify_sample_plugin
|
||||
/_build_verify_sample_plugin_capabilities
|
||||
/_build_verify_plugin_page_host
|
||||
/_build_verify_plugin_services
|
||||
/_build_obj
|
||||
|
||||
6
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
6
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPlugin
|
||||
{
|
||||
void Initialize(IPluginContext context);
|
||||
}
|
||||
27
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
27
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginContext
|
||||
{
|
||||
PluginManifest Manifest { get; }
|
||||
|
||||
string PluginDirectory { get; }
|
||||
|
||||
string DataDirectory { get; }
|
||||
|
||||
IServiceProvider Services { get; }
|
||||
|
||||
IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
T? GetService<T>();
|
||||
|
||||
bool TryGetProperty<T>(string key, out T? value);
|
||||
|
||||
void RegisterService<TService>(TService service)
|
||||
where TService : class;
|
||||
|
||||
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
|
||||
|
||||
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginMessageBus
|
||||
{
|
||||
IDisposable Subscribe<TMessage>(Action<TMessage> handler);
|
||||
|
||||
void Publish<TMessage>(TMessage message);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
83
LanMountainDesktop.PluginSdk/LoadedPlugin.cs
Normal file
83
LanMountainDesktop.PluginSdk/LoadedPlugin.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
internal LoadedPlugin(
|
||||
PluginManifest manifest,
|
||||
string sourcePath,
|
||||
string assemblyPath,
|
||||
Assembly assembly,
|
||||
IPlugin plugin,
|
||||
IPluginContext context,
|
||||
IReadOnlyList<PluginSettingsPageRegistration> settingsPages,
|
||||
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
|
||||
PluginLoadContext loadContext)
|
||||
{
|
||||
Manifest = manifest;
|
||||
SourcePath = sourcePath;
|
||||
AssemblyPath = assemblyPath;
|
||||
Assembly = assembly;
|
||||
Plugin = plugin;
|
||||
Context = context;
|
||||
SettingsPages = settingsPages;
|
||||
DesktopComponents = desktopComponents;
|
||||
LoadContext = loadContext;
|
||||
}
|
||||
|
||||
public PluginManifest Manifest { get; }
|
||||
|
||||
public string SourcePath { get; }
|
||||
|
||||
public string AssemblyPath { get; }
|
||||
|
||||
public Assembly Assembly { get; }
|
||||
|
||||
public IPlugin Plugin { get; }
|
||||
|
||||
public IPluginContext Context { get; }
|
||||
|
||||
public IReadOnlyList<PluginSettingsPageRegistration> SettingsPages { get; }
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; }
|
||||
|
||||
public PluginLoadContext LoadContext { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Plugin is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync();
|
||||
}
|
||||
else if (Plugin is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
|
||||
if (Context is IAsyncDisposable asyncContext)
|
||||
{
|
||||
await asyncContext.DisposeAsync();
|
||||
}
|
||||
else if (Context is IDisposable disposableContext)
|
||||
{
|
||||
disposableContext.Dispose();
|
||||
}
|
||||
|
||||
LoadContext.Unload();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
8
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public abstract class PluginBase : IPlugin
|
||||
{
|
||||
public virtual void Initialize(IPluginContext context)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentContext
|
||||
{
|
||||
public PluginDesktopComponentContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider services,
|
||||
IReadOnlyDictionary<string, object?> properties,
|
||||
string componentId,
|
||||
string? placementId,
|
||||
double cellSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
|
||||
Manifest = manifest;
|
||||
PluginDirectory = pluginDirectory;
|
||||
DataDirectory = dataDirectory;
|
||||
Services = services;
|
||||
Properties = properties;
|
||||
ComponentId = componentId.Trim();
|
||||
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||
CellSize = Math.Max(1, cellSize);
|
||||
}
|
||||
|
||||
public PluginManifest Manifest { get; }
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public string DataDirectory { get; }
|
||||
|
||||
public IServiceProvider Services { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string? PlacementId { get; }
|
||||
|
||||
public double CellSize { get; }
|
||||
|
||||
public T? GetService<T>()
|
||||
{
|
||||
return (T?)Services.GetService(typeof(T));
|
||||
}
|
||||
|
||||
public bool TryGetProperty<T>(string key, out T? value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
|
||||
{
|
||||
value = typedValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentRegistration
|
||||
{
|
||||
public PluginDesktopComponentRegistration(
|
||||
string componentId,
|
||||
string displayName,
|
||||
Func<PluginDesktopComponentContext, Control> controlFactory,
|
||||
string iconKey = "PuzzlePiece",
|
||||
string category = "Plugins",
|
||||
int minWidthCells = 2,
|
||||
int minHeightCells = 2,
|
||||
bool allowDesktopPlacement = true,
|
||||
bool allowStatusBarPlacement = false,
|
||||
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
|
||||
string? displayNameLocalizationKey = null,
|
||||
Func<double, double>? cornerRadiusResolver = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(category);
|
||||
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||
|
||||
ComponentId = componentId.Trim();
|
||||
DisplayName = displayName.Trim();
|
||||
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
|
||||
? null
|
||||
: displayNameLocalizationKey.Trim();
|
||||
ControlFactory = controlFactory;
|
||||
IconKey = iconKey.Trim();
|
||||
Category = category.Trim();
|
||||
MinWidthCells = Math.Max(1, minWidthCells);
|
||||
MinHeightCells = Math.Max(1, minHeightCells);
|
||||
AllowDesktopPlacement = allowDesktopPlacement;
|
||||
AllowStatusBarPlacement = allowStatusBarPlacement;
|
||||
ResizeMode = resizeMode;
|
||||
CornerRadiusResolver = cornerRadiusResolver;
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string? DisplayNameLocalizationKey { get; }
|
||||
|
||||
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; }
|
||||
|
||||
public string IconKey { get; }
|
||||
|
||||
public string Category { get; }
|
||||
|
||||
public int MinWidthCells { get; }
|
||||
|
||||
public int MinHeightCells { get; }
|
||||
|
||||
public bool AllowDesktopPlacement { get; }
|
||||
|
||||
public bool AllowStatusBarPlacement { get; }
|
||||
|
||||
public PluginDesktopComponentResizeMode ResizeMode { get; }
|
||||
|
||||
public Func<double, double>? CornerRadiusResolver { get; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum PluginDesktopComponentResizeMode
|
||||
{
|
||||
Proportional = 0,
|
||||
Free = 1
|
||||
}
|
||||
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class PluginEntranceAttribute : Attribute
|
||||
{
|
||||
}
|
||||
66
LanMountainDesktop.PluginSdk/PluginLoadContext.cs
Normal file
66
LanMountainDesktop.PluginSdk/PluginLoadContext.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginLoadContext : AssemblyLoadContext
|
||||
{
|
||||
private readonly AssemblyDependencyResolver _resolver;
|
||||
private readonly HashSet<string> _sharedAssemblyNames;
|
||||
|
||||
public PluginLoadContext(string mainAssemblyPath, IEnumerable<string>? sharedAssemblyNames = null)
|
||||
: base($"{Path.GetFileNameWithoutExtension(mainAssemblyPath)}_{Guid.NewGuid():N}", isCollectible: true)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(mainAssemblyPath);
|
||||
|
||||
MainAssemblyPath = Path.GetFullPath(mainAssemblyPath);
|
||||
_resolver = new AssemblyDependencyResolver(MainAssemblyPath);
|
||||
_sharedAssemblyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
typeof(IPlugin).Assembly.GetName().Name!
|
||||
};
|
||||
|
||||
if (sharedAssemblyNames is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var assemblyName in sharedAssemblyNames)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(assemblyName))
|
||||
{
|
||||
_sharedAssemblyNames.Add(assemblyName.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string MainAssemblyPath { get; }
|
||||
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
var simpleName = assemblyName.Name;
|
||||
if (string.IsNullOrWhiteSpace(simpleName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_sharedAssemblyNames.Contains(simpleName))
|
||||
{
|
||||
return Default.Assemblies.FirstOrDefault(
|
||||
assembly => string.Equals(
|
||||
assembly.GetName().Name,
|
||||
simpleName,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
?? null;
|
||||
}
|
||||
|
||||
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||
return assemblyPath is null ? null : LoadFromAssemblyPath(assemblyPath);
|
||||
}
|
||||
|
||||
protected override nint LoadUnmanagedDll(string unmanagedDllName)
|
||||
{
|
||||
var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||
return libraryPath is null ? nint.Zero : LoadUnmanagedDllFromPath(libraryPath);
|
||||
}
|
||||
}
|
||||
20
LanMountainDesktop.PluginSdk/PluginLoadResult.cs
Normal file
20
LanMountainDesktop.PluginSdk/PluginLoadResult.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginLoadResult(
|
||||
string SourcePath,
|
||||
PluginManifest? Manifest,
|
||||
LoadedPlugin? LoadedPlugin,
|
||||
Exception? Error)
|
||||
{
|
||||
public bool IsSuccess => LoadedPlugin is not null && Error is null;
|
||||
|
||||
public static PluginLoadResult Success(string sourcePath, PluginManifest manifest, LoadedPlugin loadedPlugin)
|
||||
{
|
||||
return new PluginLoadResult(sourcePath, manifest, loadedPlugin, null);
|
||||
}
|
||||
|
||||
public static PluginLoadResult Failure(string sourcePath, PluginManifest? manifest, Exception error)
|
||||
{
|
||||
return new PluginLoadResult(sourcePath, manifest, null, error);
|
||||
}
|
||||
}
|
||||
924
LanMountainDesktop.PluginSdk/PluginLoader.cs
Normal file
924
LanMountainDesktop.PluginSdk/PluginLoader.cs
Normal file
@@ -0,0 +1,924 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginLoader
|
||||
{
|
||||
private readonly PluginLoaderOptions _options;
|
||||
|
||||
public PluginLoader(PluginLoaderOptions? options = null)
|
||||
{
|
||||
_options = options ?? new PluginLoaderOptions();
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginLoadResult> LoadAll(
|
||||
string pluginsRootDirectory,
|
||||
IServiceProvider? services = null,
|
||||
IReadOnlyDictionary<string, object?>? properties = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsRootDirectory);
|
||||
|
||||
if (!Directory.Exists(pluginsRootDirectory))
|
||||
{
|
||||
return Array.Empty<PluginLoadResult>();
|
||||
}
|
||||
|
||||
var results = new List<PluginLoadResult>();
|
||||
var candidates = DiscoverCandidates(pluginsRootDirectory, results);
|
||||
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!selectedPluginIds.Add(candidate.Manifest.Id))
|
||||
{
|
||||
results.Add(PluginLoadResult.Failure(
|
||||
candidate.SourcePath,
|
||||
candidate.Manifest,
|
||||
new InvalidOperationException(
|
||||
$"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected.")));
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(candidate.SourceKind switch
|
||||
{
|
||||
PluginSourceKind.Package => LoadFromPackage(
|
||||
candidate.SourcePath,
|
||||
pluginsRootDirectory,
|
||||
candidate.Manifest,
|
||||
services,
|
||||
properties),
|
||||
_ => LoadFromManifest(
|
||||
candidate.SourcePath,
|
||||
candidate.Manifest,
|
||||
services,
|
||||
properties)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public PluginLoadResult LoadFromManifest(
|
||||
string manifestPath,
|
||||
IServiceProvider? services = null,
|
||||
IReadOnlyDictionary<string, object?>? properties = null)
|
||||
{
|
||||
PluginManifest? manifest = null;
|
||||
|
||||
try
|
||||
{
|
||||
manifest = PluginManifest.Load(manifestPath);
|
||||
return LoadFromManifest(manifestPath, manifest, services, properties);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return PluginLoadResult.Failure(Path.GetFullPath(manifestPath), manifest, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public PluginLoadResult LoadFromPackage(
|
||||
string packagePath,
|
||||
string pluginsRootDirectory,
|
||||
IServiceProvider? services = null,
|
||||
IReadOnlyDictionary<string, object?>? properties = null)
|
||||
{
|
||||
PluginManifest? manifest = null;
|
||||
|
||||
try
|
||||
{
|
||||
manifest = ReadManifestFromPackage(packagePath);
|
||||
return LoadFromPackage(packagePath, pluginsRootDirectory, manifest, services, properties);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return PluginLoadResult.Failure(Path.GetFullPath(packagePath), manifest, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public PluginLoadResult LoadFromAssembly(
|
||||
string assemblyPath,
|
||||
PluginManifest manifest,
|
||||
IServiceProvider? services = null,
|
||||
IReadOnlyDictionary<string, object?>? properties = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(assemblyPath);
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var fullAssemblyPath = Path.GetFullPath(assemblyPath);
|
||||
var pluginDirectory = Path.GetDirectoryName(fullAssemblyPath)
|
||||
?? throw new InvalidOperationException($"Failed to determine the plugin directory of '{fullAssemblyPath}'.");
|
||||
var dataDirectory = Path.Combine(pluginDirectory, _options.DataDirectoryName);
|
||||
return LoadCore(fullAssemblyPath, fullAssemblyPath, pluginDirectory, dataDirectory, manifest, services, properties);
|
||||
}
|
||||
|
||||
private PluginLoadResult LoadCore(
|
||||
string sourcePath,
|
||||
string assemblyPath,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
PluginManifest manifest,
|
||||
IServiceProvider? services,
|
||||
IReadOnlyDictionary<string, object?>? properties)
|
||||
{
|
||||
PluginLoadContext? loadContext = null;
|
||||
IPlugin? plugin = null;
|
||||
PluginContext? context = null;
|
||||
|
||||
try
|
||||
{
|
||||
loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
||||
var pluginType = ResolvePluginType(assembly);
|
||||
plugin = CreatePluginInstance(pluginType);
|
||||
context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties);
|
||||
|
||||
plugin.Initialize(context);
|
||||
var settingsPages = context.GetSettingsPagesSnapshot();
|
||||
var desktopComponents = context.GetDesktopComponentsSnapshot();
|
||||
|
||||
var loadedPlugin = new LoadedPlugin(
|
||||
manifest,
|
||||
sourcePath,
|
||||
assemblyPath,
|
||||
assembly,
|
||||
plugin,
|
||||
context,
|
||||
settingsPages,
|
||||
desktopComponents,
|
||||
loadContext);
|
||||
|
||||
return PluginLoadResult.Success(sourcePath, manifest, loadedPlugin);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DisposeInstance(plugin);
|
||||
DisposeInstance(context);
|
||||
loadContext?.Unload();
|
||||
return PluginLoadResult.Failure(sourcePath, manifest, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private PluginLoadResult LoadFromManifest(
|
||||
string manifestPath,
|
||||
PluginManifest manifest,
|
||||
IServiceProvider? services,
|
||||
IReadOnlyDictionary<string, object?>? properties)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullManifestPath = Path.GetFullPath(manifestPath);
|
||||
var assemblyPath = manifest.ResolveEntranceAssemblyPath(fullManifestPath);
|
||||
if (!File.Exists(assemblyPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"Plugin '{manifest.Id}' entrance assembly '{assemblyPath}' was not found.",
|
||||
assemblyPath);
|
||||
}
|
||||
|
||||
var pluginDirectory = Path.GetDirectoryName(assemblyPath)
|
||||
?? throw new InvalidOperationException($"Failed to determine the plugin directory of '{assemblyPath}'.");
|
||||
var dataDirectory = Path.Combine(pluginDirectory, _options.DataDirectoryName);
|
||||
return LoadCore(fullManifestPath, assemblyPath, pluginDirectory, dataDirectory, manifest, services, properties);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return PluginLoadResult.Failure(Path.GetFullPath(manifestPath), manifest, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private PluginLoadResult LoadFromPackage(
|
||||
string packagePath,
|
||||
string pluginsRootDirectory,
|
||||
PluginManifest manifest,
|
||||
IServiceProvider? services,
|
||||
IReadOnlyDictionary<string, object?>? properties)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPackagePath = Path.GetFullPath(packagePath);
|
||||
var extractionDirectory = ExtractPackage(fullPackagePath, pluginsRootDirectory);
|
||||
var extractedManifestPath = Path.Combine(extractionDirectory, _options.ManifestFileName);
|
||||
|
||||
if (!File.Exists(extractedManifestPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"Plugin package '{fullPackagePath}' does not contain '{_options.ManifestFileName}'.",
|
||||
extractedManifestPath);
|
||||
}
|
||||
|
||||
var extractedManifest = PluginManifest.Load(extractedManifestPath);
|
||||
if (!string.Equals(extractedManifest.Id, manifest.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{fullPackagePath}' manifest id changed after extraction. Expected '{manifest.Id}', actual '{extractedManifest.Id}'.");
|
||||
}
|
||||
|
||||
var assemblyPath = extractedManifest.ResolveEntranceAssemblyPath(extractedManifestPath);
|
||||
if (!File.Exists(assemblyPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"Plugin '{extractedManifest.Id}' entrance assembly '{assemblyPath}' was not found after package extraction.",
|
||||
assemblyPath);
|
||||
}
|
||||
|
||||
var dataDirectory = GetPackagedDataDirectory(pluginsRootDirectory, extractedManifest);
|
||||
return LoadCore(fullPackagePath, assemblyPath, extractionDirectory, dataDirectory, extractedManifest, services, properties);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return PluginLoadResult.Failure(Path.GetFullPath(packagePath), manifest, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private PluginContext CreateContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider? services,
|
||||
IReadOnlyDictionary<string, object?>? properties)
|
||||
{
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
|
||||
return new PluginContext(
|
||||
manifest,
|
||||
pluginDirectory,
|
||||
dataDirectory,
|
||||
services ?? NullServiceProvider.Instance,
|
||||
CreateReadOnlyProperties(properties));
|
||||
}
|
||||
|
||||
private IReadOnlyList<PluginCandidate> DiscoverCandidates(
|
||||
string pluginsRootDirectory,
|
||||
List<PluginLoadResult> preparationFailures)
|
||||
{
|
||||
var candidates = new List<PluginCandidate>();
|
||||
|
||||
foreach (var packagePath in EnumerateCandidatePaths(
|
||||
pluginsRootDirectory,
|
||||
"*" + NormalizePackageExtension(_options.PackageFileExtension)))
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = ReadManifestFromPackage(packagePath);
|
||||
candidates.Add(new PluginCandidate(Path.GetFullPath(packagePath), manifest, PluginSourceKind.Package));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
preparationFailures.Add(PluginLoadResult.Failure(Path.GetFullPath(packagePath), null, ex));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var manifestPath in EnumerateCandidatePaths(pluginsRootDirectory, _options.ManifestFileName))
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = PluginManifest.Load(manifestPath);
|
||||
candidates.Add(new PluginCandidate(Path.GetFullPath(manifestPath), manifest, PluginSourceKind.Manifest));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
preparationFailures.Add(PluginLoadResult.Failure(Path.GetFullPath(manifestPath), null, ex));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
.OrderBy(candidate => candidate.SourceKind)
|
||||
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private IEnumerable<string> EnumerateCandidatePaths(string pluginsRootDirectory, string searchPattern)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(GetRuntimeRootDirectory(pluginsRootDirectory));
|
||||
|
||||
return Directory
|
||||
.EnumerateFiles(pluginsRootDirectory, searchPattern, SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
|
||||
var fullPackagePath = Path.GetFullPath(packagePath);
|
||||
if (!File.Exists(fullPackagePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath);
|
||||
}
|
||||
|
||||
using var archive = ZipFile.OpenRead(fullPackagePath);
|
||||
var manifestEntries = archive.Entries
|
||||
.Where(entry =>
|
||||
!string.IsNullOrWhiteSpace(entry.Name) &&
|
||||
string.Equals(entry.Name, _options.ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (manifestEntries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{fullPackagePath}' does not contain '{_options.ManifestFileName}'.");
|
||||
}
|
||||
|
||||
if (manifestEntries.Length > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{fullPackagePath}' contains multiple '{_options.ManifestFileName}' files.");
|
||||
}
|
||||
|
||||
using var stream = manifestEntries[0].Open();
|
||||
return PluginManifest.Load(stream, $"{fullPackagePath}!/{manifestEntries[0].FullName}");
|
||||
}
|
||||
|
||||
private string ExtractPackage(string packagePath, string pluginsRootDirectory)
|
||||
{
|
||||
var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath);
|
||||
RecreateDirectory(extractionDirectory);
|
||||
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
|
||||
return extractionDirectory;
|
||||
}
|
||||
|
||||
private string GetPackageExtractionDirectory(string pluginsRootDirectory, string packagePath)
|
||||
{
|
||||
var packageName = SanitizeDirectoryName(Path.GetFileNameWithoutExtension(packagePath));
|
||||
var packageHash = Convert.ToHexString(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(Path.GetFullPath(packagePath))))
|
||||
.Substring(0, 12);
|
||||
|
||||
return Path.Combine(
|
||||
GetRuntimeRootDirectory(pluginsRootDirectory),
|
||||
_options.ExtractedPackagesDirectoryName,
|
||||
$"{packageName}_{packageHash}");
|
||||
}
|
||||
|
||||
private string GetPackagedDataDirectory(string pluginsRootDirectory, PluginManifest manifest)
|
||||
{
|
||||
return Path.Combine(
|
||||
GetRuntimeRootDirectory(pluginsRootDirectory),
|
||||
_options.PackagedDataDirectoryName,
|
||||
SanitizeDirectoryName(manifest.Id));
|
||||
}
|
||||
|
||||
private string GetRuntimeRootDirectory(string pluginsRootDirectory)
|
||||
{
|
||||
return Path.Combine(Path.GetFullPath(pluginsRootDirectory), _options.RuntimeDirectoryName);
|
||||
}
|
||||
|
||||
private static void RecreateDirectory(string directoryPath)
|
||||
{
|
||||
if (Directory.Exists(directoryPath))
|
||||
{
|
||||
Directory.Delete(directoryPath, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
}
|
||||
|
||||
private static string NormalizePackageExtension(string extension)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(extension);
|
||||
return extension.StartsWith(".", StringComparison.Ordinal) ? extension : "." + extension;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
return fullPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
? fullPath
|
||||
: fullPath + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private static string SanitizeDirectoryName(string value)
|
||||
{
|
||||
var invalidCharacters = Path.GetInvalidFileNameChars();
|
||||
var builder = new StringBuilder(value.Length);
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
builder.Append(invalidCharacters.Contains(ch) ? '_' : ch);
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim();
|
||||
}
|
||||
|
||||
private static ReadOnlyDictionary<string, object?> CreateReadOnlyProperties(
|
||||
IReadOnlyDictionary<string, object?>? properties)
|
||||
{
|
||||
if (properties is null || properties.Count == 0)
|
||||
{
|
||||
return new ReadOnlyDictionary<string, object?>(
|
||||
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var map = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in properties)
|
||||
{
|
||||
map[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return new ReadOnlyDictionary<string, object?>(map);
|
||||
}
|
||||
|
||||
private static Type ResolvePluginType(Assembly assembly)
|
||||
{
|
||||
var candidateTypes = GetLoadableTypes(assembly)
|
||||
.Where(type =>
|
||||
typeof(IPlugin).IsAssignableFrom(type) &&
|
||||
!type.IsAbstract &&
|
||||
!type.IsInterface &&
|
||||
!type.ContainsGenericParameters)
|
||||
.ToArray();
|
||||
|
||||
if (candidateTypes.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Assembly '{assembly.Location}' does not contain a concrete type implementing '{nameof(IPlugin)}'.");
|
||||
}
|
||||
|
||||
var attributedTypes = candidateTypes
|
||||
.Where(type => type.IsDefined(typeof(PluginEntranceAttribute), inherit: false))
|
||||
.ToArray();
|
||||
|
||||
if (attributedTypes.Length == 1)
|
||||
{
|
||||
return attributedTypes[0];
|
||||
}
|
||||
|
||||
if (attributedTypes.Length > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Assembly '{assembly.Location}' contains multiple plugin entrance types. Mark only one type with '{nameof(PluginEntranceAttribute)}'.");
|
||||
}
|
||||
|
||||
if (candidateTypes.Length == 1)
|
||||
{
|
||||
return candidateTypes[0];
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Assembly '{assembly.Location}' contains multiple '{nameof(IPlugin)}' implementations. Mark the intended entrance type with '{nameof(PluginEntranceAttribute)}'.");
|
||||
}
|
||||
|
||||
private static IPlugin CreatePluginInstance(Type pluginType)
|
||||
{
|
||||
if (pluginType.GetConstructor(Type.EmptyTypes) is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin type '{pluginType.FullName}' must expose a public parameterless constructor.");
|
||||
}
|
||||
|
||||
if (Activator.CreateInstance(pluginType) is not IPlugin plugin)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to create plugin instance of type '{pluginType.FullName}'.");
|
||||
}
|
||||
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static void DisposeInstance(object? instance)
|
||||
{
|
||||
if (instance is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (instance is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
asyncDisposable.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
return;
|
||||
}
|
||||
|
||||
if (instance is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception disposeError)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine(
|
||||
$"[PluginLoader] Disposal of '{instance.GetType().FullName}' failed: {disposeError}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Type[] GetLoadableTypes(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
var loaderMessages = ex.LoaderExceptions
|
||||
.Where(exception => exception is not null)
|
||||
.Select(exception => exception!.Message)
|
||||
.ToArray();
|
||||
|
||||
var detail = loaderMessages.Length == 0
|
||||
? "No additional loader diagnostics were provided."
|
||||
: string.Join(Environment.NewLine, loaderMessages);
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to inspect plugin assembly '{assembly.Location}'.{Environment.NewLine}{detail}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PluginContext : IPluginContext, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly Dictionary<string, PluginSettingsPageRegistration> _settingsPages =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, PluginDesktopComponentRegistration> _desktopComponents =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<Type, object> _registeredServices = [];
|
||||
private readonly List<object> _serviceRegistrationOrder = [];
|
||||
private readonly object _serviceGate = new();
|
||||
private readonly IServiceProvider _hostServices;
|
||||
private int _disposed;
|
||||
|
||||
public PluginContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider services,
|
||||
IReadOnlyDictionary<string, object?> properties)
|
||||
{
|
||||
Manifest = manifest;
|
||||
PluginDirectory = pluginDirectory;
|
||||
DataDirectory = dataDirectory;
|
||||
_hostServices = services;
|
||||
Services = new PluginCompositeServiceProvider(this);
|
||||
Properties = properties;
|
||||
|
||||
RegisterBuiltInService<IPluginContext>(this);
|
||||
RegisterBuiltInService<IPluginMessageBus>(new PluginMessageBus());
|
||||
}
|
||||
|
||||
public PluginManifest Manifest { get; }
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public string DataDirectory { get; }
|
||||
|
||||
public IServiceProvider Services { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
public T? GetService<T>()
|
||||
{
|
||||
return (T?)Services.GetService(typeof(T));
|
||||
}
|
||||
|
||||
public bool TryGetProperty<T>(string key, out T? value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
|
||||
{
|
||||
value = typedValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void RegisterService<TService>(TService service)
|
||||
where TService : class
|
||||
{
|
||||
RegisterServiceCore(typeof(TService), service, allowOverride: false);
|
||||
}
|
||||
|
||||
public void RegisterSettingsPage(PluginSettingsPageRegistration registration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (!_settingsPages.TryAdd(registration.Id, registration))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{Manifest.Id}' already registered a settings page with id '{registration.Id}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public void RegisterDesktopComponent(PluginDesktopComponentRegistration registration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (!_desktopComponents.TryAdd(registration.ComponentId, registration))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{Manifest.Id}' already registered a desktop component with id '{registration.ComponentId}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginSettingsPageRegistration> GetSettingsPagesSnapshot()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _settingsPages.Values
|
||||
.OrderBy(page => page.SortOrder)
|
||||
.ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentRegistration> GetDesktopComponentsSnapshot()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _desktopComponents.Values
|
||||
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal object? ResolveService(Type serviceType)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (serviceType == typeof(IServiceProvider))
|
||||
{
|
||||
return Services;
|
||||
}
|
||||
|
||||
lock (_serviceGate)
|
||||
{
|
||||
if (_registeredServices.TryGetValue(serviceType, out var service))
|
||||
{
|
||||
return service;
|
||||
}
|
||||
|
||||
foreach (var registeredService in _registeredServices.Values)
|
||||
{
|
||||
if (serviceType.IsInstanceOfType(registeredService))
|
||||
{
|
||||
return registeredService;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _hostServices.GetService(serviceType);
|
||||
}
|
||||
|
||||
private void RegisterBuiltInService<TService>(TService service)
|
||||
where TService : class
|
||||
{
|
||||
RegisterServiceCore(typeof(TService), service, allowOverride: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
object[] services;
|
||||
lock (_serviceGate)
|
||||
{
|
||||
services = _serviceRegistrationOrder.ToArray();
|
||||
_registeredServices.Clear();
|
||||
_serviceRegistrationOrder.Clear();
|
||||
}
|
||||
|
||||
_settingsPages.Clear();
|
||||
_desktopComponents.Clear();
|
||||
|
||||
var disposedServices = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
||||
for (var i = services.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var service = services[i];
|
||||
if (ReferenceEquals(service, this) || !disposedServices.Add(service))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (service is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync();
|
||||
}
|
||||
else if (service is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterServiceCore(Type serviceType, object service, bool allowOverride)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceType);
|
||||
ArgumentNullException.ThrowIfNull(service);
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (!serviceType.IsInstanceOfType(service))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Service instance '{service.GetType().FullName}' is not assignable to '{serviceType.FullName}'.");
|
||||
}
|
||||
|
||||
lock (_serviceGate)
|
||||
{
|
||||
if (!allowOverride && _registeredServices.ContainsKey(serviceType))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{Manifest.Id}' already registered a service for '{serviceType.FullName}'.");
|
||||
}
|
||||
|
||||
_registeredServices[serviceType] = service;
|
||||
_serviceRegistrationOrder.Add(service);
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(PluginContext));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PluginCompositeServiceProvider : IServiceProvider
|
||||
{
|
||||
private readonly PluginContext _context;
|
||||
|
||||
public PluginCompositeServiceProvider(PluginContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceType);
|
||||
return _context.ResolveService(serviceType);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PluginMessageBus : IPluginMessageBus, IDisposable
|
||||
{
|
||||
private readonly Dictionary<Type, List<Subscription>> _subscriptions = [];
|
||||
private readonly object _gate = new();
|
||||
private int _disposed;
|
||||
|
||||
public IDisposable Subscribe<TMessage>(Action<TMessage> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(PluginMessageBus));
|
||||
}
|
||||
|
||||
var subscription = new Subscription(this, typeof(TMessage), message => handler((TMessage)message!));
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_subscriptions.TryGetValue(subscription.MessageType, out var handlers))
|
||||
{
|
||||
handlers = [];
|
||||
_subscriptions[subscription.MessageType] = handlers;
|
||||
}
|
||||
|
||||
handlers.Add(subscription);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public void Publish<TMessage>(TMessage message)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Subscription[] handlers;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_subscriptions.TryGetValue(typeof(TMessage), out var subscriptions) || subscriptions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
handlers = subscriptions.ToArray();
|
||||
}
|
||||
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
try
|
||||
{
|
||||
handler.Invoke(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine(
|
||||
$"[PluginMessageBus] Handler for '{typeof(TMessage).FullName}' failed: {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_subscriptions.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void Unsubscribe(Subscription subscription)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_subscriptions.TryGetValue(subscription.MessageType, out var handlers))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
handlers.Remove(subscription);
|
||||
if (handlers.Count == 0)
|
||||
{
|
||||
_subscriptions.Remove(subscription.MessageType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Subscription : IDisposable
|
||||
{
|
||||
private readonly PluginMessageBus _owner;
|
||||
private int _disposed;
|
||||
|
||||
public Subscription(PluginMessageBus owner, Type messageType, Action<object?> handler)
|
||||
{
|
||||
_owner = owner;
|
||||
MessageType = messageType;
|
||||
Handler = handler;
|
||||
}
|
||||
|
||||
public Type MessageType { get; }
|
||||
|
||||
public Action<object?> Handler { get; }
|
||||
|
||||
public void Invoke(object? message)
|
||||
{
|
||||
if (_disposed != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Handler(message);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_owner.Unsubscribe(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullServiceProvider : IServiceProvider
|
||||
{
|
||||
public static NullServiceProvider Instance { get; } = new();
|
||||
|
||||
private NullServiceProvider()
|
||||
{
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private enum PluginSourceKind
|
||||
{
|
||||
Package = 0,
|
||||
Manifest = 1
|
||||
}
|
||||
|
||||
private sealed record PluginCandidate(
|
||||
string SourcePath,
|
||||
PluginManifest Manifest,
|
||||
PluginSourceKind SourceKind);
|
||||
}
|
||||
21
LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs
Normal file
21
LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginLoaderOptions
|
||||
{
|
||||
public string ManifestFileName { get; init; } = PluginSdkInfo.ManifestFileName;
|
||||
|
||||
public string PackageFileExtension { get; init; } = PluginSdkInfo.PackageFileExtension;
|
||||
|
||||
public string DataDirectoryName { get; init; } = PluginSdkInfo.DataDirectoryName;
|
||||
|
||||
public string RuntimeDirectoryName { get; init; } = PluginSdkInfo.RuntimeDirectoryName;
|
||||
|
||||
public string ExtractedPackagesDirectoryName { get; init; } = PluginSdkInfo.ExtractedPackagesDirectoryName;
|
||||
|
||||
public string PackagedDataDirectoryName { get; init; } = PluginSdkInfo.PackagedDataDirectoryName;
|
||||
|
||||
public ISet<string> SharedAssemblyNames { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
typeof(IPlugin).Assembly.GetName().Name!
|
||||
};
|
||||
}
|
||||
107
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
107
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginManifest(
|
||||
string Id,
|
||||
string Name,
|
||||
string EntranceAssembly,
|
||||
string? Description = null,
|
||||
string? Author = null,
|
||||
string? Version = null,
|
||||
string? ApiVersion = null)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public static PluginManifest Load(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
using var stream = File.OpenRead(manifestPath);
|
||||
return Load(stream, manifestPath);
|
||||
}
|
||||
|
||||
public static PluginManifest Load(Stream stream, string sourceName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
||||
|
||||
var manifest = JsonSerializer.Deserialize<PluginManifest>(stream, SerializerOptions);
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize plugin manifest '{sourceName}'.");
|
||||
}
|
||||
|
||||
return manifest.NormalizeAndValidate(sourceName);
|
||||
}
|
||||
|
||||
public string ResolveEntranceAssemblyPath(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
if (Path.IsPathRooted(EntranceAssembly))
|
||||
{
|
||||
return Path.GetFullPath(EntranceAssembly);
|
||||
}
|
||||
|
||||
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
|
||||
?? throw new InvalidOperationException($"Failed to determine the directory of '{manifestPath}'.");
|
||||
|
||||
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
|
||||
}
|
||||
|
||||
private PluginManifest NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
Name = RequireValue(Name, nameof(Name), manifestPath),
|
||||
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
|
||||
Description = NormalizeOptionalValue(Description),
|
||||
Author = NormalizeOptionalValue(Author),
|
||||
Version = NormalizeOptionalValue(Version),
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion
|
||||
};
|
||||
|
||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' declares an invalid API version '{normalized.ApiVersion}'.");
|
||||
}
|
||||
|
||||
if (!System.Version.TryParse(PluginSdkInfo.ApiVersion, out var currentVersion))
|
||||
{
|
||||
throw new InvalidOperationException($"Plugin SDK API version '{PluginSdkInfo.ApiVersion}' is invalid.");
|
||||
}
|
||||
|
||||
if (requestedVersion.Major != currentVersion.Major)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string RequireValue(string? value, string propertyName, string manifestPath)
|
||||
{
|
||||
var normalized = NormalizeOptionalValue(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' is missing required property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalValue(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginSdkInfo
|
||||
{
|
||||
public const string ApiVersion = "1.0.0";
|
||||
public const string ManifestFileName = "plugin.json";
|
||||
public const string PackageFileExtension = ".laapp";
|
||||
public const string DataDirectoryName = "Data";
|
||||
public const string RuntimeDirectoryName = ".runtime";
|
||||
public const string ExtractedPackagesDirectoryName = "packages";
|
||||
public const string PackagedDataDirectoryName = "data";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginSettingsPageRegistration
|
||||
{
|
||||
public PluginSettingsPageRegistration(
|
||||
string id,
|
||||
string title,
|
||||
Func<Control> contentFactory,
|
||||
int sortOrder = 0)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||
ArgumentNullException.ThrowIfNull(contentFactory);
|
||||
|
||||
Id = id.Trim();
|
||||
Title = title.Trim();
|
||||
ContentFactory = contentFactory;
|
||||
SortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public int SortOrder { get; }
|
||||
|
||||
public Func<Control> ContentFactory { get; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
<LegacyLoosePluginOutputDirectory>..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
|
||||
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CreateLaappPackage" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
|
||||
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
|
||||
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
|
||||
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
84
LanMountainDesktop.SamplePlugin/SamplePlugin.cs
Normal file
84
LanMountainDesktop.SamplePlugin/SamplePlugin.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.SamplePlugin;
|
||||
|
||||
[PluginEntrance]
|
||||
public sealed class SamplePlugin : PluginBase, IDisposable
|
||||
{
|
||||
private SamplePluginRuntimeStateService? _stateService;
|
||||
private SamplePluginClockService? _clockService;
|
||||
|
||||
public override void Initialize(IPluginContext context)
|
||||
{
|
||||
Directory.CreateDirectory(context.DataDirectory);
|
||||
|
||||
var hostName = GetHostProperty(context, "HostApplicationName", "UnknownHost");
|
||||
var hostVersion = GetHostProperty(context, "HostVersion", "UnknownVersion");
|
||||
var sdkApiVersion = GetHostProperty(context, "PluginSdkApiVersion", "UnknownApiVersion");
|
||||
var messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? throw new InvalidOperationException("Plugin message bus is not available.");
|
||||
|
||||
_stateService = new SamplePluginRuntimeStateService(
|
||||
context.Manifest,
|
||||
context.PluginDirectory,
|
||||
context.DataDirectory,
|
||||
hostName,
|
||||
hostVersion,
|
||||
sdkApiVersion,
|
||||
messageBus);
|
||||
context.RegisterService(_stateService);
|
||||
|
||||
_clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus);
|
||||
context.RegisterService(_clockService);
|
||||
_stateService.AttachClockService(_clockService);
|
||||
|
||||
var logPath = Path.Combine(context.DataDirectory, "sample-plugin.log");
|
||||
var initMessage =
|
||||
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {context.Manifest.Version ?? "dev"}).";
|
||||
|
||||
try
|
||||
{
|
||||
File.AppendAllText(logPath, initMessage + Environment.NewLine);
|
||||
_stateService.MarkBackendReady($"Initialization log written to {logPath}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_stateService.MarkBackendFaulted($"Initialization log write failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
|
||||
_clockService.Start();
|
||||
|
||||
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
|
||||
"status",
|
||||
"Plugin Status",
|
||||
() => new SamplePluginSettingsView(context)));
|
||||
|
||||
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
||||
"LanMountainDesktop.SamplePlugin.StatusClock",
|
||||
"Sample Plugin Status Clock",
|
||||
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
|
||||
iconKey: "PuzzlePiece",
|
||||
category: "Plugins",
|
||||
minWidthCells: 4,
|
||||
minHeightCells: 4,
|
||||
allowDesktopPlacement: true,
|
||||
allowStatusBarPlacement: false,
|
||||
resizeMode: PluginDesktopComponentResizeMode.Proportional,
|
||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_clockService?.Dispose();
|
||||
_clockService = null;
|
||||
_stateService = null;
|
||||
}
|
||||
|
||||
private static string GetHostProperty(IPluginContext context, string key, string fallback)
|
||||
{
|
||||
return context.TryGetProperty<string>(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
}
|
||||
465
LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs
Normal file
465
LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs
Normal file
@@ -0,0 +1,465 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.SamplePlugin;
|
||||
|
||||
internal enum SamplePluginHealthState
|
||||
{
|
||||
Healthy,
|
||||
Pending,
|
||||
Faulted
|
||||
}
|
||||
|
||||
internal sealed record SamplePluginStatusEntry(
|
||||
string Key,
|
||||
string Title,
|
||||
SamplePluginHealthState State,
|
||||
string Summary,
|
||||
string Detail,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
internal sealed record SamplePluginCapabilityItem(
|
||||
string Title,
|
||||
string Detail);
|
||||
|
||||
internal sealed record SamplePluginRuntimeSnapshot(
|
||||
PluginManifest Manifest,
|
||||
string PluginDirectory,
|
||||
string DataDirectory,
|
||||
string HostApplicationName,
|
||||
string HostVersion,
|
||||
string SdkApiVersion,
|
||||
IReadOnlyList<SamplePluginStatusEntry> StatusEntries,
|
||||
bool HasPlacedComponent,
|
||||
int PlacedCount,
|
||||
int PreviewCount,
|
||||
IReadOnlyList<string> PlacementIds,
|
||||
string? LastComponentId,
|
||||
double LastCellSize,
|
||||
DateTimeOffset? ServiceClockTime);
|
||||
|
||||
internal sealed record SamplePluginClockTickMessage(DateTimeOffset CurrentTime);
|
||||
|
||||
internal sealed record SamplePluginStateChangedMessage(string Reason);
|
||||
|
||||
internal sealed record SamplePluginComponentInstance(
|
||||
string ComponentId,
|
||||
string? PlacementId,
|
||||
double CellSize)
|
||||
{
|
||||
public bool IsPlaced => !string.IsNullOrWhiteSpace(PlacementId);
|
||||
}
|
||||
|
||||
internal sealed class SamplePluginRuntimeStateService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
private readonly Dictionary<string, SamplePluginComponentInstance> _componentInstances =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly PluginManifest _manifest;
|
||||
private readonly string _pluginDirectory;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly string _hostApplicationName;
|
||||
private readonly string _hostVersion;
|
||||
private readonly string _sdkApiVersion;
|
||||
|
||||
private SamplePluginStatusEntry _frontend;
|
||||
private SamplePluginStatusEntry _component;
|
||||
private SamplePluginStatusEntry _backend;
|
||||
private SamplePluginStatusEntry _service;
|
||||
private string? _lastComponentId;
|
||||
private double _lastCellSize;
|
||||
private DateTimeOffset? _serviceClockTime;
|
||||
|
||||
public SamplePluginRuntimeStateService(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
string hostApplicationName,
|
||||
string hostVersion,
|
||||
string sdkApiVersion,
|
||||
IPluginMessageBus messageBus)
|
||||
{
|
||||
_manifest = manifest;
|
||||
_pluginDirectory = pluginDirectory;
|
||||
_dataDirectory = dataDirectory;
|
||||
_hostApplicationName = hostApplicationName;
|
||||
_hostVersion = hostVersion;
|
||||
_sdkApiVersion = sdkApiVersion;
|
||||
_messageBus = messageBus;
|
||||
|
||||
_frontend = CreateEntry(
|
||||
"frontend",
|
||||
"Frontend",
|
||||
SamplePluginHealthState.Pending,
|
||||
"Pending",
|
||||
"Waiting for a plugin UI surface to connect.");
|
||||
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
"Component",
|
||||
SamplePluginHealthState.Pending,
|
||||
"Pending",
|
||||
"No component instance has been created yet.");
|
||||
|
||||
_backend = CreateEntry(
|
||||
"backend",
|
||||
"Backend",
|
||||
SamplePluginHealthState.Pending,
|
||||
"Pending",
|
||||
"Plugin initialization is in progress.");
|
||||
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
"Clock Service",
|
||||
SamplePluginHealthState.Pending,
|
||||
"Pending",
|
||||
"Clock service is not attached yet.");
|
||||
}
|
||||
|
||||
public void AttachClockService(SamplePluginClockService clockService)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(clockService);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_serviceClockTime = clockService.CurrentTime;
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
"Clock Service",
|
||||
SamplePluginHealthState.Pending,
|
||||
"Attached",
|
||||
"Clock service was attached and is waiting for the first tick.");
|
||||
}
|
||||
|
||||
PublishStateChanged("Clock service attached");
|
||||
}
|
||||
|
||||
public void MarkFrontendReady(string detail)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_frontend = CreateEntry(
|
||||
"frontend",
|
||||
"Frontend",
|
||||
SamplePluginHealthState.Healthy,
|
||||
"Healthy",
|
||||
detail);
|
||||
}
|
||||
|
||||
PublishStateChanged("Frontend updated");
|
||||
}
|
||||
|
||||
public void MarkBackendReady(string detail)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_backend = CreateEntry(
|
||||
"backend",
|
||||
"Backend",
|
||||
SamplePluginHealthState.Healthy,
|
||||
"Healthy",
|
||||
detail);
|
||||
}
|
||||
|
||||
PublishStateChanged("Backend updated");
|
||||
}
|
||||
|
||||
public void MarkBackendFaulted(string detail)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_backend = CreateEntry(
|
||||
"backend",
|
||||
"Backend",
|
||||
SamplePluginHealthState.Faulted,
|
||||
"Faulted",
|
||||
detail);
|
||||
}
|
||||
|
||||
PublishStateChanged("Backend faulted");
|
||||
}
|
||||
|
||||
public void MarkClockServiceTick(DateTimeOffset currentTime)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_serviceClockTime = currentTime;
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
"Clock Service",
|
||||
SamplePluginHealthState.Healthy,
|
||||
"Healthy",
|
||||
$"Clock service is running. Current service time: {currentTime.LocalDateTime:HH:mm:ss}");
|
||||
}
|
||||
|
||||
PublishStateChanged("Clock service tick");
|
||||
}
|
||||
|
||||
public void MarkClockServiceFaulted(string detail)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
"Clock Service",
|
||||
SamplePluginHealthState.Faulted,
|
||||
"Faulted",
|
||||
detail);
|
||||
}
|
||||
|
||||
PublishStateChanged("Clock service faulted");
|
||||
}
|
||||
|
||||
public string RegisterComponentInstance(string componentId, string? placementId, double cellSize)
|
||||
{
|
||||
var instanceId = Guid.NewGuid().ToString("N");
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_componentInstances[instanceId] = new SamplePluginComponentInstance(componentId, placementId, cellSize);
|
||||
_lastComponentId = componentId;
|
||||
_lastCellSize = cellSize;
|
||||
UpdateComponentStatusNoLock();
|
||||
}
|
||||
|
||||
PublishStateChanged("Component attached");
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
public void UnregisterComponentInstance(string instanceId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(instanceId);
|
||||
|
||||
var removed = false;
|
||||
lock (_gate)
|
||||
{
|
||||
removed = _componentInstances.Remove(instanceId);
|
||||
if (removed)
|
||||
{
|
||||
UpdateComponentStatusNoLock();
|
||||
}
|
||||
}
|
||||
|
||||
if (removed)
|
||||
{
|
||||
PublishStateChanged("Component detached");
|
||||
}
|
||||
}
|
||||
|
||||
public SamplePluginRuntimeSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var placementIds = _componentInstances.Values
|
||||
.Where(instance => instance.IsPlaced)
|
||||
.Select(instance => instance.PlacementId!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
|
||||
|
||||
return new SamplePluginRuntimeSnapshot(
|
||||
_manifest,
|
||||
_pluginDirectory,
|
||||
_dataDirectory,
|
||||
_hostApplicationName,
|
||||
_hostVersion,
|
||||
_sdkApiVersion,
|
||||
[_frontend, _component, _backend, _service],
|
||||
placementIds.Length > 0,
|
||||
placementIds.Length,
|
||||
previewCount,
|
||||
placementIds,
|
||||
_lastComponentId,
|
||||
_lastCellSize,
|
||||
_serviceClockTime);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SamplePluginCapabilityItem> GetCapabilities(
|
||||
IPluginContext context,
|
||||
bool hasStateService,
|
||||
bool hasClockService,
|
||||
bool hasMessageBus)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var propertyNames = context.Properties.Count == 0
|
||||
? "(none)"
|
||||
: string.Join(", ", context.Properties.Keys.OrderBy(key => key, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
return
|
||||
[
|
||||
new SamplePluginCapabilityItem(
|
||||
"IPluginContext.Manifest",
|
||||
$"Readable. Current plugin id: {context.Manifest.Id}; version: {context.Manifest.Version ?? "dev"}."),
|
||||
new SamplePluginCapabilityItem(
|
||||
"IPluginContext.PluginDirectory / DataDirectory",
|
||||
$"Readable. Plugin directory: {context.PluginDirectory}; data directory: {context.DataDirectory}."),
|
||||
new SamplePluginCapabilityItem(
|
||||
"IPluginContext.Properties",
|
||||
$"Readable. Host properties currently exposed: {propertyNames}."),
|
||||
new SamplePluginCapabilityItem(
|
||||
"IPluginContext.GetService<T>()",
|
||||
$"Callable. State service resolved: {hasStateService}; clock service resolved: {hasClockService}; message bus resolved: {hasMessageBus}."),
|
||||
new SamplePluginCapabilityItem(
|
||||
"IPluginContext.RegisterService<TService>()",
|
||||
"Callable during plugin initialization. This plugin registers SamplePluginRuntimeStateService and SamplePluginClockService into the plugin service container."),
|
||||
new SamplePluginCapabilityItem(
|
||||
"Plugin communication bus",
|
||||
"This plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces."),
|
||||
new SamplePluginCapabilityItem(
|
||||
"PluginDesktopComponentContext",
|
||||
"Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.")
|
||||
];
|
||||
}
|
||||
|
||||
private void UpdateComponentStatusNoLock()
|
||||
{
|
||||
var placementIds = _componentInstances.Values
|
||||
.Where(instance => instance.IsPlaced)
|
||||
.Select(instance => instance.PlacementId!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
|
||||
|
||||
if (placementIds.Length > 0)
|
||||
{
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
"Component",
|
||||
SamplePluginHealthState.Healthy,
|
||||
"Placed",
|
||||
$"Placed count: {placementIds.Length}; preview count: {previewCount}; placements: {string.Join(", ", placementIds)}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewCount > 0)
|
||||
{
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
"Component",
|
||||
SamplePluginHealthState.Healthy,
|
||||
"Preview",
|
||||
$"Preview instances: {previewCount}; no placed desktop instance is active yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
"Component",
|
||||
SamplePluginHealthState.Pending,
|
||||
"Pending",
|
||||
"No component instance is active.");
|
||||
}
|
||||
|
||||
private void PublishStateChanged(string reason)
|
||||
{
|
||||
_messageBus.Publish(new SamplePluginStateChangedMessage(reason));
|
||||
}
|
||||
|
||||
private static SamplePluginStatusEntry CreateEntry(
|
||||
string key,
|
||||
string title,
|
||||
SamplePluginHealthState state,
|
||||
string summary,
|
||||
string detail)
|
||||
{
|
||||
return new SamplePluginStatusEntry(
|
||||
key,
|
||||
title,
|
||||
state,
|
||||
summary,
|
||||
detail,
|
||||
DateTimeOffset.Now);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SamplePluginClockService : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly string _clockStateFilePath;
|
||||
private readonly SamplePluginRuntimeStateService _stateService;
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
private readonly Timer _timer;
|
||||
private DateTimeOffset _currentTime = DateTimeOffset.Now;
|
||||
private int _disposed;
|
||||
|
||||
public SamplePluginClockService(
|
||||
string dataDirectory,
|
||||
SamplePluginRuntimeStateService stateService,
|
||||
IPluginMessageBus messageBus)
|
||||
{
|
||||
_clockStateFilePath = Path.Combine(dataDirectory, "clock-service.txt");
|
||||
_stateService = stateService;
|
||||
_messageBus = messageBus;
|
||||
_timer = new Timer(OnTimerTick);
|
||||
}
|
||||
|
||||
public DateTimeOffset CurrentTime
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _currentTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
PublishTick();
|
||||
_timer.Change(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_timer.Dispose();
|
||||
}
|
||||
|
||||
private void OnTimerTick(object? state)
|
||||
{
|
||||
PublishTick();
|
||||
}
|
||||
|
||||
private void PublishTick()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.Now;
|
||||
lock (_gate)
|
||||
{
|
||||
_currentTime = now;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(
|
||||
_clockStateFilePath,
|
||||
now.ToString("O", CultureInfo.InvariantCulture));
|
||||
_stateService.MarkClockServiceTick(now);
|
||||
_messageBus.Publish(new SamplePluginClockTickMessage(now));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_stateService.MarkClockServiceFaulted($"Clock state write failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
339
LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs
Normal file
339
LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs
Normal file
@@ -0,0 +1,339 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.SamplePlugin;
|
||||
|
||||
internal sealed class SamplePluginSettingsView : UserControl
|
||||
{
|
||||
private readonly IPluginContext _context;
|
||||
private readonly SamplePluginRuntimeStateService _stateService;
|
||||
private readonly SamplePluginClockService _clockService;
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
private readonly StackPanel _pluginInfoPanel = new() { Spacing = 8 };
|
||||
private readonly StackPanel _capabilityPanel = new() { Spacing = 8 };
|
||||
private readonly StackPanel _statusPanel = new() { Spacing = 10 };
|
||||
private readonly List<IDisposable> _subscriptions = [];
|
||||
|
||||
public SamplePluginSettingsView(IPluginContext context)
|
||||
{
|
||||
_context = context;
|
||||
_stateService = context.GetService<SamplePluginRuntimeStateService>()
|
||||
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
||||
_clockService = context.GetService<SamplePluginClockService>()
|
||||
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
|
||||
_messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
|
||||
|
||||
_stateService.MarkFrontendReady("Settings page is connected to plugin services and communication.");
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
|
||||
Content = new Border
|
||||
{
|
||||
Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(Color.Parse("#1F0B1120"), 0),
|
||||
new GradientStop(Color.Parse("#260C4A6E"), 1)
|
||||
]
|
||||
},
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#6628B2FF")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(18),
|
||||
Padding = new Thickness(18),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 14,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = "Sample Plugin Capability Inspector",
|
||||
FontSize = 22,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Brushes.White
|
||||
},
|
||||
CreateSection("Plugin Info", _pluginInfoPanel),
|
||||
CreateSection("Accessible Capabilities", _capabilityPanel),
|
||||
CreateSection("Live Runtime Status", _statusPanel)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
RefreshView();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
SubscribeToPluginBus();
|
||||
RefreshView();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
foreach (var subscription in _subscriptions)
|
||||
{
|
||||
subscription.Dispose();
|
||||
}
|
||||
|
||||
_subscriptions.Clear();
|
||||
}
|
||||
|
||||
private void SubscribeToPluginBus()
|
||||
{
|
||||
if (_subscriptions.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(_ =>
|
||||
Dispatcher.UIThread.Post(RefreshView)));
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
|
||||
Dispatcher.UIThread.Post(RefreshView)));
|
||||
}
|
||||
|
||||
private void RefreshView()
|
||||
{
|
||||
var snapshot = _stateService.GetSnapshot();
|
||||
RefreshPluginInfo(snapshot);
|
||||
RefreshCapabilities();
|
||||
RefreshStatuses(snapshot);
|
||||
}
|
||||
|
||||
private void RefreshPluginInfo(SamplePluginRuntimeSnapshot snapshot)
|
||||
{
|
||||
_pluginInfoPanel.Children.Clear();
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Name", snapshot.Manifest.Name));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Id", snapshot.Manifest.Id));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Version", snapshot.Manifest.Version ?? "dev"));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Author", snapshot.Manifest.Author ?? "(none)"));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Description", snapshot.Manifest.Description ?? "(none)"));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Directory", snapshot.PluginDirectory));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Data Directory", snapshot.DataDirectory));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Host Application", snapshot.HostApplicationName));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Host Version", snapshot.HostVersion));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("SDK API Version", snapshot.SdkApiVersion));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("State Service Resolved", (_context.GetService<SamplePluginRuntimeStateService>() is not null).ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Clock Service Resolved", (_context.GetService<SamplePluginClockService>() is not null).ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Message Bus Resolved", (_context.GetService<IPluginMessageBus>() is not null).ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Component Placed", snapshot.HasPlacedComponent ? "Yes" : "No"));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Placed Count", snapshot.PlacedCount.ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Preview Count", snapshot.PreviewCount.ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
"Placement Ids",
|
||||
snapshot.PlacementIds.Count == 0 ? "(none)" : string.Join(", ", snapshot.PlacementIds)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Last Component Id", snapshot.LastComponentId ?? "(none)"));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
"Last Cell Size",
|
||||
snapshot.LastCellSize > 0 ? $"{snapshot.LastCellSize:F0}px" : "(unknown)"));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
"Clock Service Time",
|
||||
_clockService.CurrentTime.LocalDateTime.ToString("HH:mm:ss")));
|
||||
}
|
||||
|
||||
private void RefreshCapabilities()
|
||||
{
|
||||
var capabilities = _stateService.GetCapabilities(
|
||||
_context,
|
||||
_context.GetService<SamplePluginRuntimeStateService>() is not null,
|
||||
_context.GetService<SamplePluginClockService>() is not null,
|
||||
_context.GetService<IPluginMessageBus>() is not null);
|
||||
|
||||
_capabilityPanel.Children.Clear();
|
||||
foreach (var capability in capabilities)
|
||||
{
|
||||
_capabilityPanel.Children.Add(CreateCapabilityCard(capability));
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshStatuses(SamplePluginRuntimeSnapshot snapshot)
|
||||
{
|
||||
_statusPanel.Children.Clear();
|
||||
|
||||
foreach (var entry in snapshot.StatusEntries)
|
||||
{
|
||||
var palette = GetPalette(entry.State);
|
||||
_statusPanel.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(palette.Background),
|
||||
BorderBrush = new SolidColorBrush(palette.Border),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(12, 10),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children =
|
||||
{
|
||||
CreateStatusHeader(entry, palette),
|
||||
new TextBlock
|
||||
{
|
||||
Text = entry.Detail,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"Updated: {entry.UpdatedAt.LocalDateTime:HH:mm:ss}",
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FF93C5FD"))
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private Border CreateSection(string title, Control content)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#14000000")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#3328B2FF")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(14),
|
||||
Padding = new Thickness(14),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = title,
|
||||
FontSize = 16,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Brushes.White
|
||||
},
|
||||
content
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Control CreateInfoLine(string label, string value)
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("180,*"),
|
||||
ColumnSpacing = 10
|
||||
};
|
||||
|
||||
var labelText = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")),
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
var valueText = new TextBlock
|
||||
{
|
||||
Text = value,
|
||||
Foreground = Brushes.White,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
grid.Children.Add(labelText);
|
||||
grid.Children.Add(valueText);
|
||||
Grid.SetColumn(valueText, 1);
|
||||
return grid;
|
||||
}
|
||||
|
||||
private Control CreateCapabilityCard(SamplePluginCapabilityItem item)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#0F082F49")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#3338BDF8")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(12, 10),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = item.Title,
|
||||
Foreground = Brushes.White,
|
||||
FontWeight = FontWeight.SemiBold
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = item.Detail,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Control CreateStatusHeader(
|
||||
SamplePluginStatusEntry entry,
|
||||
(Color Background, Color Border, Color Dot) palette)
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
||||
ColumnSpacing = 8
|
||||
};
|
||||
|
||||
var dot = new Border
|
||||
{
|
||||
Width = 10,
|
||||
Height = 10,
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = new SolidColorBrush(palette.Dot),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
var title = new TextBlock
|
||||
{
|
||||
Text = entry.Title,
|
||||
FontSize = 15,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Brushes.White
|
||||
};
|
||||
var summary = new TextBlock
|
||||
{
|
||||
Text = entry.Summary,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
||||
HorizontalAlignment = HorizontalAlignment.Right
|
||||
};
|
||||
|
||||
grid.Children.Add(dot);
|
||||
grid.Children.Add(title);
|
||||
grid.Children.Add(summary);
|
||||
Grid.SetColumn(title, 1);
|
||||
Grid.SetColumn(summary, 2);
|
||||
return grid;
|
||||
}
|
||||
|
||||
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
SamplePluginHealthState.Healthy => (
|
||||
Color.Parse("#1F115E59"),
|
||||
Color.Parse("#665EEAD4"),
|
||||
Color.Parse("#5EEAD4")),
|
||||
SamplePluginHealthState.Faulted => (
|
||||
Color.Parse("#291B1B"),
|
||||
Color.Parse("#66F87171"),
|
||||
Color.Parse("#F87171")),
|
||||
_ => (
|
||||
Color.Parse("#2B3A2A0D"),
|
||||
Color.Parse("#66FBBF24"),
|
||||
Color.Parse("#FBBF24"))
|
||||
};
|
||||
}
|
||||
}
|
||||
284
LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs
Normal file
284
LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.SamplePlugin;
|
||||
|
||||
internal sealed class SamplePluginStatusClockWidget : Border
|
||||
{
|
||||
private readonly PluginDesktopComponentContext _context;
|
||||
private readonly SamplePluginRuntimeStateService _stateService;
|
||||
private readonly SamplePluginClockService _clockService;
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
private readonly TextBlock _timeTextBlock;
|
||||
private readonly TextBlock _subtitleTextBlock;
|
||||
private readonly StackPanel _statusPanel;
|
||||
private readonly Border _statusHost;
|
||||
private readonly List<IDisposable> _subscriptions = [];
|
||||
private string? _instanceId;
|
||||
|
||||
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
|
||||
{
|
||||
_context = context;
|
||||
_stateService = context.GetService<SamplePluginRuntimeStateService>()
|
||||
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
||||
_clockService = context.GetService<SamplePluginClockService>()
|
||||
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
|
||||
_messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
|
||||
|
||||
_timeTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = Brushes.White,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Left
|
||||
};
|
||||
_subtitleTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
_statusPanel = new StackPanel
|
||||
{
|
||||
Spacing = 8
|
||||
};
|
||||
_statusHost = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
|
||||
BorderThickness = new Thickness(1),
|
||||
Child = _statusPanel
|
||||
};
|
||||
|
||||
Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(Color.Parse("#FF07111F"), 0),
|
||||
new GradientStop(Color.Parse("#FF0C4A6E"), 0.55),
|
||||
new GradientStop(Color.Parse("#FF0EA5E9"), 1)
|
||||
]
|
||||
};
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF"));
|
||||
BorderThickness = new Thickness(1);
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||
VerticalAlignment = VerticalAlignment.Stretch;
|
||||
Child = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("Auto,*"),
|
||||
RowSpacing = 14,
|
||||
Children =
|
||||
{
|
||||
new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Children =
|
||||
{
|
||||
_timeTextBlock,
|
||||
_subtitleTextBlock
|
||||
}
|
||||
},
|
||||
_statusHost
|
||||
}
|
||||
};
|
||||
|
||||
Grid.SetRow(((Grid)Child).Children[1], 1);
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
RefreshClock(_clockService.CurrentTime);
|
||||
UpdateSubtitle();
|
||||
RefreshStatusPanel();
|
||||
ApplyScale();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_instanceId))
|
||||
{
|
||||
_instanceId = _stateService.RegisterComponentInstance(
|
||||
_context.ComponentId,
|
||||
_context.PlacementId,
|
||||
_context.CellSize);
|
||||
}
|
||||
|
||||
_stateService.MarkFrontendReady("Widget surface is connected to plugin services and communication.");
|
||||
SubscribeToPluginBus();
|
||||
|
||||
RefreshClock(_clockService.CurrentTime);
|
||||
UpdateSubtitle();
|
||||
RefreshStatusPanel();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
foreach (var subscription in _subscriptions)
|
||||
{
|
||||
subscription.Dispose();
|
||||
}
|
||||
|
||||
_subscriptions.Clear();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_instanceId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_stateService.UnregisterComponentInstance(_instanceId);
|
||||
_instanceId = null;
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyScale();
|
||||
RefreshStatusPanel();
|
||||
}
|
||||
|
||||
private void SubscribeToPluginBus()
|
||||
{
|
||||
if (_subscriptions.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(message =>
|
||||
Dispatcher.UIThread.Post(() => RefreshClock(message.CurrentTime))));
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
UpdateSubtitle();
|
||||
RefreshStatusPanel();
|
||||
})));
|
||||
}
|
||||
|
||||
private void RefreshClock(DateTimeOffset currentTime)
|
||||
{
|
||||
_timeTextBlock.Text = currentTime.LocalDateTime.ToString("HH:mm:ss");
|
||||
}
|
||||
|
||||
private void UpdateSubtitle()
|
||||
{
|
||||
var snapshot = _stateService.GetSnapshot();
|
||||
_subtitleTextBlock.Text = string.IsNullOrWhiteSpace(_context.PlacementId)
|
||||
? $"Preview surface | placed: {snapshot.PlacedCount}"
|
||||
: $"Placement {_context.PlacementId} | placed: {snapshot.PlacedCount}";
|
||||
}
|
||||
|
||||
private void RefreshStatusPanel()
|
||||
{
|
||||
_statusPanel.Children.Clear();
|
||||
|
||||
var snapshot = _stateService.GetSnapshot();
|
||||
var basis = GetLayoutBasis();
|
||||
var titleSize = Math.Clamp(basis * 0.068, 11, 16);
|
||||
var detailSize = Math.Clamp(basis * 0.052, 9, 13);
|
||||
|
||||
foreach (var entry in snapshot.StatusEntries)
|
||||
{
|
||||
var palette = GetPalette(entry.State);
|
||||
_statusPanel.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(palette.Background),
|
||||
BorderBrush = new SolidColorBrush(palette.Border),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(10, 8),
|
||||
Child = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("Auto,Auto"),
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
||||
ColumnSpacing = 8,
|
||||
Children =
|
||||
{
|
||||
new Border
|
||||
{
|
||||
Width = Math.Clamp(basis * 0.038, 8, 11),
|
||||
Height = Math.Clamp(basis * 0.038, 8, 11),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = new SolidColorBrush(palette.Dot),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = entry.Title,
|
||||
FontSize = titleSize,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Brushes.White,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = entry.Summary,
|
||||
FontSize = detailSize,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
TextAlignment = TextAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = entry.Detail,
|
||||
FontSize = detailSize,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var row = (Grid)((Border)_statusPanel.Children[^1]).Child!;
|
||||
Grid.SetColumn(row.Children[1], 1);
|
||||
Grid.SetColumn(row.Children[2], 2);
|
||||
Grid.SetColumnSpan(row.Children[3], 3);
|
||||
Grid.SetRow(row.Children[3], 1);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyScale()
|
||||
{
|
||||
var basis = GetLayoutBasis();
|
||||
Padding = new Thickness(Math.Clamp(basis * 0.09, 16, 26));
|
||||
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.14, 20, 34));
|
||||
_timeTextBlock.FontSize = Math.Clamp(basis * 0.22, 30, 58);
|
||||
_subtitleTextBlock.FontSize = Math.Clamp(basis * 0.062, 11, 17);
|
||||
_statusHost.Padding = new Thickness(Math.Clamp(basis * 0.045, 10, 18));
|
||||
_statusHost.CornerRadius = new CornerRadius(Math.Clamp(basis * 0.09, 14, 22));
|
||||
_statusPanel.Spacing = Math.Clamp(basis * 0.024, 6, 10);
|
||||
}
|
||||
|
||||
private double GetLayoutBasis()
|
||||
{
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 4;
|
||||
var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4;
|
||||
return Math.Max(_context.CellSize * 4, Math.Min(width, height));
|
||||
}
|
||||
|
||||
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
SamplePluginHealthState.Healthy => (
|
||||
Color.Parse("#1F0F766E"),
|
||||
Color.Parse("#4D5EEAD4"),
|
||||
Color.Parse("#5EEAD4")),
|
||||
SamplePluginHealthState.Faulted => (
|
||||
Color.Parse("#29B91C1C"),
|
||||
Color.Parse("#66F87171"),
|
||||
Color.Parse("#F87171")),
|
||||
_ => (
|
||||
Color.Parse("#1F7C2D12"),
|
||||
Color.Parse("#66FDBA74"),
|
||||
Color.Parse("#FDBA74"))
|
||||
};
|
||||
}
|
||||
}
|
||||
9
LanMountainDesktop.SamplePlugin/plugin.json
Normal file
9
LanMountainDesktop.SamplePlugin/plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "LanMountainDesktop.SamplePlugin",
|
||||
"name": "LanMountain Sample Plugin",
|
||||
"description": "Example plugin used to validate PluginSdk loading and isolation.",
|
||||
"author": "LanMountainDesktop",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "1.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll"
|
||||
}
|
||||
@@ -5,6 +5,10 @@ VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -15,5 +19,13 @@ Global
|
||||
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -15,6 +15,23 @@
|
||||
<Application.DataTemplates>
|
||||
<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 />
|
||||
|
||||
@@ -3,8 +3,10 @@ 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.Threading;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views;
|
||||
@@ -14,6 +16,11 @@ namespace LanMountainDesktop;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private SettingsWindow? _traySettingsWindow;
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
|
||||
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
ConfigureWebViewUserDataFolder();
|
||||
@@ -23,11 +30,15 @@ public partial class App : Application
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePluginRuntime();
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// 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.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
@@ -37,6 +48,65 @@ public partial class App : Application
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTraySettingsClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_traySettingsWindow is { } existingWindow && existingWindow.IsVisible)
|
||||
{
|
||||
existingWindow.WindowState = Avalonia.Controls.WindowState.Normal;
|
||||
existingWindow.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsWindow = new SettingsWindow();
|
||||
settingsWindow.Closed += (_, _) =>
|
||||
{
|
||||
if (ReferenceEquals(_traySettingsWindow, settingsWindow))
|
||||
{
|
||||
_traySettingsWindow = null;
|
||||
}
|
||||
};
|
||||
|
||||
_traySettingsWindow = settingsWindow;
|
||||
settingsWindow.Show();
|
||||
settingsWindow.Activate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[TraySettings] Failed to open settings window: {ex}");
|
||||
}
|
||||
}, DispatcherPriority.Normal);
|
||||
}
|
||||
|
||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (AppRestartService.TryRestartCurrentProcess())
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
{
|
||||
// Get an array of plugins to remove
|
||||
@@ -76,4 +146,18 @@ public partial class App : Application
|
||||
// Keep startup resilient if user profile folders are unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializePluginRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
_pluginRuntimeService?.Dispose();
|
||||
_pluginRuntimeService = new PluginRuntimeService();
|
||||
_pluginRuntimeService.LoadInstalledPlugins();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ public static class BuiltInComponentIds
|
||||
public const string HolidayCalendar = "HolidayCalendar";
|
||||
public const string DesktopDailyPoetry = "DesktopDailyPoetry";
|
||||
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
|
||||
public const string DesktopDailyWord = "DesktopDailyWord";
|
||||
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
|
||||
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
||||
public const string DesktopIfengNews = "DesktopIfengNews";
|
||||
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
|
||||
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
|
||||
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
|
||||
public const string DesktopExchangeRateCalculator = "DesktopExchangeRateCalculator";
|
||||
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||
public const string DesktopBrowser = "DesktopBrowser";
|
||||
|
||||
@@ -225,6 +225,78 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopDailyWord,
|
||||
"Daily Word",
|
||||
"Book",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopDailyWord2x2,
|
||||
"Daily Word 2x2",
|
||||
"Book",
|
||||
"Info",
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopCnrDailyNews,
|
||||
"CNR Daily News",
|
||||
"News",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopIfengNews,
|
||||
"iFeng News",
|
||||
"News",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||
"Bilibili Hot Search",
|
||||
"News",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopBaiduHotSearch,
|
||||
"Baidu Hot Search",
|
||||
"News",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopStcn24Forum,
|
||||
"STCN 24",
|
||||
"News",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopExchangeRateCalculator,
|
||||
"Exchange Rate Converter",
|
||||
"Calculator",
|
||||
"Calculator",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
"Blackboard Portrait",
|
||||
@@ -313,6 +385,13 @@ public sealed class ComponentRegistry
|
||||
return new ComponentRegistry(merged);
|
||||
}
|
||||
|
||||
public ComponentRegistry RegisterComponents(IEnumerable<DesktopComponentDefinition> definitions)
|
||||
{
|
||||
var merged = _definitions.Values.ToList();
|
||||
merged.AddRange(definitions);
|
||||
return new ComponentRegistry(merged);
|
||||
}
|
||||
|
||||
public bool TryGetDefinition(string componentId, out DesktopComponentDefinition definition)
|
||||
{
|
||||
return _definitions.TryGetValue(componentId, out definition!);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public sealed record DesktopComponentRuntimeContext(
|
||||
string ComponentId,
|
||||
string? PlacementId,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentPlacementContextAware
|
||||
{
|
||||
void SetComponentPlacementContext(string componentId, string? placementId);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentRuntimeContextAware
|
||||
{
|
||||
void SetComponentRuntimeContext(DesktopComponentRuntimeContext context);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentSettingsStoreAware
|
||||
{
|
||||
void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore);
|
||||
}
|
||||
@@ -24,6 +24,10 @@
|
||||
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"settings.nav.status_bar": "Status Bar",
|
||||
"settings.nav.weather": "Weather",
|
||||
"settings.nav.region": "Region",
|
||||
"settings.nav.update": "Update",
|
||||
"settings.nav.launcher": "App Launcher",
|
||||
"settings.nav.plugins": "Plugins",
|
||||
"settings.nav.about": "About",
|
||||
"settings.wallpaper.title": "Wallpaper",
|
||||
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
|
||||
@@ -195,6 +198,38 @@
|
||||
"settings.region.timezone_header": "Time Zone",
|
||||
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
|
||||
"settings.region.applied_format": "Language switched to: {0}",
|
||||
"settings.update.title": "Update",
|
||||
"settings.update.current_version_label": "Current Version",
|
||||
"settings.update.latest_version_label": "Latest Release",
|
||||
"settings.update.published_at_label": "Published At",
|
||||
"settings.update.options_header": "Update Options",
|
||||
"settings.update.options_desc": "Configure update checks and release channel.",
|
||||
"settings.update.auto_check_toggle": "Automatically check for updates on startup",
|
||||
"settings.update.include_prerelease_toggle": "Include prerelease versions",
|
||||
"settings.update.channel_label": "Update Channel",
|
||||
"settings.update.channel_stable": "Stable",
|
||||
"settings.update.channel_preview": "Preview",
|
||||
"settings.update.actions_header": "Update Actions",
|
||||
"settings.update.actions_desc": "Check releases, download installer, and start update.",
|
||||
"settings.update.check_button": "Check for Updates",
|
||||
"settings.update.download_install_button": "Download & Install",
|
||||
"settings.update.download_progress_idle": "Download progress: -",
|
||||
"settings.update.download_progress_format": "Download progress: {0:F0}%",
|
||||
"settings.update.status_ready": "Ready to check for updates.",
|
||||
"settings.update.status_channel_changed": "Update channel changed. Please check again.",
|
||||
"settings.update.status_channel_changed_format": "Update channel switched to {0}. Please check again.",
|
||||
"settings.update.status_windows_only": "Automatic installer update is currently available only on Windows.",
|
||||
"settings.update.status_checking": "Checking GitHub releases...",
|
||||
"settings.update.status_check_failed_format": "Update check failed: {0}",
|
||||
"settings.update.status_up_to_date": "You are already on the latest version.",
|
||||
"settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.",
|
||||
"settings.update.status_available_format": "New version {0} is available. Click Download & Install.",
|
||||
"settings.update.status_downloading": "Downloading installer...",
|
||||
"settings.update.status_download_failed_format": "Download failed: {0}",
|
||||
"settings.update.status_launching_installer": "Download complete. Launching installer...",
|
||||
"settings.update.status_installer_missing": "Installer file was not found after download.",
|
||||
"settings.update.status_installer_started": "Installer started. The app will close for update.",
|
||||
"settings.update.status_launch_failed_format": "Failed to start installer: {0}",
|
||||
"settings.about.title": "About",
|
||||
"settings.about.version_format": "Version: {0}",
|
||||
"settings.about.codename_format": "Code Name: {0}",
|
||||
@@ -202,6 +237,18 @@
|
||||
"settings.about.startup_header": "Windows Startup",
|
||||
"settings.about.startup_desc": "Launch the app automatically when signing in to Windows.",
|
||||
"settings.about.startup_toggle": "Launch at Windows sign-in",
|
||||
"settings.about.render_mode_header": "App Rendering Mode",
|
||||
"settings.about.render_mode_desc": "Choose the rendering backend. Restart the app after changing this option. Unsupported modes fall back to software.",
|
||||
"settings.about.render_mode.default": "Default",
|
||||
"settings.about.render_mode.software": "Software",
|
||||
"settings.about.render_mode.angle_egl": "angleEgl",
|
||||
"settings.about.render_mode.wgl": "WGL",
|
||||
"settings.about.render_mode.vulkan": "Vulkan",
|
||||
"settings.about.render_mode.unknown": "Unknown",
|
||||
"settings.about.render_mode.current_label": "Current actual backend",
|
||||
"settings.about.render_mode.current_format": "Current backend: {0}",
|
||||
"settings.about.render_mode.impl_format": "Runtime implementation: {0}",
|
||||
"settings.about.render_mode.impl_unavailable": "Runtime implementation details are unavailable.",
|
||||
"settings.footer": "LanMountainDesktop Settings",
|
||||
"filepicker.title": "Select wallpaper",
|
||||
"filepicker.image_files": "Image files",
|
||||
@@ -210,14 +257,53 @@
|
||||
"common.night": "Night",
|
||||
"common.back": "Back",
|
||||
"common.close": "Close",
|
||||
"common.unknown": "Unknown error",
|
||||
"common.recommended": "Recommended",
|
||||
"common.monet": "Monet",
|
||||
"desktop.page_index_format": "Desktop {0}",
|
||||
"launcher.title": "App Launcher",
|
||||
"launcher.subtitle": "Apps and folders from Windows Start Menu",
|
||||
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
|
||||
"launcher.empty": "No Start Menu entries found.",
|
||||
"launcher.empty_linux": "No Linux desktop entries were found.",
|
||||
"launcher.empty_folder": "This folder is empty.",
|
||||
"launcher.folder_items_format": "{0} apps",
|
||||
"launcher.context.hide_icon": "Hide Icon",
|
||||
"launcher.action.hide": "Hide",
|
||||
"settings.launcher.title": "App Launcher",
|
||||
"settings.launcher.hidden_header": "Hidden Items",
|
||||
"settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.",
|
||||
"settings.launcher.hidden_hint": "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.",
|
||||
"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.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.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}.",
|
||||
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||
"settings.plugins.state.enabled": "Enabled",
|
||||
"settings.plugins.state.enabled_failed": "Enabled / failed to load",
|
||||
"settings.plugins.state.disabled": "Disabled",
|
||||
"settings.plugins.state.loaded": "Loaded",
|
||||
"settings.plugins.state.load_failed": "Load failed",
|
||||
"settings.plugins.toggle_on": "Enabled",
|
||||
"settings.plugins.toggle_off": "Disabled",
|
||||
"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.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}",
|
||||
"button.component_library": "Edit Desktop",
|
||||
"tooltip.component_library": "Edit Desktop",
|
||||
"component_library.title": "Widgets",
|
||||
@@ -231,6 +317,7 @@
|
||||
"component_category.board": "Board",
|
||||
"component_category.media": "Media",
|
||||
"component_category.info": "Info",
|
||||
"component_category.calculator": "Calculator",
|
||||
"component_category.study": "Study",
|
||||
"component.date": "Calendar",
|
||||
"component.month_calendar": "Month Calendar",
|
||||
@@ -248,6 +335,14 @@
|
||||
"component.audio_recorder": "Recorder",
|
||||
"component.daily_poetry": "Daily Poetry",
|
||||
"component.daily_artwork": "Daily Artwork",
|
||||
"component.daily_word": "Daily Word",
|
||||
"component.daily_word_2x2": "Daily Word 2x2",
|
||||
"component.cnr_daily_news": "CNR Headlines",
|
||||
"component.ifeng_news": "iFeng News",
|
||||
"component.bilibili_hot_search": "Bilibili Hot Search",
|
||||
"component.baidu_hot_search": "Baidu Hot Search",
|
||||
"component.stcn24_forum": "STCN 24",
|
||||
"component.exchange_rate_converter": "Exchange Rate Converter",
|
||||
"component.whiteboard": "Blackboard (Portrait)",
|
||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||
"component.browser": "Browser",
|
||||
@@ -280,6 +375,134 @@
|
||||
"artwork.widget.fallback_artist": "Recommendation service unavailable",
|
||||
"artwork.widget.fallback_year": "Try again later",
|
||||
"artwork.widget.unknown_artist": "Unknown artist",
|
||||
"dailyword.widget.loading": "Loading...",
|
||||
"dailyword.widget.loading_word": "daily word",
|
||||
"dailyword.widget.loading_pronunciation": "Fetching pronunciation...",
|
||||
"dailyword.widget.loading_meaning": "Fetching meaning...",
|
||||
"dailyword.widget.loading_example": "Fetching example sentence...",
|
||||
"dailyword.widget.loading_example_translation": "Loading...",
|
||||
"dailyword.widget.fetch_failed": "Daily word fetch failed",
|
||||
"dailyword.widget.fallback_word": "daily word",
|
||||
"dailyword.widget.fallback_pronunciation": "Pronunciation unavailable",
|
||||
"dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.",
|
||||
"dailyword.widget.fallback_example": "Tap the refresh button and try again.",
|
||||
"dailyword.widget.fallback_example_translation": "It will retry when network recovers.",
|
||||
"dailyword2x2.widget.tap_to_show": "Tap to reveal meaning",
|
||||
"cnrnews.widget.loading": "Loading...",
|
||||
"cnrnews.widget.loading_title": "Fetching CNR headlines",
|
||||
"cnrnews.widget.loading_subtitle": "Please wait",
|
||||
"cnrnews.widget.fetch_failed": "News fetch failed",
|
||||
"cnrnews.widget.fallback_title": "CNR news is temporarily unavailable",
|
||||
"cnrnews.widget.fallback_subtitle": "Tap refresh and try again",
|
||||
"cnrnews.widget.hot_label": "Hot",
|
||||
"bilihot.widget.brand": "bilibili hot search",
|
||||
"bilihot.widget.top_right_label": "bilibili热搜",
|
||||
"bilihot.widget.search_entry": "Search",
|
||||
"bilihot.widget.search_placeholder": "Search trending topics",
|
||||
"bilihot.widget.loading": "Loading...",
|
||||
"bilihot.widget.loading_item": "Loading...",
|
||||
"bilihot.widget.fetch_failed": "Hot search fetch failed",
|
||||
"bilihot.widget.fallback_item": "No hot search data",
|
||||
"bilihot.widget.more_hot": "More hot search",
|
||||
"baiduhot.widget.brand": "Baidu Hot Search",
|
||||
"baiduhot.widget.loading": "Loading...",
|
||||
"baiduhot.widget.loading_item": "Loading...",
|
||||
"baiduhot.widget.fetch_failed": "Hot search fetch failed",
|
||||
"baiduhot.widget.fallback_item": "No hot search data",
|
||||
"baiduhot.widget.refresh_tooltip": "Refresh",
|
||||
"ifeng.widget.brand": "iFeng News",
|
||||
"ifeng.widget.loading": "Loading...",
|
||||
"ifeng.widget.loading_item": "Loading...",
|
||||
"ifeng.widget.fetch_failed": "News fetch failed",
|
||||
"ifeng.widget.fallback_item": "No news data",
|
||||
"ifeng.widget.refresh_tooltip": "Refresh",
|
||||
"dailyword.settings.title": "Daily word settings",
|
||||
"dailyword.settings.desc": "Configure auto refresh and refresh interval.",
|
||||
"dailyword.settings.auto_refresh_label": "Auto refresh",
|
||||
"dailyword.settings.auto_refresh_enabled": "Enable auto refresh",
|
||||
"dailyword.settings.frequency_label": "Refresh interval",
|
||||
"bilihot.settings.title": "Bilibili hot search settings",
|
||||
"bilihot.settings.desc": "Configure auto refresh and refresh interval.",
|
||||
"bilihot.settings.auto_refresh_label": "Auto refresh",
|
||||
"bilihot.settings.auto_refresh_enabled": "Enable auto refresh",
|
||||
"bilihot.settings.frequency_label": "Refresh interval",
|
||||
"baiduhot.settings.title": "Baidu hot search settings",
|
||||
"baiduhot.settings.desc": "Configure source, auto refresh and refresh interval.",
|
||||
"baiduhot.settings.source_label": "Data source",
|
||||
"baiduhot.settings.source_official": "Official Source",
|
||||
"baiduhot.settings.source_rss": "Third-party RSS",
|
||||
"baiduhot.settings.auto_refresh_label": "Auto refresh",
|
||||
"baiduhot.settings.auto_refresh_enabled": "Enable auto refresh",
|
||||
"baiduhot.settings.frequency_label": "Refresh interval",
|
||||
"ifeng.settings.title": "iFeng news settings",
|
||||
"ifeng.settings.desc": "Configure channel, auto refresh and refresh interval.",
|
||||
"ifeng.settings.channel_label": "News channel",
|
||||
"ifeng.settings.channel_comprehensive": "Comprehensive",
|
||||
"ifeng.settings.channel_mainland": "China Mainland",
|
||||
"ifeng.settings.channel_taiwan": "Taiwan",
|
||||
"ifeng.settings.auto_refresh_label": "Auto refresh",
|
||||
"ifeng.settings.auto_refresh_enabled": "Enable auto refresh",
|
||||
"ifeng.settings.frequency_label": "Refresh interval",
|
||||
"refresh.frequency.5m": "5 minutes",
|
||||
"refresh.frequency.10m": "10 minutes",
|
||||
"refresh.frequency.12m": "12 minutes",
|
||||
"refresh.frequency.15m": "15 minutes",
|
||||
"refresh.frequency.20m": "20 minutes",
|
||||
"refresh.frequency.30m": "30 minutes",
|
||||
"refresh.frequency.40m": "40 minutes",
|
||||
"refresh.frequency.1h": "1 hour",
|
||||
"refresh.frequency.3h": "3 hours",
|
||||
"refresh.frequency.6h": "6 hours",
|
||||
"refresh.frequency.12h": "12 hours",
|
||||
"refresh.frequency.24h": "24 hours",
|
||||
"weather.widget.settings.title": "Weather widget settings",
|
||||
"weather.widget.settings.desc": "Configure auto refresh and refresh interval for all weather widgets.",
|
||||
"weather.widget.settings.auto_refresh_label": "Auto refresh",
|
||||
"weather.widget.settings.auto_refresh_enabled": "Enable auto refresh",
|
||||
"weather.widget.settings.frequency_label": "Refresh interval",
|
||||
"weather.widget.settings.frequency_10m": "10 minutes",
|
||||
"weather.widget.settings.frequency_12m": "12 minutes",
|
||||
"weather.widget.settings.frequency_15m": "15 minutes",
|
||||
"weather.widget.settings.frequency_30m": "30 minutes",
|
||||
"weather.widget.settings.frequency_1h": "1 hour",
|
||||
"weather.widget.settings.frequency_3h": "3 hours",
|
||||
"stcn24.widget.loading": "Loading...",
|
||||
"stcn24.widget.loading_item": "Loading...",
|
||||
"stcn24.widget.fetch_failed": "Forum posts fetch failed",
|
||||
"stcn24.widget.fallback_item": "No posts",
|
||||
"stcn24.settings.title": "STCN 24 settings",
|
||||
"stcn24.settings.desc": "Configure information source, auto refresh and refresh interval.",
|
||||
"stcn24.settings.source_label": "Information source",
|
||||
"stcn24.settings.source_latest_created": "Latest posts",
|
||||
"stcn24.settings.source_latest_activity": "Latest activity",
|
||||
"stcn24.settings.source_most_replies": "Most replies",
|
||||
"stcn24.settings.source_earliest_created": "Earliest posts",
|
||||
"stcn24.settings.source_earliest_activity": "Earliest activity",
|
||||
"stcn24.settings.source_least_replies": "Least replies",
|
||||
"stcn24.settings.source_frontpage_latest": "Frontpage latest",
|
||||
"stcn24.settings.source_frontpage_earliest": "Frontpage earliest",
|
||||
"stcn24.settings.auto_refresh_label": "Auto refresh",
|
||||
"stcn24.settings.auto_refresh_enabled": "Enable auto refresh",
|
||||
"stcn24.settings.frequency_label": "Refresh interval",
|
||||
"stcn24.settings.frequency_5m": "5 minutes",
|
||||
"stcn24.settings.frequency_10m": "10 minutes",
|
||||
"stcn24.settings.frequency_20m": "20 minutes",
|
||||
"stcn24.settings.frequency_30m": "30 minutes",
|
||||
"stcn24.settings.frequency_1h": "1 hour",
|
||||
"stcn24.settings.frequency_3h": "3 hours",
|
||||
"exchange.widget.loading": "Loading exchange rates...",
|
||||
"exchange.widget.fetch_failed": "Exchange rate fetch failed",
|
||||
"cnrnews.settings.title": "CNR Settings",
|
||||
"cnrnews.settings.desc": "Configure auto-rotation and refresh interval.",
|
||||
"cnrnews.settings.auto_rotate_label": "Auto-rotation",
|
||||
"cnrnews.settings.auto_rotate_enabled": "Enable auto-rotation",
|
||||
"cnrnews.settings.frequency_label": "Rotation interval",
|
||||
"cnrnews.settings.frequency_5m": "5 minutes",
|
||||
"cnrnews.settings.frequency_10m": "10 minutes",
|
||||
"cnrnews.settings.frequency_40m": "40 minutes",
|
||||
"cnrnews.settings.frequency_1h": "1 hour",
|
||||
"cnrnews.settings.frequency_12h": "12 hours",
|
||||
"cnrnews.settings.frequency_24h": "24 hours",
|
||||
"artwork.settings.title": "Daily Artwork Settings",
|
||||
"artwork.settings.desc": "Switch the data source used by Daily Artwork.",
|
||||
"artwork.settings.source_label": "Mirror Source",
|
||||
@@ -427,3 +650,5 @@
|
||||
"placement.center": "Center",
|
||||
"placement.tile": "Tile"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"settings.nav.status_bar": "状态栏",
|
||||
"settings.nav.weather": "天气",
|
||||
"settings.nav.region": "地区",
|
||||
"settings.nav.update": "更新",
|
||||
"settings.nav.launcher": "应用启动台",
|
||||
"settings.nav.plugins": "插件",
|
||||
"settings.nav.about": "关于",
|
||||
"settings.wallpaper.title": "壁纸",
|
||||
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
|
||||
@@ -195,6 +198,38 @@
|
||||
"settings.region.timezone_header": "时区",
|
||||
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
|
||||
"settings.region.applied_format": "语言已切换为:{0}",
|
||||
"settings.update.title": "更新",
|
||||
"settings.update.current_version_label": "当前版本",
|
||||
"settings.update.latest_version_label": "最新发布",
|
||||
"settings.update.published_at_label": "发布时间",
|
||||
"settings.update.options_header": "更新选项",
|
||||
"settings.update.options_desc": "配置更新检查与发布通道。",
|
||||
"settings.update.auto_check_toggle": "启动时自动检查更新",
|
||||
"settings.update.include_prerelease_toggle": "包含预发布版本",
|
||||
"settings.update.channel_label": "更新通道",
|
||||
"settings.update.channel_stable": "正式版",
|
||||
"settings.update.channel_preview": "预览版",
|
||||
"settings.update.actions_header": "更新操作",
|
||||
"settings.update.actions_desc": "检查发布、下载安装包并启动更新。",
|
||||
"settings.update.check_button": "检查更新",
|
||||
"settings.update.download_install_button": "下载并安装",
|
||||
"settings.update.download_progress_idle": "下载进度:-",
|
||||
"settings.update.download_progress_format": "下载进度:{0:F0}%",
|
||||
"settings.update.status_ready": "可开始检查更新。",
|
||||
"settings.update.status_channel_changed": "更新通道已变更,请重新检查更新。",
|
||||
"settings.update.status_channel_changed_format": "更新通道已切换为 {0},请重新检查更新。",
|
||||
"settings.update.status_windows_only": "自动安装包更新当前仅支持 Windows。",
|
||||
"settings.update.status_checking": "正在检查 GitHub Release...",
|
||||
"settings.update.status_check_failed_format": "检查更新失败:{0}",
|
||||
"settings.update.status_up_to_date": "当前已是最新版本。",
|
||||
"settings.update.status_asset_missing": "发现新版本,但未找到兼容的安装包。",
|
||||
"settings.update.status_available_format": "发现新版本 {0},点击“下载并安装”继续。",
|
||||
"settings.update.status_downloading": "正在下载安装包...",
|
||||
"settings.update.status_download_failed_format": "下载失败:{0}",
|
||||
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
|
||||
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
|
||||
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
|
||||
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
|
||||
"settings.about.title": "关于",
|
||||
"settings.about.version_format": "版本号: {0}",
|
||||
"settings.about.codename_format": "版本代号: {0}",
|
||||
@@ -202,6 +237,18 @@
|
||||
"settings.about.startup_header": "Windows 自启动",
|
||||
"settings.about.startup_desc": "在登录 Windows 时自动启动应用。",
|
||||
"settings.about.startup_toggle": "登录 Windows 时启动",
|
||||
"settings.about.render_mode_header": "应用渲染模式",
|
||||
"settings.about.render_mode_desc": "选择应用渲染后端。更改后需要重启应用生效。不支持的模式会回退到软件渲染。",
|
||||
"settings.about.render_mode.default": "默认",
|
||||
"settings.about.render_mode.software": "软件",
|
||||
"settings.about.render_mode.angle_egl": "angleEgl",
|
||||
"settings.about.render_mode.wgl": "WGL",
|
||||
"settings.about.render_mode.vulkan": "Vulkan",
|
||||
"settings.about.render_mode.unknown": "未知",
|
||||
"settings.about.render_mode.current_label": "当前实际渲染后端",
|
||||
"settings.about.render_mode.current_format": "当前后端:{0}",
|
||||
"settings.about.render_mode.impl_format": "运行时实现:{0}",
|
||||
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
|
||||
"settings.footer": "LanMountainDesktop 设置",
|
||||
"filepicker.title": "选择壁纸",
|
||||
"filepicker.image_files": "图片文件",
|
||||
@@ -210,14 +257,53 @@
|
||||
"common.night": "夜间",
|
||||
"common.back": "返回",
|
||||
"common.close": "关闭",
|
||||
"common.unknown": "未知错误",
|
||||
"common.recommended": "推荐",
|
||||
"common.monet": "莫奈",
|
||||
"desktop.page_index_format": "桌面 {0}",
|
||||
"launcher.title": "应用启动台",
|
||||
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
|
||||
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
|
||||
"launcher.empty": "未找到开始菜单条目。",
|
||||
"launcher.empty_linux": "未找到 Linux .desktop 应用条目。",
|
||||
"launcher.empty_folder": "此文件夹为空。",
|
||||
"launcher.folder_items_format": "{0} 个应用",
|
||||
"launcher.context.hide_icon": "隐藏图标",
|
||||
"launcher.action.hide": "隐藏",
|
||||
"settings.launcher.title": "应用启动台",
|
||||
"settings.launcher.hidden_header": "已隐藏项目",
|
||||
"settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。",
|
||||
"settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。",
|
||||
"settings.launcher.hidden_empty": "暂无隐藏项目。",
|
||||
"settings.launcher.hidden_type_folder": "文件夹",
|
||||
"settings.launcher.hidden_type_shortcut": "快捷方式",
|
||||
"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.empty": "未找到插件。",
|
||||
"settings.plugins.runtime_unavailable": "插件运行时不可用。",
|
||||
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
|
||||
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||
"settings.plugins.state.enabled": "已启用",
|
||||
"settings.plugins.state.enabled_failed": "已启用 / 加载失败",
|
||||
"settings.plugins.state.disabled": "已禁用",
|
||||
"settings.plugins.state.loaded": "已加载",
|
||||
"settings.plugins.state.load_failed": "加载失败",
|
||||
"settings.plugins.toggle_on": "启用",
|
||||
"settings.plugins.toggle_off": "禁用",
|
||||
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
||||
"settings.plugins.toggle_state_enabled": "启用",
|
||||
"settings.plugins.toggle_state_disabled": "禁用",
|
||||
"settings.plugins.source_package": ".laapp 包",
|
||||
"settings.plugins.source_manifest": "散装清单",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
||||
"button.component_library": "桌面编辑",
|
||||
"tooltip.component_library": "桌面编辑",
|
||||
"component_library.title": "桌面编辑",
|
||||
@@ -231,6 +317,7 @@
|
||||
"component_category.board": "白板",
|
||||
"component_category.media": "媒体",
|
||||
"component_category.info": "信息推荐",
|
||||
"component_category.calculator": "计算器",
|
||||
"component_category.study": "自习",
|
||||
"component.date": "日历",
|
||||
"component.month_calendar": "月历",
|
||||
@@ -248,6 +335,14 @@
|
||||
"component.audio_recorder": "录音",
|
||||
"component.daily_poetry": "每日诗词",
|
||||
"component.daily_artwork": "每日名画",
|
||||
"component.daily_word": "每日单词",
|
||||
"component.daily_word_2x2": "每日单词 2x2",
|
||||
"component.cnr_daily_news": "央广网头条",
|
||||
"component.ifeng_news": "凤凰网新闻",
|
||||
"component.bilibili_hot_search": "B站热搜",
|
||||
"component.baidu_hot_search": "百度热搜",
|
||||
"component.stcn24_forum": "STCN 24",
|
||||
"component.exchange_rate_converter": "汇率换算",
|
||||
"component.whiteboard": "竖向小黑板",
|
||||
"component.blackboard_landscape": "横向小黑板",
|
||||
"component.browser": "浏览器",
|
||||
@@ -280,6 +375,134 @@
|
||||
"artwork.widget.fallback_artist": "推荐服务不可用",
|
||||
"artwork.widget.fallback_year": "稍后重试",
|
||||
"artwork.widget.unknown_artist": "未知作者",
|
||||
"dailyword.widget.loading": "加载中...",
|
||||
"dailyword.widget.loading_word": "每日单词",
|
||||
"dailyword.widget.loading_pronunciation": "正在获取发音",
|
||||
"dailyword.widget.loading_meaning": "正在获取释义",
|
||||
"dailyword.widget.loading_example": "正在获取例句",
|
||||
"dailyword.widget.loading_example_translation": "加载中",
|
||||
"dailyword.widget.fetch_failed": "每日单词获取失败",
|
||||
"dailyword.widget.fallback_word": "每日单词",
|
||||
"dailyword.widget.fallback_pronunciation": "发音暂不可用",
|
||||
"dailyword.widget.fallback_meaning": "有道词典暂不可用",
|
||||
"dailyword.widget.fallback_example": "请点击右上角刷新重试",
|
||||
"dailyword.widget.fallback_example_translation": "网络恢复后将自动更新",
|
||||
"dailyword2x2.widget.tap_to_show": "点击查看释义",
|
||||
"cnrnews.widget.loading": "加载中...",
|
||||
"cnrnews.widget.loading_title": "正在获取新闻热点",
|
||||
"cnrnews.widget.loading_subtitle": "请稍候",
|
||||
"cnrnews.widget.fetch_failed": "新闻获取失败",
|
||||
"cnrnews.widget.fallback_title": "央广网新闻暂不可用",
|
||||
"cnrnews.widget.fallback_subtitle": "点击右上角稍后重试",
|
||||
"cnrnews.widget.hot_label": "热点",
|
||||
"bilihot.widget.brand": "bilibili 热搜",
|
||||
"bilihot.widget.top_right_label": "bilibili热搜",
|
||||
"bilihot.widget.search_entry": "搜索",
|
||||
"bilihot.widget.search_placeholder": "搜索热词",
|
||||
"bilihot.widget.loading": "加载中...",
|
||||
"bilihot.widget.loading_item": "加载中...",
|
||||
"bilihot.widget.fetch_failed": "热搜获取失败",
|
||||
"bilihot.widget.fallback_item": "暂无热搜",
|
||||
"bilihot.widget.more_hot": "更多热搜",
|
||||
"baiduhot.widget.brand": "百度热搜",
|
||||
"baiduhot.widget.loading": "加载中...",
|
||||
"baiduhot.widget.loading_item": "加载中...",
|
||||
"baiduhot.widget.fetch_failed": "热搜获取失败",
|
||||
"baiduhot.widget.fallback_item": "暂无热搜",
|
||||
"baiduhot.widget.refresh_tooltip": "刷新",
|
||||
"ifeng.widget.brand": "凤凰网新闻",
|
||||
"ifeng.widget.loading": "加载中...",
|
||||
"ifeng.widget.loading_item": "加载中...",
|
||||
"ifeng.widget.fetch_failed": "新闻获取失败",
|
||||
"ifeng.widget.fallback_item": "暂无新闻",
|
||||
"ifeng.widget.refresh_tooltip": "刷新",
|
||||
"dailyword.settings.title": "每日单词设置",
|
||||
"dailyword.settings.desc": "配置自动刷新开关与刷新频率。",
|
||||
"dailyword.settings.auto_refresh_label": "自动刷新",
|
||||
"dailyword.settings.auto_refresh_enabled": "启用自动刷新",
|
||||
"dailyword.settings.frequency_label": "刷新频率",
|
||||
"bilihot.settings.title": "B站热搜设置",
|
||||
"bilihot.settings.desc": "配置自动刷新开关与刷新频率。",
|
||||
"bilihot.settings.auto_refresh_label": "自动刷新",
|
||||
"bilihot.settings.auto_refresh_enabled": "启用自动刷新",
|
||||
"bilihot.settings.frequency_label": "刷新频率",
|
||||
"baiduhot.settings.title": "百度热搜设置",
|
||||
"baiduhot.settings.desc": "配置数据源、自动刷新开关与刷新频率。",
|
||||
"baiduhot.settings.source_label": "数据源",
|
||||
"baiduhot.settings.source_official": "百度官方源",
|
||||
"baiduhot.settings.source_rss": "第三方 RSS 源",
|
||||
"baiduhot.settings.auto_refresh_label": "自动刷新",
|
||||
"baiduhot.settings.auto_refresh_enabled": "启用自动刷新",
|
||||
"baiduhot.settings.frequency_label": "刷新频率",
|
||||
"ifeng.settings.title": "凤凰网新闻设置",
|
||||
"ifeng.settings.desc": "配置频道、自动刷新开关与刷新频率。",
|
||||
"ifeng.settings.channel_label": "新闻频道",
|
||||
"ifeng.settings.channel_comprehensive": "综合",
|
||||
"ifeng.settings.channel_mainland": "中国大陆",
|
||||
"ifeng.settings.channel_taiwan": "台湾",
|
||||
"ifeng.settings.auto_refresh_label": "自动刷新",
|
||||
"ifeng.settings.auto_refresh_enabled": "启用自动刷新",
|
||||
"ifeng.settings.frequency_label": "刷新频率",
|
||||
"refresh.frequency.5m": "5 分钟",
|
||||
"refresh.frequency.10m": "10 分钟",
|
||||
"refresh.frequency.12m": "12 分钟",
|
||||
"refresh.frequency.15m": "15 分钟",
|
||||
"refresh.frequency.20m": "20 分钟",
|
||||
"refresh.frequency.30m": "30 分钟",
|
||||
"refresh.frequency.40m": "40 分钟",
|
||||
"refresh.frequency.1h": "1 小时",
|
||||
"refresh.frequency.3h": "3 小时",
|
||||
"refresh.frequency.6h": "6 小时",
|
||||
"refresh.frequency.12h": "12 小时",
|
||||
"refresh.frequency.24h": "24 小时",
|
||||
"weather.widget.settings.title": "天气组件设置",
|
||||
"weather.widget.settings.desc": "配置全部天气组件的自动刷新开关与刷新频率。",
|
||||
"weather.widget.settings.auto_refresh_label": "自动刷新",
|
||||
"weather.widget.settings.auto_refresh_enabled": "启用自动刷新",
|
||||
"weather.widget.settings.frequency_label": "刷新频率",
|
||||
"weather.widget.settings.frequency_10m": "10 分钟",
|
||||
"weather.widget.settings.frequency_12m": "12 分钟",
|
||||
"weather.widget.settings.frequency_15m": "15 分钟",
|
||||
"weather.widget.settings.frequency_30m": "30 分钟",
|
||||
"weather.widget.settings.frequency_1h": "1 小时",
|
||||
"weather.widget.settings.frequency_3h": "3 小时",
|
||||
"stcn24.widget.loading": "加载中...",
|
||||
"stcn24.widget.loading_item": "加载中...",
|
||||
"stcn24.widget.fetch_failed": "帖子获取失败",
|
||||
"stcn24.widget.fallback_item": "暂无帖子",
|
||||
"stcn24.settings.title": "STCN 24 设置",
|
||||
"stcn24.settings.desc": "配置信息源、自动刷新开关与刷新频率。",
|
||||
"stcn24.settings.source_label": "信息源",
|
||||
"stcn24.settings.source_latest_created": "最新发布",
|
||||
"stcn24.settings.source_latest_activity": "最新回复",
|
||||
"stcn24.settings.source_most_replies": "回复最多",
|
||||
"stcn24.settings.source_earliest_created": "最早发布",
|
||||
"stcn24.settings.source_earliest_activity": "最早回复",
|
||||
"stcn24.settings.source_least_replies": "回复最少",
|
||||
"stcn24.settings.source_frontpage_latest": "前台推荐(新)",
|
||||
"stcn24.settings.source_frontpage_earliest": "前台推荐(旧)",
|
||||
"stcn24.settings.auto_refresh_label": "自动刷新",
|
||||
"stcn24.settings.auto_refresh_enabled": "启用自动刷新",
|
||||
"stcn24.settings.frequency_label": "刷新频率",
|
||||
"stcn24.settings.frequency_5m": "5 分钟",
|
||||
"stcn24.settings.frequency_10m": "10 分钟",
|
||||
"stcn24.settings.frequency_20m": "20 分钟",
|
||||
"stcn24.settings.frequency_30m": "30 分钟",
|
||||
"stcn24.settings.frequency_1h": "1 小时",
|
||||
"stcn24.settings.frequency_3h": "3 小时",
|
||||
"exchange.widget.loading": "正在加载汇率...",
|
||||
"exchange.widget.fetch_failed": "汇率获取失败",
|
||||
"cnrnews.settings.title": "央广网设置",
|
||||
"cnrnews.settings.desc": "配置新闻自动轮换与刷新频率。",
|
||||
"cnrnews.settings.auto_rotate_label": "自动轮换",
|
||||
"cnrnews.settings.auto_rotate_enabled": "启用自动轮换",
|
||||
"cnrnews.settings.frequency_label": "轮换频率",
|
||||
"cnrnews.settings.frequency_5m": "5 分钟",
|
||||
"cnrnews.settings.frequency_10m": "10 分钟",
|
||||
"cnrnews.settings.frequency_40m": "40 分钟",
|
||||
"cnrnews.settings.frequency_1h": "1 小时",
|
||||
"cnrnews.settings.frequency_12h": "12 小时",
|
||||
"cnrnews.settings.frequency_24h": "24 小时",
|
||||
"artwork.settings.title": "每日图片设置",
|
||||
"artwork.settings.desc": "切换每日图片的数据源。",
|
||||
"artwork.settings.source_label": "镜像源",
|
||||
@@ -427,3 +650,5 @@
|
||||
"placement.center": "居中",
|
||||
"placement.tile": "平铺"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public int SettingsTabIndex { get; set; } = 0;
|
||||
|
||||
public string? SettingsTabTag { get; set; }
|
||||
|
||||
public string LanguageCode { get; set; } = "zh-CN";
|
||||
|
||||
public string? TimeZoneId { get; set; }
|
||||
@@ -44,10 +46,16 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool WeatherNoTlsRequests { get; set; }
|
||||
|
||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||
|
||||
public bool AutoStartWithWindows { get; set; }
|
||||
|
||||
public string AppRenderMode { get; set; } = "Default";
|
||||
|
||||
public bool AutoCheckUpdates { get; set; } = true;
|
||||
|
||||
public bool IncludePrereleaseUpdates { get; set; }
|
||||
|
||||
public string UpdateChannel { get; set; } = string.Empty;
|
||||
|
||||
public List<string> TopStatusComponentIds { get; set; } = [];
|
||||
|
||||
public List<string> PinnedTaskbarActions { get; set; } =
|
||||
@@ -66,31 +74,7 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||
|
||||
public int DesktopPageCount { get; set; } = 1;
|
||||
|
||||
public int CurrentDesktopSurfaceIndex { get; set; } = 0;
|
||||
|
||||
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
|
||||
|
||||
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
||||
|
||||
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
||||
|
||||
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
|
||||
|
||||
public bool StudyEnvironmentShowDbfs { get; set; }
|
||||
|
||||
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
|
||||
public string DesktopClockSecondHandMode { get; set; } = "Tick";
|
||||
|
||||
public List<string> WorldClockTimeZoneIds { get; set; } =
|
||||
[
|
||||
"China Standard Time",
|
||||
"GMT Standard Time",
|
||||
"AUS Eastern Standard Time",
|
||||
"Eastern Standard Time"
|
||||
];
|
||||
public string WorldClockSecondHandMode { get; set; } = "Tick";
|
||||
public List<string> DisabledPluginIds { get; set; } = [];
|
||||
|
||||
public AppSettingsSnapshot Clone()
|
||||
{
|
||||
@@ -102,53 +86,8 @@ public sealed class AppSettingsSnapshot
|
||||
clone.PinnedTaskbarActions = PinnedTaskbarActions is { Count: > 0 }
|
||||
? new List<string>(PinnedTaskbarActions)
|
||||
: [];
|
||||
|
||||
var placements = new List<DesktopComponentPlacementSnapshot>(DesktopComponentPlacements?.Count ?? 0);
|
||||
if (DesktopComponentPlacements is not null)
|
||||
{
|
||||
foreach (var placement in DesktopComponentPlacements)
|
||||
{
|
||||
if (placement is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
placements.Add(new DesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placement.PlacementId,
|
||||
PageIndex = placement.PageIndex,
|
||||
ComponentId = placement.ComponentId,
|
||||
Row = placement.Row,
|
||||
Column = placement.Column,
|
||||
WidthCells = placement.WidthCells,
|
||||
HeightCells = placement.HeightCells
|
||||
});
|
||||
}
|
||||
}
|
||||
clone.DesktopComponentPlacements = placements;
|
||||
|
||||
var schedules = new List<ImportedClassScheduleSnapshot>(ImportedClassSchedules?.Count ?? 0);
|
||||
if (ImportedClassSchedules is not null)
|
||||
{
|
||||
foreach (var schedule in ImportedClassSchedules)
|
||||
{
|
||||
if (schedule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
schedules.Add(new ImportedClassScheduleSnapshot
|
||||
{
|
||||
Id = schedule.Id,
|
||||
DisplayName = schedule.DisplayName,
|
||||
FilePath = schedule.FilePath
|
||||
});
|
||||
}
|
||||
}
|
||||
clone.ImportedClassSchedules = schedules;
|
||||
|
||||
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
|
||||
? new List<string>(WorldClockTimeZoneIds)
|
||||
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
|
||||
? new List<string>(DisabledPluginIds)
|
||||
: [];
|
||||
|
||||
return clone;
|
||||
|
||||
19
LanMountainDesktop/Models/BaiduHotSearchSourceTypes.cs
Normal file
19
LanMountainDesktop/Models/BaiduHotSearchSourceTypes.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public static class BaiduHotSearchSourceTypes
|
||||
{
|
||||
public const string Official = "Official";
|
||||
public const string ThirdPartyRss = "ThirdPartyRss";
|
||||
|
||||
public static string Normalize(string? sourceType)
|
||||
{
|
||||
if (string.Equals(sourceType, ThirdPartyRss, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ThirdPartyRss;
|
||||
}
|
||||
|
||||
return Official;
|
||||
}
|
||||
}
|
||||
95
LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
Normal file
95
LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public sealed class ComponentSettingsSnapshot
|
||||
{
|
||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||
|
||||
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
||||
|
||||
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
||||
|
||||
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
|
||||
|
||||
public bool StudyEnvironmentShowDbfs { get; set; }
|
||||
|
||||
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
|
||||
|
||||
public string DesktopClockSecondHandMode { get; set; } = "Tick";
|
||||
|
||||
public List<string> WorldClockTimeZoneIds { get; set; } =
|
||||
[
|
||||
"China Standard Time",
|
||||
"GMT Standard Time",
|
||||
"AUS Eastern Standard Time",
|
||||
"Eastern Standard Time"
|
||||
];
|
||||
|
||||
public string WorldClockSecondHandMode { get; set; } = "Tick";
|
||||
|
||||
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
|
||||
|
||||
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
|
||||
|
||||
public bool IfengNewsAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20;
|
||||
|
||||
public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive;
|
||||
|
||||
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
|
||||
|
||||
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
|
||||
|
||||
public bool BaiduHotSearchAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
|
||||
|
||||
public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official;
|
||||
|
||||
public bool WeatherAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
|
||||
|
||||
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
|
||||
|
||||
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
||||
|
||||
public ComponentSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||
|
||||
var schedules = new List<ImportedClassScheduleSnapshot>(ImportedClassSchedules?.Count ?? 0);
|
||||
if (ImportedClassSchedules is not null)
|
||||
{
|
||||
foreach (var schedule in ImportedClassSchedules)
|
||||
{
|
||||
if (schedule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
schedules.Add(new ImportedClassScheduleSnapshot
|
||||
{
|
||||
Id = schedule.Id,
|
||||
DisplayName = schedule.DisplayName,
|
||||
FilePath = schedule.FilePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clone.ImportedClassSchedules = schedules;
|
||||
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
|
||||
? new List<string>(WorldClockTimeZoneIds)
|
||||
: [];
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
43
LanMountainDesktop/Models/DesktopLayoutSettingsSnapshot.cs
Normal file
43
LanMountainDesktop/Models/DesktopLayoutSettingsSnapshot.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public sealed class DesktopLayoutSettingsSnapshot
|
||||
{
|
||||
public int DesktopPageCount { get; set; } = 1;
|
||||
|
||||
public int CurrentDesktopSurfaceIndex { get; set; }
|
||||
|
||||
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
|
||||
|
||||
public DesktopLayoutSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (DesktopLayoutSettingsSnapshot)MemberwiseClone();
|
||||
var placements = new List<DesktopComponentPlacementSnapshot>(DesktopComponentPlacements?.Count ?? 0);
|
||||
|
||||
if (DesktopComponentPlacements is not null)
|
||||
{
|
||||
foreach (var placement in DesktopComponentPlacements)
|
||||
{
|
||||
if (placement is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
placements.Add(new DesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placement.PlacementId,
|
||||
PageIndex = placement.PageIndex,
|
||||
ComponentId = placement.ComponentId,
|
||||
Row = placement.Row,
|
||||
Column = placement.Column,
|
||||
WidthCells = placement.WidthCells,
|
||||
HeightCells = placement.HeightCells
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clone.DesktopComponentPlacements = placements;
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
32
LanMountainDesktop/Models/IfengNewsChannelTypes.cs
Normal file
32
LanMountainDesktop/Models/IfengNewsChannelTypes.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public static class IfengNewsChannelTypes
|
||||
{
|
||||
public const string Comprehensive = "Comprehensive";
|
||||
public const string Mainland = "Mainland";
|
||||
public const string Taiwan = "Taiwan";
|
||||
|
||||
public static IReadOnlyList<string> SupportedValues { get; } =
|
||||
[
|
||||
Comprehensive,
|
||||
Mainland,
|
||||
Taiwan
|
||||
];
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
var candidate = value?.Trim() ?? string.Empty;
|
||||
foreach (var supported in SupportedValues)
|
||||
{
|
||||
if (string.Equals(candidate, supported, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return supported;
|
||||
}
|
||||
}
|
||||
|
||||
return Comprehensive;
|
||||
}
|
||||
}
|
||||
22
LanMountainDesktop/Models/LauncherSettingsSnapshot.cs
Normal file
22
LanMountainDesktop/Models/LauncherSettingsSnapshot.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public sealed class LauncherSettingsSnapshot
|
||||
{
|
||||
public List<string> HiddenLauncherFolderPaths { get; set; } = [];
|
||||
|
||||
public List<string> HiddenLauncherAppPaths { get; set; } = [];
|
||||
|
||||
public LauncherSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (LauncherSettingsSnapshot)MemberwiseClone();
|
||||
clone.HiddenLauncherFolderPaths = HiddenLauncherFolderPaths is { Count: > 0 }
|
||||
? new List<string>(HiddenLauncherFolderPaths)
|
||||
: [];
|
||||
clone.HiddenLauncherAppPaths = HiddenLauncherAppPaths is { Count: > 0 }
|
||||
? new List<string>(HiddenLauncherAppPaths)
|
||||
: [];
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
@@ -20,3 +21,77 @@ public sealed record DailyPoetrySnapshot(
|
||||
string? Author,
|
||||
string? Category,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record DailyNewsItemSnapshot(
|
||||
string Title,
|
||||
string? Summary,
|
||||
string Url,
|
||||
string? ImageUrl,
|
||||
string? PublishTime);
|
||||
|
||||
public sealed record DailyNewsSnapshot(
|
||||
string Provider,
|
||||
string Source,
|
||||
IReadOnlyList<DailyNewsItemSnapshot> Items,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record BilibiliHotSearchItemSnapshot(
|
||||
string Title,
|
||||
string Keyword,
|
||||
string Url,
|
||||
long? HeatScore,
|
||||
bool HasHotTag,
|
||||
string? IconUrl);
|
||||
|
||||
public sealed record BilibiliHotSearchSnapshot(
|
||||
string Provider,
|
||||
string Source,
|
||||
string SearchPlaceholder,
|
||||
string SearchUrl,
|
||||
string MoreHotUrl,
|
||||
IReadOnlyList<BilibiliHotSearchItemSnapshot> Items,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record BaiduHotSearchItemSnapshot(
|
||||
string Title,
|
||||
string Url,
|
||||
long? HeatScore);
|
||||
|
||||
public sealed record BaiduHotSearchSnapshot(
|
||||
string Provider,
|
||||
string Source,
|
||||
string BoardUrl,
|
||||
IReadOnlyList<BaiduHotSearchItemSnapshot> Items,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record DailyWordSnapshot(
|
||||
string Provider,
|
||||
string Word,
|
||||
string? UkPronunciation,
|
||||
string? UsPronunciation,
|
||||
string Meaning,
|
||||
string? ExampleSentence,
|
||||
string? ExampleTranslation,
|
||||
string? SourceUrl,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record ExchangeRateSnapshot(
|
||||
string Provider,
|
||||
string Source,
|
||||
string BaseCurrency,
|
||||
string TargetCurrency,
|
||||
decimal Rate,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record Stcn24ForumPostItemSnapshot(
|
||||
string Title,
|
||||
string Url,
|
||||
string? AuthorDisplayName,
|
||||
string? AuthorAvatarUrl,
|
||||
DateTimeOffset? CreatedAt);
|
||||
|
||||
public sealed record Stcn24ForumPostsSnapshot(
|
||||
string Provider,
|
||||
string Source,
|
||||
IReadOnlyList<Stcn24ForumPostItemSnapshot> Items,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
74
LanMountainDesktop/Models/RefreshIntervalCatalog.cs
Normal file
74
LanMountainDesktop/Models/RefreshIntervalCatalog.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public static class RefreshIntervalCatalog
|
||||
{
|
||||
public static IReadOnlyList<int> SupportedIntervalsMinutes { get; } =
|
||||
[
|
||||
5,
|
||||
10,
|
||||
12,
|
||||
15,
|
||||
20,
|
||||
30,
|
||||
40,
|
||||
60,
|
||||
180,
|
||||
360,
|
||||
720,
|
||||
1440
|
||||
];
|
||||
|
||||
public static int Normalize(int minutes, int fallbackMinutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return fallbackMinutes;
|
||||
}
|
||||
|
||||
if (SupportedIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(fallbackMinutes);
|
||||
}
|
||||
|
||||
public static string ToLocalizationKeySuffix(int minutes)
|
||||
{
|
||||
return minutes switch
|
||||
{
|
||||
5 => "5m",
|
||||
10 => "10m",
|
||||
12 => "12m",
|
||||
15 => "15m",
|
||||
20 => "20m",
|
||||
30 => "30m",
|
||||
40 => "40m",
|
||||
60 => "1h",
|
||||
180 => "3h",
|
||||
360 => "6h",
|
||||
720 => "12h",
|
||||
1440 => "24h",
|
||||
_ => $"{minutes}m"
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToEnglishFallbackLabel(int minutes)
|
||||
{
|
||||
return minutes switch
|
||||
{
|
||||
60 => "1 hour",
|
||||
180 => "3 hours",
|
||||
360 => "6 hours",
|
||||
720 => "12 hours",
|
||||
1440 => "24 hours",
|
||||
_ => $"{minutes} min"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace LanMountainDesktop.Models;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public sealed class StartMenuAppEntry
|
||||
{
|
||||
@@ -9,4 +11,10 @@ public sealed class StartMenuAppEntry
|
||||
public required string RelativePath { get; init; }
|
||||
|
||||
public byte[]? IconPngBytes { get; init; }
|
||||
|
||||
public string? LaunchExecutable { get; init; }
|
||||
|
||||
public IReadOnlyList<string> LaunchArguments { get; init; } = [];
|
||||
|
||||
public string? WorkingDirectory { get; init; }
|
||||
}
|
||||
|
||||
42
LanMountainDesktop/Models/Stcn24ForumSourceTypes.cs
Normal file
42
LanMountainDesktop/Models/Stcn24ForumSourceTypes.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public static class Stcn24ForumSourceTypes
|
||||
{
|
||||
public const string LatestCreated = "LatestCreated";
|
||||
public const string LatestActivity = "LatestActivity";
|
||||
public const string MostReplies = "MostReplies";
|
||||
public const string EarliestCreated = "EarliestCreated";
|
||||
public const string EarliestActivity = "EarliestActivity";
|
||||
public const string LeastReplies = "LeastReplies";
|
||||
public const string FrontpageLatest = "FrontpageLatest";
|
||||
public const string FrontpageEarliest = "FrontpageEarliest";
|
||||
|
||||
public static IReadOnlyList<string> SupportedValues { get; } =
|
||||
[
|
||||
LatestCreated,
|
||||
LatestActivity,
|
||||
MostReplies,
|
||||
EarliestCreated,
|
||||
EarliestActivity,
|
||||
LeastReplies,
|
||||
FrontpageLatest,
|
||||
FrontpageEarliest
|
||||
];
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
var candidate = value?.Trim() ?? string.Empty;
|
||||
foreach (var supported in SupportedValues)
|
||||
{
|
||||
if (string.Equals(candidate, supported, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return supported;
|
||||
}
|
||||
}
|
||||
|
||||
return LatestCreated;
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,6 @@ public enum TaskbarActionId
|
||||
AddDesktopPage,
|
||||
DeleteDesktopPage,
|
||||
DeleteComponent,
|
||||
EditComponent
|
||||
EditComponent,
|
||||
HideLauncherEntry
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia;
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using LanMountainDesktop.Services;
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
@@ -10,14 +11,42 @@ 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()
|
||||
public static void Main(string[] args) => BuildAvaloniaApp(LoadConfiguredRenderMode())
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
|
||||
{
|
||||
var builder = AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.UseDesktopWebView()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var configuredModes = AppRenderingModeHelper.GetWin32RenderingModes(renderMode);
|
||||
if (configuredModes is { Length: > 0 })
|
||||
{
|
||||
builder = builder.With(new Win32PlatformOptions
|
||||
{
|
||||
RenderingMode = configuredModes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static string LoadConfiguredRenderMode()
|
||||
{
|
||||
try
|
||||
{
|
||||
return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return AppRenderingModeHelper.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
73
LanMountainDesktop/Services/AppRenderBackendDiagnostics.cs
Normal file
73
LanMountainDesktop/Services/AppRenderBackendDiagnostics.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Platform;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public readonly record struct AppRenderBackendInfo(
|
||||
string ActualBackend,
|
||||
string? ImplementationTypeName);
|
||||
|
||||
public static class AppRenderBackendDiagnostics
|
||||
{
|
||||
public const string Unknown = "Unknown";
|
||||
|
||||
public static AppRenderBackendInfo Detect()
|
||||
{
|
||||
var platformGraphics = GetPlatformGraphics();
|
||||
var implementationTypeName = platformGraphics?.GetType().FullName;
|
||||
var actualBackend = DetectBackendFromImplementationType(implementationTypeName, platformGraphics is null);
|
||||
|
||||
return new AppRenderBackendInfo(actualBackend, implementationTypeName);
|
||||
}
|
||||
|
||||
private static object? GetPlatformGraphics()
|
||||
{
|
||||
var currentResolver = typeof(AvaloniaLocator)
|
||||
.GetProperty("Current", BindingFlags.Public | BindingFlags.Static)
|
||||
?.GetValue(null);
|
||||
|
||||
var getServiceMethod = currentResolver?
|
||||
.GetType()
|
||||
.GetMethod(
|
||||
"GetService",
|
||||
BindingFlags.Public | BindingFlags.Instance,
|
||||
binder: null,
|
||||
types: [typeof(Type)],
|
||||
modifiers: null);
|
||||
|
||||
return getServiceMethod?.Invoke(currentResolver, [typeof(IPlatformGraphics)]);
|
||||
}
|
||||
|
||||
private static string DetectBackendFromImplementationType(string? implementationTypeName, bool isSoftwareFallback)
|
||||
{
|
||||
if (isSoftwareFallback)
|
||||
{
|
||||
return AppRenderingModeHelper.Software;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(implementationTypeName))
|
||||
{
|
||||
return Unknown;
|
||||
}
|
||||
|
||||
if (implementationTypeName.Contains("Vulkan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AppRenderingModeHelper.Vulkan;
|
||||
}
|
||||
|
||||
if (implementationTypeName.Contains("Wgl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AppRenderingModeHelper.Wgl;
|
||||
}
|
||||
|
||||
if (implementationTypeName.Contains("Angle", StringComparison.OrdinalIgnoreCase) ||
|
||||
implementationTypeName.Contains("Egl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AppRenderingModeHelper.AngleEgl;
|
||||
}
|
||||
|
||||
return Unknown;
|
||||
}
|
||||
}
|
||||
42
LanMountainDesktop/Services/AppRenderingModeHelper.cs
Normal file
42
LanMountainDesktop/Services/AppRenderingModeHelper.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppRenderingModeHelper
|
||||
{
|
||||
public const string Default = "Default";
|
||||
public const string Software = "Software";
|
||||
public const string AngleEgl = "AngleEgl";
|
||||
public const string Wgl = "Wgl";
|
||||
public const string Vulkan = "Vulkan";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Default;
|
||||
}
|
||||
|
||||
return value.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"SOFTWARE" => Software,
|
||||
"ANGLEEGL" => AngleEgl,
|
||||
"ANGLE_EGL" => AngleEgl,
|
||||
"WGL" => Wgl,
|
||||
"VULKAN" => Vulkan,
|
||||
_ => Default
|
||||
};
|
||||
}
|
||||
|
||||
public static Win32RenderingMode[]? GetWin32RenderingModes(string? value)
|
||||
{
|
||||
return Normalize(value) switch
|
||||
{
|
||||
Software => [Win32RenderingMode.Software],
|
||||
AngleEgl => [Win32RenderingMode.AngleEgl, Win32RenderingMode.Software],
|
||||
Wgl => [Win32RenderingMode.Wgl, Win32RenderingMode.Software],
|
||||
Vulkan => [Win32RenderingMode.Vulkan, Win32RenderingMode.Software],
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
154
LanMountainDesktop/Services/AppRestartService.cs
Normal file
154
LanMountainDesktop/Services/AppRestartService.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppRestartService
|
||||
{
|
||||
public static bool TryRestartCurrentProcess()
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = CreateRestartStartInfo();
|
||||
if (startInfo is null)
|
||||
{
|
||||
Debug.WriteLine("[AppRestart] Failed to resolve restart start info.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Process.Start(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[AppRestart] Failed to restart app: {ex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static ProcessStartInfo? CreateRestartStartInfo(
|
||||
string[]? commandLineArgs = null,
|
||||
string? processPath = null,
|
||||
string? entryAssemblyLocation = null)
|
||||
{
|
||||
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
|
||||
var resolvedProcessPath = NormalizeExistingPath(processPath ?? Environment.ProcessPath);
|
||||
var resolvedEntryAssemblyPath = NormalizeExistingPath(
|
||||
entryAssemblyLocation ?? Assembly.GetEntryAssembly()?.Location);
|
||||
|
||||
if (IsDotnetHost(resolvedProcessPath))
|
||||
{
|
||||
return CreateDotnetStartInfo(
|
||||
resolvedProcessPath!,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
|
||||
{
|
||||
return CreateExecutableStartInfo(
|
||||
resolvedProcessPath,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedEntryAssemblyPath) &&
|
||||
string.Equals(Path.GetExtension(resolvedEntryAssemblyPath), ".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateDotnetStartInfo(
|
||||
"dotnet",
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateExecutableStartInfo(
|
||||
string executablePath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executablePath,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo? CreateDotnetStartInfo(
|
||||
string dotnetHostPath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entryAssemblyPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = dotnetHostPath,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(entryAssemblyPath);
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
{
|
||||
startInfo.ArgumentList.Add(commandLineArgs[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
return File.Exists(fullPath) ? fullPath : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDotnetHost(string? processPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(processPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(processPath);
|
||||
return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ResolveWorkingDirectory(string launchPath, string? entryAssemblyPath)
|
||||
{
|
||||
var basePath = !string.IsNullOrWhiteSpace(entryAssemblyPath)
|
||||
? entryAssemblyPath
|
||||
: launchPath;
|
||||
|
||||
return Path.GetDirectoryName(basePath) ?? AppContext.BaseDirectory;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class AppSettingsService
|
||||
{
|
||||
public static event Action<string>? SettingsSaved;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
@@ -21,6 +23,8 @@ public sealed class AppSettingsService
|
||||
|
||||
private readonly string _settingsPath;
|
||||
|
||||
public string InstanceId { get; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
public AppSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
@@ -88,6 +92,8 @@ public sealed class AppSettingsService
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
SettingsSaved?.Invoke(InstanceId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
123
LanMountainDesktop/Services/CalculatorDataService.cs
Normal file
123
LanMountainDesktop/Services/CalculatorDataService.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class CalculatorDataService : ICalculatorDataService
|
||||
{
|
||||
private const int MaxInputLength = 18;
|
||||
|
||||
public string ApplyInputToken(string currentInput, string token)
|
||||
{
|
||||
var normalized = NormalizeInput(currentInput);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (string.Equals(token, CalculatorInputTokens.Clear, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (string.Equals(token, CalculatorInputTokens.Backspace, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (normalized.Length <= 1)
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
var trimmed = normalized[..^1];
|
||||
if (trimmed is "-" or "" or "-0")
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (string.Equals(token, CalculatorInputTokens.DecimalPoint, StringComparison.Ordinal))
|
||||
{
|
||||
if (normalized.Contains('.', StringComparison.Ordinal))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized.Length >= MaxInputLength)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return $"{normalized}.";
|
||||
}
|
||||
|
||||
if (token is "00")
|
||||
{
|
||||
if (normalized == "0")
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (normalized.Length + 2 > MaxInputLength)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized + "00";
|
||||
}
|
||||
|
||||
if (token.Length == 1 && char.IsDigit(token[0]))
|
||||
{
|
||||
if (normalized == "0")
|
||||
{
|
||||
return token;
|
||||
}
|
||||
|
||||
if (normalized.Length >= MaxInputLength)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized + token;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public decimal ParseAmountOrZero(string? inputText)
|
||||
{
|
||||
var normalized = NormalizeInput(inputText);
|
||||
if (decimal.TryParse(
|
||||
normalized,
|
||||
NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var amount))
|
||||
{
|
||||
return amount;
|
||||
}
|
||||
|
||||
return 0m;
|
||||
}
|
||||
|
||||
public string FormatAmount(decimal amount, int maxFractionDigits = 4)
|
||||
{
|
||||
var safeDigits = Math.Clamp(maxFractionDigits, 0, 8);
|
||||
var pattern = safeDigits == 0 ? "0" : $"0.{new string('#', safeDigits)}";
|
||||
return amount.ToString(pattern, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string NormalizeInput(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
var trimmed = input.Trim();
|
||||
return trimmed switch
|
||||
{
|
||||
"-" or "-0" => "0",
|
||||
_ => trimmed
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -39,7 +39,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
||||
};
|
||||
|
||||
private static readonly IDeserializer CsesDeserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.WithNamingConvention(UnderscoredNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inputPath))
|
||||
{
|
||||
inputPath = ResolveImportedSchedulePathFromAppSettings();
|
||||
inputPath = ResolveImportedSchedulePathFromComponentSettings();
|
||||
}
|
||||
|
||||
var source = ResolveSource(inputPath, profileFileName, warnings);
|
||||
@@ -180,11 +180,11 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? ResolveImportedSchedulePathFromAppSettings()
|
||||
private static string? ResolveImportedSchedulePathFromComponentSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = new AppSettingsService().Load();
|
||||
var snapshot = new ComponentSettingsService().Load();
|
||||
if (snapshot.ImportedClassSchedules.Count == 0)
|
||||
{
|
||||
return null;
|
||||
|
||||
751
LanMountainDesktop/Services/ComponentSettingsService.cs
Normal file
751
LanMountainDesktop/Services/ComponentSettingsService.cs
Normal file
@@ -0,0 +1,751 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static readonly object CacheGate = new();
|
||||
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private static string? _cachedPath;
|
||||
private static ComponentSettingsDocumentSnapshot? _cachedSnapshot;
|
||||
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
|
||||
private static DateTime _lastProbeUtc = DateTime.MinValue;
|
||||
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _legacyAppSettingsPath;
|
||||
private string _scopedComponentId = string.Empty;
|
||||
private string _scopedPlacementId = string.Empty;
|
||||
|
||||
public ComponentSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
_settingsPath = Path.Combine(settingsDirectory, "component-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
public ComponentSettingsSnapshot Load()
|
||||
{
|
||||
if (HasScopedComponentContext())
|
||||
{
|
||||
return LoadForComponent(_scopedComponentId, _scopedPlacementId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
return document.DefaultSettings.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ComponentSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
if (HasScopedComponentContext())
|
||||
{
|
||||
SaveForComponent(_scopedComponentId, _scopedPlacementId, snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshotToPersist = NormalizeSnapshot(snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
document.DefaultSettings = snapshotToPersist;
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentSettingsSnapshot LoadForComponent(string componentId, string? placementId)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (!string.IsNullOrWhiteSpace(instanceKey) &&
|
||||
document.InstanceSettings.TryGetValue(instanceKey, out var snapshot))
|
||||
{
|
||||
return snapshot.Clone();
|
||||
}
|
||||
|
||||
return document.DefaultSettings.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ComponentSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveForComponent(string componentId, string? placementId, ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
var normalizedSnapshot = NormalizeSnapshot(snapshot);
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
Save(normalizedSnapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
document.InstanceSettings[instanceKey] = normalizedSnapshot;
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteForComponent(string componentId, string? placementId)
|
||||
{
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
var changed = document.InstanceSettings.Remove(instanceKey);
|
||||
changed |= document.PluginSettings.Remove(instanceKey);
|
||||
if (changed)
|
||||
{
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
public T LoadPluginSettings<T>(string componentId, string? placementId) where T : new()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey) ||
|
||||
!document.PluginSettings.TryGetValue(instanceKey, out var settingsElement))
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(settingsElement.GetRawText(), SerializerOptions) ?? new T();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
}
|
||||
|
||||
public void SavePluginSettings<T>(string componentId, string? placementId, T settings)
|
||||
{
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
document.PluginSettings[instanceKey] = JsonSerializer.SerializeToElement(settings, SerializerOptions).Clone();
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
public void DeletePluginSettings(string componentId, string? placementId)
|
||||
{
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
if (document.PluginSettings.Remove(instanceKey))
|
||||
{
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
public void SetScopedComponentContext(string componentId, string? placementId)
|
||||
{
|
||||
_scopedComponentId = componentId?.Trim() ?? string.Empty;
|
||||
_scopedPlacementId = placementId?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public void ClearScopedComponentContext()
|
||||
{
|
||||
_scopedComponentId = string.Empty;
|
||||
_scopedPlacementId = string.Empty;
|
||||
}
|
||||
|
||||
public static void ApplyScopedContextToTarget(object? target, string componentId, string? placementId)
|
||||
{
|
||||
if (target is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
foreach (var field in target.GetType().GetFields(flags))
|
||||
{
|
||||
if (field.FieldType != typeof(ComponentSettingsService))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.GetValue(target) is ComponentSettingsService settingsService)
|
||||
{
|
||||
settingsService.SetScopedComponentContext(componentId, placementId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in target.GetType().GetProperties(flags))
|
||||
{
|
||||
if (property.PropertyType != typeof(ComponentSettingsService) ||
|
||||
!property.CanRead)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (property.GetValue(target) is ComponentSettingsService settingsService)
|
||||
{
|
||||
settingsService.SetScopedComponentContext(componentId, placementId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
nowUtc - _lastProbeUtc < CacheProbeInterval)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
writeTimeUtc == _cachedWriteTimeUtc)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private ComponentSettingsDocumentSnapshot LoadDocumentLocked()
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
ComponentSettingsDocumentSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = new ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
DefaultSettings = NormalizeSnapshot(migratedSnapshot)
|
||||
};
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new ComponentSettingsDocumentSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeDocument(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
|
||||
private ComponentSettingsDocumentSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
(document.RootElement.TryGetProperty("defaultSettings", out _) ||
|
||||
document.RootElement.TryGetProperty("instanceSettings", out _) ||
|
||||
document.RootElement.TryGetProperty("pluginSettings", out _)))
|
||||
{
|
||||
var snapshot = JsonSerializer.Deserialize<ComponentSettingsDocumentSnapshot>(json, SerializerOptions);
|
||||
return NormalizeDocument(snapshot);
|
||||
}
|
||||
|
||||
var legacySnapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
|
||||
return new ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
DefaultSettings = NormalizeSnapshot(legacySnapshot)
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ComponentSettingsDocumentSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadLegacySnapshot(out ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
snapshot = new ComponentSettingsSnapshot();
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_legacyAppSettingsPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
|
||||
var legacy = JsonSerializer.Deserialize<LegacyComponentSettingsSnapshot>(legacyJson, SerializerOptions);
|
||||
if (legacy is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new ComponentSettingsSnapshot
|
||||
{
|
||||
DailyArtworkMirrorSource = legacy.DailyArtworkMirrorSource,
|
||||
ImportedClassSchedules = legacy.ImportedClassSchedules ?? [],
|
||||
ActiveImportedClassScheduleId = legacy.ActiveImportedClassScheduleId ?? string.Empty,
|
||||
StudyEnvironmentShowDisplayDb = legacy.StudyEnvironmentShowDisplayDb,
|
||||
StudyEnvironmentShowDbfs = legacy.StudyEnvironmentShowDbfs,
|
||||
DesktopClockTimeZoneId = legacy.DesktopClockTimeZoneId,
|
||||
DesktopClockSecondHandMode = legacy.DesktopClockSecondHandMode,
|
||||
WorldClockTimeZoneIds = legacy.WorldClockTimeZoneIds ?? [],
|
||||
WorldClockSecondHandMode = legacy.WorldClockSecondHandMode,
|
||||
CnrDailyNewsAutoRotateEnabled = legacy.CnrDailyNewsAutoRotateEnabled,
|
||||
CnrDailyNewsAutoRotateIntervalMinutes = legacy.CnrDailyNewsAutoRotateIntervalMinutes,
|
||||
IfengNewsAutoRefreshEnabled = legacy.IfengNewsAutoRefreshEnabled,
|
||||
IfengNewsAutoRefreshIntervalMinutes = legacy.IfengNewsAutoRefreshIntervalMinutes,
|
||||
IfengNewsChannelType = legacy.IfengNewsChannelType,
|
||||
DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled,
|
||||
DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes,
|
||||
BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled,
|
||||
BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes,
|
||||
BaiduHotSearchAutoRefreshEnabled = legacy.BaiduHotSearchAutoRefreshEnabled,
|
||||
BaiduHotSearchAutoRefreshIntervalMinutes = legacy.BaiduHotSearchAutoRefreshIntervalMinutes,
|
||||
BaiduHotSearchSourceType = legacy.BaiduHotSearchSourceType,
|
||||
WeatherAutoRefreshEnabled = legacy.WeatherAutoRefreshEnabled,
|
||||
WeatherAutoRefreshIntervalMinutes = legacy.WeatherAutoRefreshIntervalMinutes,
|
||||
Stcn24ForumAutoRefreshEnabled = legacy.Stcn24ForumAutoRefreshEnabled,
|
||||
Stcn24ForumAutoRefreshIntervalMinutes = legacy.Stcn24ForumAutoRefreshIntervalMinutes,
|
||||
Stcn24ForumSourceType = legacy.Stcn24ForumSourceType
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistDocumentLocked(ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshot);
|
||||
UpdateCache(snapshot, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
|
||||
return File.Exists(_settingsPath)
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static ComponentSettingsSnapshot NormalizeSnapshot(ComponentSettingsSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new ComponentSettingsSnapshot();
|
||||
|
||||
normalized.DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(normalized.DailyArtworkMirrorSource);
|
||||
normalized.ImportedClassSchedules = NormalizeImportedSchedules(normalized.ImportedClassSchedules);
|
||||
normalized.ActiveImportedClassScheduleId = NormalizeActiveScheduleId(
|
||||
normalized.ActiveImportedClassScheduleId,
|
||||
normalized.ImportedClassSchedules);
|
||||
|
||||
if (!normalized.StudyEnvironmentShowDisplayDb && !normalized.StudyEnvironmentShowDbfs)
|
||||
{
|
||||
normalized.StudyEnvironmentShowDisplayDb = true;
|
||||
}
|
||||
|
||||
normalized.DesktopClockTimeZoneId = NormalizeDesktopClockTimeZoneId(normalized.DesktopClockTimeZoneId);
|
||||
normalized.DesktopClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.DesktopClockSecondHandMode);
|
||||
normalized.WorldClockTimeZoneIds = WorldClockTimeZoneCatalog
|
||||
.NormalizeTimeZoneIds(normalized.WorldClockTimeZoneIds)
|
||||
.ToList();
|
||||
normalized.WorldClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.WorldClockSecondHandMode);
|
||||
normalized.CnrDailyNewsAutoRotateIntervalMinutes = NormalizeCnrInterval(normalized.CnrDailyNewsAutoRotateIntervalMinutes);
|
||||
normalized.IfengNewsAutoRefreshIntervalMinutes = NormalizeIfengNewsInterval(normalized.IfengNewsAutoRefreshIntervalMinutes);
|
||||
normalized.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(normalized.IfengNewsChannelType);
|
||||
normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes);
|
||||
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval(
|
||||
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes);
|
||||
normalized.BaiduHotSearchAutoRefreshIntervalMinutes = NormalizeBaiduHotSearchInterval(
|
||||
normalized.BaiduHotSearchAutoRefreshIntervalMinutes);
|
||||
normalized.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(normalized.BaiduHotSearchSourceType);
|
||||
normalized.WeatherAutoRefreshIntervalMinutes = NormalizeWeatherInterval(normalized.WeatherAutoRefreshIntervalMinutes);
|
||||
normalized.Stcn24ForumAutoRefreshIntervalMinutes = NormalizeStcn24ForumInterval(normalized.Stcn24ForumAutoRefreshIntervalMinutes);
|
||||
normalized.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(normalized.Stcn24ForumSourceType);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static ComponentSettingsDocumentSnapshot NormalizeDocument(ComponentSettingsDocumentSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new ComponentSettingsDocumentSnapshot();
|
||||
normalized.DefaultSettings = NormalizeSnapshot(normalized.DefaultSettings);
|
||||
|
||||
var instanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in normalized.InstanceSettings)
|
||||
{
|
||||
var key = NormalizeInstanceKey(pair.Key);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
instanceSettings[key] = NormalizeSnapshot(pair.Value);
|
||||
}
|
||||
|
||||
var pluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in normalized.PluginSettings)
|
||||
{
|
||||
var key = NormalizeInstanceKey(pair.Key);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
pluginSettings[key] = pair.Value.Clone();
|
||||
}
|
||||
|
||||
normalized.InstanceSettings = instanceSettings;
|
||||
normalized.PluginSettings = pluginSettings;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static List<ImportedClassScheduleSnapshot> NormalizeImportedSchedules(
|
||||
IReadOnlyList<ImportedClassScheduleSnapshot>? schedules)
|
||||
{
|
||||
if (schedules is null || schedules.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new List<ImportedClassScheduleSnapshot>(schedules.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var schedule in schedules)
|
||||
{
|
||||
if (schedule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = schedule.Id?.Trim() ?? string.Empty;
|
||||
var filePath = schedule.FilePath?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenIds.Add(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new ImportedClassScheduleSnapshot
|
||||
{
|
||||
Id = id,
|
||||
DisplayName = schedule.DisplayName?.Trim() ?? string.Empty,
|
||||
FilePath = filePath
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string NormalizeActiveScheduleId(
|
||||
string? activeScheduleId,
|
||||
IReadOnlyList<ImportedClassScheduleSnapshot> schedules)
|
||||
{
|
||||
var activeId = activeScheduleId?.Trim() ?? string.Empty;
|
||||
if (schedules.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(activeId))
|
||||
{
|
||||
return schedules[0].Id;
|
||||
}
|
||||
|
||||
return schedules.Any(item => string.Equals(item.Id, activeId, StringComparison.OrdinalIgnoreCase))
|
||||
? activeId
|
||||
: schedules[0].Id;
|
||||
}
|
||||
|
||||
private static string NormalizeDesktopClockTimeZoneId(string? timeZoneId)
|
||||
{
|
||||
var normalizedId = string.IsNullOrWhiteSpace(timeZoneId)
|
||||
? "China Standard Time"
|
||||
: timeZoneId.Trim();
|
||||
return WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(normalizedId).Id;
|
||||
}
|
||||
|
||||
private static int NormalizeCnrInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 60);
|
||||
}
|
||||
|
||||
private static int NormalizeDailyWordInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 360);
|
||||
}
|
||||
|
||||
private static int NormalizeIfengNewsInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 20);
|
||||
}
|
||||
|
||||
private static int NormalizeBilibiliHotSearchInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 15);
|
||||
}
|
||||
|
||||
private static int NormalizeBaiduHotSearchInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 15);
|
||||
}
|
||||
|
||||
private static int NormalizeWeatherInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 12);
|
||||
}
|
||||
|
||||
private static int NormalizeStcn24ForumInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 20);
|
||||
}
|
||||
|
||||
private static string BuildInstanceKey(string componentId, string? placementId)
|
||||
{
|
||||
var normalizedComponentId = componentId?.Trim() ?? string.Empty;
|
||||
var normalizedPlacementId = placementId?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalizedComponentId) || string.IsNullOrWhiteSpace(normalizedPlacementId))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return $"{normalizedComponentId}::{normalizedPlacementId}";
|
||||
}
|
||||
|
||||
private static string NormalizeInstanceKey(string? key)
|
||||
{
|
||||
return key?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
private bool HasScopedComponentContext()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(_scopedComponentId) &&
|
||||
!string.IsNullOrWhiteSpace(_scopedPlacementId);
|
||||
}
|
||||
|
||||
private void UpdateCache(ComponentSettingsDocumentSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
{
|
||||
_cachedPath = _settingsPath;
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
_cachedWriteTimeUtc = writeTimeUtc;
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
|
||||
private sealed class ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
public ComponentSettingsSnapshot DefaultSettings { get; set; } = new();
|
||||
|
||||
public Dictionary<string, ComponentSettingsSnapshot> InstanceSettings { get; set; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Dictionary<string, JsonElement> PluginSettings { get; set; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ComponentSettingsDocumentSnapshot Clone()
|
||||
{
|
||||
var clone = new ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
DefaultSettings = DefaultSettings?.Clone() ?? new ComponentSettingsSnapshot(),
|
||||
InstanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase),
|
||||
PluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
foreach (var pair in InstanceSettings)
|
||||
{
|
||||
clone.InstanceSettings[pair.Key] = pair.Value?.Clone() ?? new ComponentSettingsSnapshot();
|
||||
}
|
||||
|
||||
foreach (var pair in PluginSettings)
|
||||
{
|
||||
clone.PluginSettings[pair.Key] = pair.Value.Clone();
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LegacyComponentSettingsSnapshot
|
||||
{
|
||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||
|
||||
public List<ImportedClassScheduleSnapshot>? ImportedClassSchedules { get; set; }
|
||||
|
||||
public string? ActiveImportedClassScheduleId { get; set; }
|
||||
|
||||
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
|
||||
|
||||
public bool StudyEnvironmentShowDbfs { get; set; }
|
||||
|
||||
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
|
||||
|
||||
public string DesktopClockSecondHandMode { get; set; } = "Tick";
|
||||
|
||||
public List<string>? WorldClockTimeZoneIds { get; set; }
|
||||
|
||||
public string WorldClockSecondHandMode { get; set; } = "Tick";
|
||||
|
||||
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
|
||||
|
||||
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
|
||||
|
||||
public bool IfengNewsAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20;
|
||||
|
||||
public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive;
|
||||
|
||||
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
|
||||
|
||||
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
|
||||
|
||||
public bool BaiduHotSearchAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
|
||||
|
||||
public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official;
|
||||
|
||||
public bool WeatherAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
|
||||
|
||||
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
|
||||
|
||||
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
||||
}
|
||||
}
|
||||
174
LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
Normal file
174
LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.ComponentSystem.Extensions;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class DesktopComponentRegistryFactory
|
||||
{
|
||||
public static ComponentRegistry Create(PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var registry = ComponentRegistry
|
||||
.CreateDefault()
|
||||
.RegisterExtensions(
|
||||
JsonComponentExtensionProvider.LoadProvidersFromDirectory(
|
||||
Path.Combine(AppContext.BaseDirectory, "Extensions", "Components")));
|
||||
|
||||
var pluginDefinitions = GetPluginDefinitions(registry, pluginRuntimeService);
|
||||
return pluginDefinitions.Count == 0
|
||||
? registry
|
||||
: registry.RegisterComponents(pluginDefinitions);
|
||||
}
|
||||
|
||||
public static DesktopComponentRuntimeRegistry CreateRuntimeRegistry(
|
||||
ComponentRegistry componentRegistry,
|
||||
PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var registrations = DesktopComponentRuntimeRegistry.GetDefaultRegistrations().ToList();
|
||||
var registeredIds = new HashSet<string>(
|
||||
registrations.Select(registration => registration.ComponentId),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (pluginRuntimeService is not null)
|
||||
{
|
||||
foreach (var contribution in pluginRuntimeService.DesktopComponents)
|
||||
{
|
||||
var registration = contribution.Registration;
|
||||
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!registeredIds.Add(registration.ComponentId))
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because a runtime registration already exists.");
|
||||
continue;
|
||||
}
|
||||
|
||||
registrations.Add(new DesktopComponentRuntimeRegistration(
|
||||
registration.ComponentId,
|
||||
registration.DisplayNameLocalizationKey,
|
||||
factoryContext => CreatePluginControl(contribution, factoryContext),
|
||||
registration.CornerRadiusResolver));
|
||||
}
|
||||
}
|
||||
|
||||
return new DesktopComponentRuntimeRegistry(componentRegistry, registrations);
|
||||
}
|
||||
|
||||
private static List<DesktopComponentDefinition> GetPluginDefinitions(
|
||||
ComponentRegistry baseRegistry,
|
||||
PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var definitions = new List<DesktopComponentDefinition>();
|
||||
if (pluginRuntimeService is null)
|
||||
{
|
||||
return definitions;
|
||||
}
|
||||
|
||||
var knownIds = new HashSet<string>(
|
||||
baseRegistry.GetAll().Select(definition => definition.Id),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var contribution in pluginRuntimeService.DesktopComponents)
|
||||
{
|
||||
var registration = contribution.Registration;
|
||||
if (!knownIds.Add(registration.ComponentId))
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because the component id already exists.");
|
||||
continue;
|
||||
}
|
||||
|
||||
definitions.Add(new DesktopComponentDefinition(
|
||||
registration.ComponentId,
|
||||
registration.DisplayName,
|
||||
registration.IconKey,
|
||||
registration.Category,
|
||||
registration.MinWidthCells,
|
||||
registration.MinHeightCells,
|
||||
registration.AllowStatusBarPlacement,
|
||||
registration.AllowDesktopPlacement,
|
||||
registration.ResizeMode == PluginDesktopComponentResizeMode.Free
|
||||
? DesktopComponentResizeMode.Free
|
||||
: DesktopComponentResizeMode.Proportional));
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private static Control CreatePluginControl(
|
||||
PluginDesktopComponentContribution contribution,
|
||||
DesktopComponentControlFactoryContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginContext = new PluginDesktopComponentContext(
|
||||
contribution.Plugin.Manifest,
|
||||
contribution.Plugin.Context.PluginDirectory,
|
||||
contribution.Plugin.Context.DataDirectory,
|
||||
contribution.Plugin.Context.Services,
|
||||
contribution.Plugin.Context.Properties,
|
||||
contribution.Registration.ComponentId,
|
||||
context.PlacementId,
|
||||
context.CellSize);
|
||||
|
||||
return contribution.Registration.ControlFactory(pluginContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Failed to create widget '{contribution.Registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}': {ex}");
|
||||
return CreatePluginErrorControl(contribution, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Control CreatePluginErrorControl(
|
||||
PluginDesktopComponentContribution contribution,
|
||||
Exception exception)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#332B0F16")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(12),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = contribution.Registration.DisplayName,
|
||||
FontSize = 14,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"Plugin {contribution.Plugin.Manifest.Name} failed to create this widget.",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = exception.Message,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
248
LanMountainDesktop/Services/DesktopLayoutSettingsService.cs
Normal file
248
LanMountainDesktop/Services/DesktopLayoutSettingsService.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class DesktopLayoutSettingsService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private static readonly object CacheGate = new();
|
||||
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private static string? _cachedPath;
|
||||
private static DesktopLayoutSettingsSnapshot? _cachedSnapshot;
|
||||
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
|
||||
private static DateTime _lastProbeUtc = DateTime.MinValue;
|
||||
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _legacyAppSettingsPath;
|
||||
|
||||
public DesktopLayoutSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
_settingsPath = Path.Combine(settingsDirectory, "desktop-layout-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
public DesktopLayoutSettingsSnapshot Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
DesktopLayoutSettingsSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = migratedSnapshot;
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new DesktopLayoutSettingsSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new DesktopLayoutSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
var snapshotToPersist = NormalizeSnapshot(snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
|
||||
|
||||
lock (CacheGate)
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
nowUtc - _lastProbeUtc < CacheProbeInterval)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
writeTimeUtc == _cachedWriteTimeUtc)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private DesktopLayoutSettingsSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
var snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions);
|
||||
return NormalizeSnapshot(snapshot);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new DesktopLayoutSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadLegacySnapshot(out DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
snapshot = new DesktopLayoutSettingsSnapshot();
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_legacyAppSettingsPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
|
||||
var legacy = JsonSerializer.Deserialize<LegacyDesktopLayoutSettingsSnapshot>(legacyJson, SerializerOptions);
|
||||
if (legacy is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new DesktopLayoutSettingsSnapshot
|
||||
{
|
||||
DesktopPageCount = legacy.DesktopPageCount,
|
||||
CurrentDesktopSurfaceIndex = legacy.CurrentDesktopSurfaceIndex,
|
||||
DesktopComponentPlacements = legacy.DesktopComponentPlacements ?? []
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
|
||||
return File.Exists(_settingsPath)
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static DesktopLayoutSettingsSnapshot NormalizeSnapshot(DesktopLayoutSettingsSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new DesktopLayoutSettingsSnapshot();
|
||||
normalized.DesktopPageCount = Math.Max(1, normalized.DesktopPageCount);
|
||||
normalized.CurrentDesktopSurfaceIndex = Math.Max(0, normalized.CurrentDesktopSurfaceIndex);
|
||||
|
||||
var placements = new List<DesktopComponentPlacementSnapshot>(normalized.DesktopComponentPlacements?.Count ?? 0);
|
||||
if (normalized.DesktopComponentPlacements is not null)
|
||||
{
|
||||
foreach (var placement in normalized.DesktopComponentPlacements)
|
||||
{
|
||||
if (placement is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
placements.Add(new DesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placement.PlacementId?.Trim() ?? string.Empty,
|
||||
PageIndex = Math.Max(0, placement.PageIndex),
|
||||
ComponentId = placement.ComponentId?.Trim() ?? string.Empty,
|
||||
Row = Math.Max(0, placement.Row),
|
||||
Column = Math.Max(0, placement.Column),
|
||||
WidthCells = Math.Max(1, placement.WidthCells),
|
||||
HeightCells = Math.Max(1, placement.HeightCells)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
normalized.DesktopComponentPlacements = placements;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void UpdateCache(DesktopLayoutSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
{
|
||||
_cachedPath = _settingsPath;
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
_cachedWriteTimeUtc = writeTimeUtc;
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
|
||||
private sealed class LegacyDesktopLayoutSettingsSnapshot
|
||||
{
|
||||
public int DesktopPageCount { get; set; } = 1;
|
||||
|
||||
public int CurrentDesktopSurfaceIndex { get; set; }
|
||||
|
||||
public List<DesktopComponentPlacementSnapshot>? DesktopComponentPlacements { get; set; }
|
||||
}
|
||||
}
|
||||
482
LanMountainDesktop/Services/GitHubReleaseUpdateService.cs
Normal file
482
LanMountainDesktop/Services/GitHubReleaseUpdateService.cs
Normal file
@@ -0,0 +1,482 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record GitHubReleaseAsset(
|
||||
string Name,
|
||||
string BrowserDownloadUrl,
|
||||
long SizeBytes);
|
||||
|
||||
public sealed record GitHubReleaseInfo(
|
||||
string TagName,
|
||||
string Name,
|
||||
bool IsPrerelease,
|
||||
bool IsDraft,
|
||||
DateTimeOffset PublishedAt,
|
||||
IReadOnlyList<GitHubReleaseAsset> Assets);
|
||||
|
||||
public sealed record UpdateCheckResult(
|
||||
bool Success,
|
||||
bool IsUpdateAvailable,
|
||||
string CurrentVersionText,
|
||||
string LatestVersionText,
|
||||
GitHubReleaseInfo? Release,
|
||||
GitHubReleaseAsset? PreferredAsset,
|
||||
string? ErrorMessage);
|
||||
|
||||
public sealed record UpdateDownloadResult(
|
||||
bool Success,
|
||||
string? FilePath,
|
||||
string? ErrorMessage);
|
||||
|
||||
public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
{
|
||||
private const string GithubApiVersion = "2022-11-28";
|
||||
|
||||
private readonly string _owner;
|
||||
private readonly string _repo;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
public GitHubReleaseUpdateService(
|
||||
string owner,
|
||||
string repo,
|
||||
HttpClient? httpClient = null)
|
||||
{
|
||||
_owner = owner?.Trim() ?? string.Empty;
|
||||
_repo = repo?.Trim() ?? string.Empty;
|
||||
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
|
||||
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
|
||||
}
|
||||
|
||||
if (!_httpClient.DefaultRequestHeaders.Accept.Any())
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
|
||||
}
|
||||
|
||||
if (!_httpClient.DefaultRequestHeaders.Contains("X-GitHub-Api-Version"))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", GithubApiVersion);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "Repository information is not configured.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var release = includePrerelease
|
||||
? await GetLatestReleaseIncludingPrereleaseAsync(cancellationToken)
|
||||
: await GetLatestStableReleaseAsync(cancellationToken);
|
||||
|
||||
if (release is null)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "No release data was returned from GitHub.");
|
||||
}
|
||||
|
||||
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
||||
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
||||
? parsedTagVersion.ToString(3)
|
||||
: release.TagName;
|
||||
|
||||
var isUpdateAvailable = parsedTagVersion is not null && parsedTagVersion > currentVersion;
|
||||
var preferredAsset = isUpdateAvailable
|
||||
? SelectPreferredInstallerAsset(release.Assets)
|
||||
: null;
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: isUpdateAvailable,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (asset is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "Asset is null.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(asset.BrowserDownloadUrl))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "Asset download url is empty.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(destinationFilePath))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "Destination file path is empty.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(destinationFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.GetAsync(
|
||||
asset.BrowserDownloadUrl,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GitHubReleaseInfo?> GetLatestStableReleaseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"https://api.github.com/repos/{_owner}/{_repo}/releases/latest";
|
||||
var responseText = await GetResponseTextAsync(url, cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
return ParseRelease(document.RootElement);
|
||||
}
|
||||
|
||||
private async Task<GitHubReleaseInfo?> GetLatestReleaseIncludingPrereleaseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"https://api.github.com/repos/{_owner}/{_repo}/releases?per_page=20";
|
||||
var responseText = await GetResponseTextAsync(url, cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var item in document.RootElement.EnumerateArray())
|
||||
{
|
||||
var release = ParseRelease(item);
|
||||
if (release is null || release.IsDraft)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return release;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string> GetResponseTextAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"GitHub API request failed with HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
private static GitHubReleaseInfo? ParseRelease(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tagName = element.TryGetProperty("tag_name", out var tagNode)
|
||||
? tagNode.GetString()?.Trim()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tagName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = element.TryGetProperty("name", out var nameNode)
|
||||
? nameNode.GetString()?.Trim() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
var isPrerelease = element.TryGetProperty("prerelease", out var prereleaseNode) &&
|
||||
prereleaseNode.ValueKind == JsonValueKind.True;
|
||||
|
||||
var isDraft = element.TryGetProperty("draft", out var draftNode) &&
|
||||
draftNode.ValueKind == JsonValueKind.True;
|
||||
|
||||
var publishedAt = DateTimeOffset.MinValue;
|
||||
if (element.TryGetProperty("published_at", out var publishedAtNode) &&
|
||||
publishedAtNode.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var publishedAtText = publishedAtNode.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(publishedAtText) &&
|
||||
DateTimeOffset.TryParse(
|
||||
publishedAtText,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal,
|
||||
out var parsedPublishedAt))
|
||||
{
|
||||
publishedAt = parsedPublishedAt;
|
||||
}
|
||||
}
|
||||
|
||||
var assets = new List<GitHubReleaseAsset>();
|
||||
if (element.TryGetProperty("assets", out var assetsNode) && assetsNode.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var assetNode in assetsNode.EnumerateArray())
|
||||
{
|
||||
if (assetNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var assetName = assetNode.TryGetProperty("name", out var assetNameNode)
|
||||
? assetNameNode.GetString()?.Trim()
|
||||
: null;
|
||||
var browserDownloadUrl = assetNode.TryGetProperty("browser_download_url", out var urlNode)
|
||||
? urlNode.GetString()?.Trim()
|
||||
: null;
|
||||
var sizeBytes = assetNode.TryGetProperty("size", out var sizeNode) && sizeNode.TryGetInt64(out var size)
|
||||
? size
|
||||
: 0L;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(assetName) || string.IsNullOrWhiteSpace(browserDownloadUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes));
|
||||
}
|
||||
}
|
||||
|
||||
return new GitHubReleaseInfo(tagName, name, isPrerelease, isDraft, publishedAt, assets);
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
||||
{
|
||||
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var architectureToken = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.Arm64 => "arm64",
|
||||
Architecture.X86 => "x86",
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
var ranked = assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ToList();
|
||||
|
||||
return ranked.FirstOrDefault(x => x.Score > 0).Asset;
|
||||
}
|
||||
|
||||
private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetName))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
|
||||
if (assetName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 200;
|
||||
}
|
||||
else if (assetName.EndsWith(".msi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 160;
|
||||
}
|
||||
else
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (assetName.Contains("setup", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("installer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 60;
|
||||
}
|
||||
|
||||
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("x86", StringComparison.OrdinalIgnoreCase) ||
|
||||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score -= 30;
|
||||
}
|
||||
|
||||
if (assetName.Contains("portable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score -= 40;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool TryParseVersion(string? value, out Version? version)
|
||||
{
|
||||
version = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
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 = NormalizeVersion(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Version NormalizeVersion(Version version)
|
||||
{
|
||||
var major = Math.Max(0, version.Major);
|
||||
var minor = Math.Max(0, version.Minor);
|
||||
var build = Math.Max(0, version.Build);
|
||||
return new Version(major, minor, build);
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..maxLength];
|
||||
}
|
||||
}
|
||||
17
LanMountainDesktop/Services/ICalculatorDataService.cs
Normal file
17
LanMountainDesktop/Services/ICalculatorDataService.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface ICalculatorDataService
|
||||
{
|
||||
string ApplyInputToken(string currentInput, string token);
|
||||
|
||||
decimal ParseAmountOrZero(string? inputText);
|
||||
|
||||
string FormatAmount(decimal amount, int maxFractionDigits = 4);
|
||||
}
|
||||
|
||||
public static class CalculatorInputTokens
|
||||
{
|
||||
public const string Clear = "AC";
|
||||
public const string Backspace = "BACK";
|
||||
public const string DecimalPoint = ".";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IComponentInstanceSettingsStore
|
||||
{
|
||||
ComponentSettingsSnapshot Load();
|
||||
|
||||
void Save(ComponentSettingsSnapshot snapshot);
|
||||
|
||||
ComponentSettingsSnapshot LoadForComponent(string componentId, string? placementId);
|
||||
|
||||
void SaveForComponent(string componentId, string? placementId, ComponentSettingsSnapshot snapshot);
|
||||
|
||||
void DeleteForComponent(string componentId, string? placementId);
|
||||
|
||||
T LoadPluginSettings<T>(string componentId, string? placementId) where T : new();
|
||||
|
||||
void SavePluginSettings<T>(string componentId, string? placementId, T settings);
|
||||
|
||||
void DeletePluginSettings(string componentId, string? placementId);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Models;
|
||||
@@ -14,6 +15,43 @@ public sealed record DailyPoetryQuery(
|
||||
string? Locale = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record DailyNewsQuery(
|
||||
string? Locale = null,
|
||||
int? ItemCount = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record IfengNewsQuery(
|
||||
string? Locale = null,
|
||||
int? ItemCount = null,
|
||||
string? ChannelType = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record BilibiliHotSearchQuery(
|
||||
string? Locale = null,
|
||||
int? ItemCount = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record BaiduHotSearchQuery(
|
||||
string? Locale = null,
|
||||
int? ItemCount = null,
|
||||
string? SourceType = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record DailyWordQuery(
|
||||
string? Locale = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record Stcn24ForumPostsQuery(
|
||||
string? Locale = null,
|
||||
int? ItemCount = null,
|
||||
string? SourceType = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record ExchangeRateQuery(
|
||||
string? BaseCurrency = null,
|
||||
string? TargetCurrency = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record RecommendationQueryResult<T>(
|
||||
bool Success,
|
||||
T? Data,
|
||||
@@ -46,11 +84,207 @@ public sealed record RecommendationApiOptions
|
||||
|
||||
public string DomesticArtworkHost { get; init; } = "https://cn.bing.com";
|
||||
|
||||
public string CnrDailyNewsListUrl { get; init; } = "https://www.cnr.cn/newscenter/native/gd/";
|
||||
|
||||
public IReadOnlyList<string> CnrDailyNewsRssFeedUrls { get; init; } =
|
||||
[
|
||||
"https://www.cnr.cn/rss.xml",
|
||||
"https://news.cnr.cn/rss.xml",
|
||||
"https://www.cnr.cn/newscenter/native/gd/rss.xml",
|
||||
"https://news.cnr.cn/native/gd/rss.xml"
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> IfengNewsComprehensiveRssFeedUrls { get; init; } =
|
||||
[
|
||||
"https://rss.injahow.cn/ifeng/news",
|
||||
"https://rsshub.shuaizheng.org/ifeng/news"
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> IfengNewsMainlandRssFeedUrls { get; init; } =
|
||||
[
|
||||
"https://rss.injahow.cn/ifeng/news/shanklist/3-35197-/",
|
||||
"https://rsshub.shuaizheng.org/ifeng/news/shanklist/3-35197-/"
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> IfengNewsTaiwanRssFeedUrls { get; init; } =
|
||||
[
|
||||
"https://rss.injahow.cn/ifeng/news/shanklist/3-35199-/",
|
||||
"https://rsshub.shuaizheng.org/ifeng/news/shanklist/3-35199-/"
|
||||
];
|
||||
|
||||
public string IfengNewsComprehensiveListPageUrl { get; init; } = "https://news.ifeng.com/";
|
||||
|
||||
public string IfengNewsMainlandListPageUrl { get; init; } = "https://news.ifeng.com/shanklist/3-35197-/";
|
||||
|
||||
public string IfengNewsTaiwanListPageUrl { get; init; } = "https://news.ifeng.com/shanklist/3-35199-/";
|
||||
|
||||
public string BilibiliHotSearchApiTemplate { get; init; } =
|
||||
"https://api.bilibili.com/x/web-interface/search/square?limit={0}";
|
||||
|
||||
public string BilibiliSearchDefaultApiUrl { get; init; } =
|
||||
"https://api.bilibili.com/x/web-interface/search/default";
|
||||
|
||||
public string BilibiliSearchPageUrl { get; init; } = "https://search.bilibili.com/all";
|
||||
|
||||
public string BaiduHotSearchRssFeedUrl { get; init; } = "https://rss.aishort.top/?type=baidu";
|
||||
|
||||
public string BaiduHotSearchBoardUrl { get; init; } = "https://top.baidu.com/board?tab=realtime";
|
||||
|
||||
public string SmartTeachForumApiTemplate { get; init; } =
|
||||
"https://forum.smart-teach.cn/api/discussions?filter[q]={0}&sort=-createdAt&page[limit]={1}&include=user";
|
||||
|
||||
public string SmartTeachForumBaseUrl { get; init; } = "https://forum.smart-teach.cn";
|
||||
|
||||
public string SmartTeachStcnKeyword { get; init; } = "STCN";
|
||||
|
||||
public string YoudaoDictionaryApiTemplate { get; init; } = "https://dict.youdao.com/jsonapi?q={0}";
|
||||
|
||||
public string YoudaoDictionaryWordPageTemplate { get; init; } = "https://dict.youdao.com/w/eng/{0}/";
|
||||
|
||||
public string ExchangeRateApiTemplate { get; init; } = "https://open.er-api.com/v6/latest/{0}";
|
||||
|
||||
public IReadOnlyList<string> YoudaoDailyWordCandidates { get; init; } =
|
||||
[
|
||||
"illustrate",
|
||||
"resilient",
|
||||
"meticulous",
|
||||
"coherent",
|
||||
"subtle",
|
||||
"constrain",
|
||||
"tangible",
|
||||
"versatile",
|
||||
"pragmatic",
|
||||
"derive",
|
||||
"intricate",
|
||||
"notion",
|
||||
"facilitate",
|
||||
"sustain",
|
||||
"clarify",
|
||||
"convey",
|
||||
"nuance",
|
||||
"transform",
|
||||
"navigate",
|
||||
"align",
|
||||
"elevate",
|
||||
"refine",
|
||||
"vivid",
|
||||
"compile",
|
||||
"inspect",
|
||||
"aggregate",
|
||||
"optimize",
|
||||
"resonate",
|
||||
"persist",
|
||||
"adapt",
|
||||
"emerge",
|
||||
"concrete",
|
||||
"articulate",
|
||||
"validate",
|
||||
"insight",
|
||||
"concise",
|
||||
"robust",
|
||||
"reliable",
|
||||
"spectrum",
|
||||
"landscape",
|
||||
"context",
|
||||
"constraint",
|
||||
"iterative",
|
||||
"foundation",
|
||||
"priority",
|
||||
"workflow",
|
||||
"synthesize",
|
||||
"anchor",
|
||||
"precision",
|
||||
"momentum",
|
||||
"integrate",
|
||||
"observe",
|
||||
"structure",
|
||||
"essence",
|
||||
"framework",
|
||||
"drift",
|
||||
"discern",
|
||||
"compose",
|
||||
"modulate",
|
||||
"stability",
|
||||
"trajectory",
|
||||
"analyze",
|
||||
"diagnose",
|
||||
"mitigate",
|
||||
"transparent",
|
||||
"progressive",
|
||||
"boundary",
|
||||
"allocate",
|
||||
"evaluate",
|
||||
"reconcile",
|
||||
"strategic",
|
||||
"holistic",
|
||||
"incremental",
|
||||
"temporal",
|
||||
"semantic",
|
||||
"parallel",
|
||||
"explicit",
|
||||
"objective",
|
||||
"capacity",
|
||||
"durable",
|
||||
"scalable",
|
||||
"residual",
|
||||
"verify",
|
||||
"discover",
|
||||
"curate",
|
||||
"invoke",
|
||||
"artistry",
|
||||
"sincere",
|
||||
"substantive",
|
||||
"deliberate",
|
||||
"dynamic",
|
||||
"intentional",
|
||||
"initiative",
|
||||
"evidence",
|
||||
"infuse",
|
||||
"harmony",
|
||||
"vitality",
|
||||
"polish",
|
||||
"portrait",
|
||||
"rhythm",
|
||||
"accent",
|
||||
"gradient",
|
||||
"palette",
|
||||
"pattern",
|
||||
"eclipse",
|
||||
"horizon",
|
||||
"luminous",
|
||||
"serene",
|
||||
"vantage",
|
||||
"kinetic",
|
||||
"refactor",
|
||||
"calibrate",
|
||||
"orchestrate",
|
||||
"prototype",
|
||||
"curiosity",
|
||||
"discipline",
|
||||
"inscribe",
|
||||
"engage",
|
||||
"spark",
|
||||
"zenith",
|
||||
"clarity",
|
||||
"resolve",
|
||||
"aptitude"
|
||||
];
|
||||
|
||||
public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(20);
|
||||
|
||||
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8);
|
||||
|
||||
public int DefaultArtworkCandidateCount { get; init; } = 50;
|
||||
|
||||
public int DefaultDailyNewsCount { get; init; } = 2;
|
||||
|
||||
public int DefaultIfengNewsCount { get; init; } = 4;
|
||||
|
||||
public int DefaultBilibiliHotSearchCount { get; init; } = 5;
|
||||
|
||||
public int DefaultBaiduHotSearchCount { get; init; } = 4;
|
||||
|
||||
public int DefaultStcn24ForumPostCount { get; init; } = 4;
|
||||
}
|
||||
|
||||
public interface IRecommendationInfoService
|
||||
@@ -63,5 +297,33 @@ public interface IRecommendationInfoService
|
||||
DailyPoetryQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<DailyNewsSnapshot>> GetDailyNewsAsync(
|
||||
DailyNewsQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<DailyNewsSnapshot>> GetIfengNewsAsync(
|
||||
IfengNewsQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<BilibiliHotSearchSnapshot>> GetBilibiliHotSearchAsync(
|
||||
BilibiliHotSearchQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<BaiduHotSearchSnapshot>> GetBaiduHotSearchAsync(
|
||||
BaiduHotSearchQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<DailyWordSnapshot>> GetDailyWordAsync(
|
||||
DailyWordQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<Stcn24ForumPostsSnapshot>> GetStcn24ForumPostsAsync(
|
||||
Stcn24ForumPostsQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
|
||||
ExchangeRateQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
void ClearCache();
|
||||
}
|
||||
|
||||
242
LanMountainDesktop/Services/LauncherSettingsService.cs
Normal file
242
LanMountainDesktop/Services/LauncherSettingsService.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class LauncherSettingsService
|
||||
{
|
||||
public static event Action<string>? SettingsSaved;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private static readonly object CacheGate = new();
|
||||
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private static string? _cachedPath;
|
||||
private static LauncherSettingsSnapshot? _cachedSnapshot;
|
||||
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
|
||||
private static DateTime _lastProbeUtc = DateTime.MinValue;
|
||||
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _legacyAppSettingsPath;
|
||||
|
||||
public string InstanceId { get; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
public LauncherSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
_settingsPath = Path.Combine(settingsDirectory, "launcher-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
public LauncherSettingsSnapshot Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
LauncherSettingsSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = migratedSnapshot;
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new LauncherSettingsSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new LauncherSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
var snapshotToPersist = NormalizeSnapshot(snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
|
||||
|
||||
lock (CacheGate)
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
SettingsSaved?.Invoke(InstanceId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
nowUtc - _lastProbeUtc < CacheProbeInterval)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
writeTimeUtc == _cachedWriteTimeUtc)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private LauncherSettingsSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
var snapshot = JsonSerializer.Deserialize<LauncherSettingsSnapshot>(json, SerializerOptions);
|
||||
return NormalizeSnapshot(snapshot);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new LauncherSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadLegacySnapshot(out LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
snapshot = new LauncherSettingsSnapshot();
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_legacyAppSettingsPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
|
||||
var legacy = JsonSerializer.Deserialize<LegacyLauncherSettingsSnapshot>(legacyJson, SerializerOptions);
|
||||
if (legacy is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new LauncherSettingsSnapshot
|
||||
{
|
||||
HiddenLauncherFolderPaths = legacy.HiddenLauncherFolderPaths ?? [],
|
||||
HiddenLauncherAppPaths = legacy.HiddenLauncherAppPaths ?? []
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
|
||||
return File.Exists(_settingsPath)
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static LauncherSettingsSnapshot NormalizeSnapshot(LauncherSettingsSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new LauncherSettingsSnapshot();
|
||||
normalized.HiddenLauncherFolderPaths = NormalizeKeys(normalized.HiddenLauncherFolderPaths);
|
||||
normalized.HiddenLauncherAppPaths = NormalizeKeys(normalized.HiddenLauncherAppPaths);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeKeys(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void UpdateCache(LauncherSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
{
|
||||
_cachedPath = _settingsPath;
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
_cachedWriteTimeUtc = writeTimeUtc;
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
|
||||
private sealed class LegacyLauncherSettingsSnapshot
|
||||
{
|
||||
public List<string>? HiddenLauncherFolderPaths { get; set; }
|
||||
|
||||
public List<string>? HiddenLauncherAppPaths { get; set; }
|
||||
}
|
||||
}
|
||||
192
LanMountainDesktop/Services/LinuxDesktopEntryInstaller.cs
Normal file
192
LanMountainDesktop/Services/LinuxDesktopEntryInstaller.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal static class LinuxDesktopEntryInstaller
|
||||
{
|
||||
private const string DesktopFileName = "LanMountainDesktop.desktop";
|
||||
private const string IconFileName = "lanmountaindesktop.png";
|
||||
private const string IconName = "lanmountaindesktop";
|
||||
|
||||
public static void EnsureInstalled()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var executablePath = ResolveExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(executablePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dataHome = ResolveDataHome();
|
||||
if (string.IsNullOrWhiteSpace(dataHome))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var applicationsDir = Path.Combine(dataHome, "applications");
|
||||
var iconDir = Path.Combine(dataHome, "icons", "hicolor", "256x256", "apps");
|
||||
|
||||
Directory.CreateDirectory(applicationsDir);
|
||||
Directory.CreateDirectory(iconDir);
|
||||
|
||||
var desktopTargetPath = Path.Combine(applicationsDir, DesktopFileName);
|
||||
var iconTargetPath = Path.Combine(iconDir, IconFileName);
|
||||
|
||||
TryCopyBundledIcon(iconTargetPath);
|
||||
|
||||
var desktopEntryContent = BuildDesktopEntryContent(executablePath);
|
||||
WriteFileIfChanged(desktopTargetPath, desktopEntryContent);
|
||||
|
||||
TryRunCommand("chmod", "+x", executablePath);
|
||||
TryRunCommand("chmod", "+x", desktopTargetPath);
|
||||
TryRunCommand("update-desktop-database", applicationsDir);
|
||||
TryRunCommand("gtk-update-icon-cache", Path.Combine(dataHome, "icons", "hicolor"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep startup resilient if desktop integration fails.
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveExecutablePath()
|
||||
{
|
||||
var processPath = Environment.ProcessPath;
|
||||
if (!string.IsNullOrWhiteSpace(processPath))
|
||||
{
|
||||
return processPath;
|
||||
}
|
||||
|
||||
var commandLineArgs = Environment.GetCommandLineArgs();
|
||||
if (commandLineArgs.Length > 0 && !string.IsNullOrWhiteSpace(commandLineArgs[0]))
|
||||
{
|
||||
return commandLineArgs[0];
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string ResolveDataHome()
|
||||
{
|
||||
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
|
||||
if (!string.IsNullOrWhiteSpace(dataHome))
|
||||
{
|
||||
return dataHome.Trim();
|
||||
}
|
||||
|
||||
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrWhiteSpace(homePath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return Path.Combine(homePath, ".local", "share");
|
||||
}
|
||||
|
||||
private static void TryCopyBundledIcon(string iconTargetPath)
|
||||
{
|
||||
foreach (var candidatePath in EnumerateIconSourceCandidates())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(candidatePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
File.Copy(candidatePath, iconTargetPath, overwrite: true);
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore failures and continue trying fallbacks.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] EnumerateIconSourceCandidates()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
return
|
||||
[
|
||||
Path.Combine(baseDirectory, "share", "icons", "hicolor", "256x256", "apps", IconFileName),
|
||||
Path.Combine(baseDirectory, IconFileName)
|
||||
];
|
||||
}
|
||||
|
||||
private static string BuildDesktopEntryContent(string executablePath)
|
||||
{
|
||||
var escapedExecutablePath = executablePath.Replace("\"", "\\\"", StringComparison.Ordinal);
|
||||
return
|
||||
"[Desktop Entry]\n" +
|
||||
"Type=Application\n" +
|
||||
"Version=1.0\n" +
|
||||
"Name=LanMountainDesktop\n" +
|
||||
"Comment=LanMountainDesktop desktop shell\n" +
|
||||
$"Exec=\"{escapedExecutablePath}\" %U\n" +
|
||||
$"Icon={IconName}\n" +
|
||||
"Terminal=false\n" +
|
||||
"Categories=Utility;Education;\n" +
|
||||
"StartupWMClass=LanMountainDesktop\n";
|
||||
}
|
||||
|
||||
private static void WriteFileIfChanged(string filePath, string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var existing = File.ReadAllText(filePath);
|
||||
if (string.Equals(existing, content, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to attempt writing the content.
|
||||
}
|
||||
|
||||
File.WriteAllText(filePath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
}
|
||||
|
||||
private static void TryRunCommand(string fileName, params string[] arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true
|
||||
};
|
||||
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = process.WaitForExit(2_500);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore missing command or update failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
371
LanMountainDesktop/Services/LinuxDesktopEntryService.cs
Normal file
371
LanMountainDesktop/Services/LinuxDesktopEntryService.cs
Normal file
@@ -0,0 +1,371 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class LinuxDesktopEntryService
|
||||
{
|
||||
private static readonly Regex FieldCodeRegex =
|
||||
new(@"%[fFuUdDnNickvm]", RegexOptions.Compiled);
|
||||
|
||||
public StartMenuFolderNode Load()
|
||||
{
|
||||
var root = new StartMenuFolderNode("All Apps", string.Empty);
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return root;
|
||||
}
|
||||
|
||||
var seenDesktopIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var applicationsRoot in EnumerateApplicationsRoots())
|
||||
{
|
||||
foreach (var desktopFilePath in EnumerateDesktopFilesSafe(applicationsRoot))
|
||||
{
|
||||
if (!TryParseDesktopEntry(desktopFilePath, applicationsRoot, out var appEntry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenDesktopIds.Add(appEntry.RelativePath))
|
||||
{
|
||||
root.Apps.Add(appEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
root.Apps.Sort((left, right) =>
|
||||
string.Compare(left.DisplayName, right.DisplayName, CultureInfo.CurrentCulture, CompareOptions.IgnoreCase));
|
||||
return root;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateApplicationsRoots()
|
||||
{
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
|
||||
if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
dataHome = Path.Combine(homeDirectory, ".local", "share");
|
||||
}
|
||||
|
||||
var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share")
|
||||
.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var candidates = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(dataHome))
|
||||
{
|
||||
candidates.Add(Path.Combine(dataHome, "applications"));
|
||||
}
|
||||
|
||||
foreach (var dataDir in dataDirs)
|
||||
{
|
||||
candidates.Add(Path.Combine(dataDir, "applications"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "applications"));
|
||||
}
|
||||
|
||||
candidates.Add("/var/lib/flatpak/exports/share/applications");
|
||||
candidates.Add("/var/lib/snapd/desktop/applications");
|
||||
|
||||
return candidates
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateDesktopFilesSafe(string applicationsRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(applicationsRoot, "*.desktop", SearchOption.AllDirectories);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseDesktopEntry(string desktopFilePath, string applicationsRoot, out StartMenuAppEntry appEntry)
|
||||
{
|
||||
appEntry = null!;
|
||||
|
||||
Dictionary<string, string> fields;
|
||||
try
|
||||
{
|
||||
fields = ReadDesktopEntryFields(desktopFilePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!fields.TryGetValue("Type", out var entryType) ||
|
||||
!string.Equals(entryType, "Application", StringComparison.OrdinalIgnoreCase) ||
|
||||
GetBooleanField(fields, "NoDisplay") ||
|
||||
GetBooleanField(fields, "Hidden"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var displayName = GetPreferredName(fields);
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!fields.TryGetValue("Exec", out var execValue) ||
|
||||
!TryParseExec(execValue, out var launchExecutable, out var launchArguments))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.TryGetValue("TryExec", out var tryExecValue) &&
|
||||
!string.IsNullOrWhiteSpace(tryExecValue) &&
|
||||
!CommandExists(tryExecValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var desktopFileId = BuildDesktopFileId(desktopFilePath, applicationsRoot);
|
||||
var iconValue = fields.TryGetValue("Icon", out var iconFieldValue)
|
||||
? iconFieldValue
|
||||
: string.Empty;
|
||||
var workingDirectory = Path.IsPathRooted(launchExecutable)
|
||||
? Path.GetDirectoryName(launchExecutable)
|
||||
: null;
|
||||
|
||||
appEntry = new StartMenuAppEntry
|
||||
{
|
||||
DisplayName = displayName.Trim(),
|
||||
FilePath = desktopFilePath,
|
||||
RelativePath = desktopFileId,
|
||||
IconPngBytes = LinuxIconService.TryGetIconPngBytes(iconValue, Path.GetDirectoryName(desktopFilePath)),
|
||||
LaunchExecutable = launchExecutable,
|
||||
LaunchArguments = launchArguments,
|
||||
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadDesktopEntryFields(string desktopFilePath)
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var inDesktopEntrySection = false;
|
||||
foreach (var rawLine in File.ReadLines(desktopFilePath))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith('[') && line.EndsWith(']'))
|
||||
{
|
||||
inDesktopEntrySection = string.Equals(line, "[Desktop Entry]", StringComparison.OrdinalIgnoreCase);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inDesktopEntrySection)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separatorIndex = line.IndexOf('=');
|
||||
if (separatorIndex <= 0 || separatorIndex >= line.Length - 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = line[..separatorIndex].Trim();
|
||||
var value = line[(separatorIndex + 1)..].Trim();
|
||||
fields[key] = value;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static bool GetBooleanField(IReadOnlyDictionary<string, string> fields, string key)
|
||||
{
|
||||
return fields.TryGetValue(key, out var value) &&
|
||||
bool.TryParse(value, out var result) &&
|
||||
result;
|
||||
}
|
||||
|
||||
private static string GetPreferredName(IReadOnlyDictionary<string, string> fields)
|
||||
{
|
||||
if (TryGetLocalizedField(fields, "Name", out var localizedName))
|
||||
{
|
||||
return localizedName;
|
||||
}
|
||||
|
||||
return fields.TryGetValue("Name", out var fallbackName)
|
||||
? fallbackName
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static bool TryGetLocalizedField(IReadOnlyDictionary<string, string> fields, string baseKey, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
var uiCulture = CultureInfo.CurrentUICulture;
|
||||
var candidates = new[]
|
||||
{
|
||||
$"{baseKey}[{uiCulture.Name}]",
|
||||
$"{baseKey}[{uiCulture.TwoLetterISOLanguageName}]"
|
||||
};
|
||||
|
||||
foreach (var key in candidates)
|
||||
{
|
||||
if (fields.TryGetValue(key, out var localizedValue) &&
|
||||
!string.IsNullOrWhiteSpace(localizedValue))
|
||||
{
|
||||
value = localizedValue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildDesktopFileId(string desktopFilePath, string applicationsRoot)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(applicationsRoot, desktopFilePath)
|
||||
.Replace(Path.DirectorySeparatorChar, '-')
|
||||
.Replace(Path.AltDirectorySeparatorChar, '-');
|
||||
|
||||
return relativePath.Trim();
|
||||
}
|
||||
|
||||
private static bool TryParseExec(string execValue, out string launchExecutable, out List<string> launchArguments)
|
||||
{
|
||||
launchExecutable = string.Empty;
|
||||
launchArguments = [];
|
||||
|
||||
var tokens = TokenizeExec(execValue);
|
||||
if (tokens.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cleanedTokens = new List<string>(tokens.Count);
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedToken = token.Replace("%%", "%", StringComparison.Ordinal);
|
||||
if (normalizedToken.Length == 2 && normalizedToken[0] == '%')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedToken = FieldCodeRegex.Replace(normalizedToken, string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedToken))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
cleanedTokens.Add(normalizedToken);
|
||||
}
|
||||
|
||||
if (cleanedTokens.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
launchExecutable = cleanedTokens[0];
|
||||
launchArguments = cleanedTokens.Skip(1).ToList();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<string> TokenizeExec(string execValue)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
char quoteChar = '\0';
|
||||
|
||||
foreach (var c in execValue)
|
||||
{
|
||||
if ((c == '"' || c == '\'') &&
|
||||
(!inQuotes || quoteChar == c))
|
||||
{
|
||||
if (inQuotes)
|
||||
{
|
||||
inQuotes = false;
|
||||
quoteChar = '\0';
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = true;
|
||||
quoteChar = c;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsWhiteSpace(c) && !inQuotes)
|
||||
{
|
||||
if (current.Length > 0)
|
||||
{
|
||||
tokens.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
current.Append(c);
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
{
|
||||
tokens.Add(current.ToString());
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static bool CommandExists(string command)
|
||||
{
|
||||
var trimmedCommand = command.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedCommand))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(trimmedCommand))
|
||||
{
|
||||
return File.Exists(trimmedCommand);
|
||||
}
|
||||
|
||||
var pathEntries = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
foreach (var pathEntry in pathEntries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidate = Path.Combine(pathEntry, trimmedCommand);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore malformed PATH entries.
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
214
LanMountainDesktop/Services/LinuxIconService.cs
Normal file
214
LanMountainDesktop/Services/LinuxIconService.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal static class LinuxIconService
|
||||
{
|
||||
private static readonly string[] SupportedRasterExtensions =
|
||||
[
|
||||
".png",
|
||||
".ico"
|
||||
];
|
||||
|
||||
private static readonly Regex SizeDirectoryRegex =
|
||||
new(@"(?<size>\d{1,4})x\d{1,4}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly ConcurrentDictionary<string, string?> IconPathCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string? iconKey, string? desktopFileDirectory = null)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() || string.IsNullOrWhiteSpace(iconKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidatePath in ResolveIconCandidates(iconKey.Trim(), desktopFileDirectory))
|
||||
{
|
||||
if (TryReadIconBytes(candidatePath, out var bytes))
|
||||
{
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ResolveIconCandidates(string iconKey, string? desktopFileDirectory)
|
||||
{
|
||||
if (Path.HasExtension(iconKey))
|
||||
{
|
||||
var directPath = ExpandHome(iconKey);
|
||||
if (Path.IsPathRooted(directPath))
|
||||
{
|
||||
yield return directPath;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(desktopFileDirectory))
|
||||
{
|
||||
yield return Path.GetFullPath(Path.Combine(desktopFileDirectory, directPath));
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
var resolvedThemePath = ResolveThemedIconPath(iconKey);
|
||||
if (!string.IsNullOrWhiteSpace(resolvedThemePath))
|
||||
{
|
||||
yield return resolvedThemePath;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveThemedIconPath(string iconName)
|
||||
{
|
||||
return IconPathCache.GetOrAdd(iconName, static key => FindBestMatchingIconPath(key));
|
||||
}
|
||||
|
||||
private static string? FindBestMatchingIconPath(string iconName)
|
||||
{
|
||||
var candidates = new List<(string Path, int Score)>();
|
||||
foreach (var iconRoot in EnumerateIconRoots())
|
||||
{
|
||||
foreach (var extension in SupportedRasterExtensions)
|
||||
{
|
||||
foreach (var candidatePath in EnumerateFilesSafe(iconRoot, iconName + extension))
|
||||
{
|
||||
candidates.Add((candidatePath, ScoreIconPath(candidatePath)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.ThenBy(candidate => candidate.Path.Length)
|
||||
.Select(candidate => candidate.Path)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateIconRoots()
|
||||
{
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
|
||||
if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
dataHome = Path.Combine(homeDirectory, ".local", "share");
|
||||
}
|
||||
|
||||
var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share")
|
||||
.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var candidates = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(dataHome))
|
||||
{
|
||||
candidates.Add(Path.Combine(dataHome, "icons"));
|
||||
candidates.Add(Path.Combine(dataHome, "pixmaps"));
|
||||
}
|
||||
|
||||
foreach (var dataDir in dataDirs)
|
||||
{
|
||||
candidates.Add(Path.Combine(dataDir, "icons"));
|
||||
candidates.Add(Path.Combine(dataDir, "pixmaps"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
candidates.Add(Path.Combine(homeDirectory, ".icons"));
|
||||
candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "icons"));
|
||||
}
|
||||
|
||||
candidates.Add("/var/lib/flatpak/exports/share/icons");
|
||||
candidates.Add("/var/lib/snapd/desktop/icons");
|
||||
|
||||
return candidates
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateFilesSafe(string rootPath, string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(rootPath, fileName, SearchOption.AllDirectories);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadIconBytes(string filePath, out byte[] bytes)
|
||||
{
|
||||
bytes = [];
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (!SupportedRasterExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||
!File.Exists(filePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bytes = File.ReadAllBytes(filePath);
|
||||
return bytes.Length > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int ScoreIconPath(string filePath)
|
||||
{
|
||||
var score = 0;
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (extension.Equals(".png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 4_000;
|
||||
}
|
||||
else if (extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 2_000;
|
||||
}
|
||||
|
||||
if (filePath.Contains($"{Path.DirectorySeparatorChar}hicolor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 8_000;
|
||||
}
|
||||
|
||||
if (filePath.Contains($"{Path.DirectorySeparatorChar}apps{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 1_000;
|
||||
}
|
||||
|
||||
var match = SizeDirectoryRegex.Match(filePath);
|
||||
if (match.Success &&
|
||||
int.TryParse(match.Groups["size"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var size))
|
||||
{
|
||||
score += Math.Min(size, 512);
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static string ExpandHome(string path)
|
||||
{
|
||||
if (!path.StartsWith("~", StringComparison.Ordinal))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path.Length == 1
|
||||
? homeDirectory
|
||||
: Path.Combine(homeDirectory, path[2..]);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ public sealed class LocalizationService
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
// Defensive: tolerate accidentally duplicated UTF-8 BOM characters at file start.
|
||||
json = json.TrimStart('\uFEFF');
|
||||
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
|
||||
if (data is not null)
|
||||
{
|
||||
@@ -62,4 +64,3 @@ public sealed class LocalizationService
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
LanMountainDesktop/Services/PluginCatalogEntry.cs
Normal file
19
LanMountainDesktop/Services/PluginCatalogEntry.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public enum PluginCatalogSourceKind
|
||||
{
|
||||
Package = 0,
|
||||
Manifest = 1
|
||||
}
|
||||
|
||||
public sealed record PluginCatalogEntry(
|
||||
PluginManifest Manifest,
|
||||
string SourcePath,
|
||||
bool IsPackage,
|
||||
bool IsEnabled,
|
||||
bool IsLoaded,
|
||||
string? ErrorMessage,
|
||||
int SettingsPageCount,
|
||||
int WidgetCount);
|
||||
11
LanMountainDesktop/Services/PluginContributions.cs
Normal file
11
LanMountainDesktop/Services/PluginContributions.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record PluginSettingsPageContribution(
|
||||
LoadedPlugin Plugin,
|
||||
PluginSettingsPageRegistration Registration);
|
||||
|
||||
public sealed record PluginDesktopComponentContribution(
|
||||
LoadedPlugin Plugin,
|
||||
PluginDesktopComponentRegistration Registration);
|
||||
328
LanMountainDesktop/Services/PluginRuntimeService.cs
Normal file
328
LanMountainDesktop/Services/PluginRuntimeService.cs
Normal file
@@ -0,0 +1,328 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class PluginRuntimeService : IDisposable
|
||||
{
|
||||
private readonly PluginLoader _loader;
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly List<LoadedPlugin> _loadedPlugins = [];
|
||||
private readonly List<PluginLoadResult> _loadResults = [];
|
||||
private readonly List<PluginCatalogEntry> _catalog = [];
|
||||
private readonly List<PluginSettingsPageContribution> _settingsPages = [];
|
||||
private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
|
||||
|
||||
public PluginRuntimeService()
|
||||
{
|
||||
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
|
||||
_loader = new PluginLoader(CreateOptions());
|
||||
}
|
||||
|
||||
public string PluginsDirectory { get; }
|
||||
|
||||
public IReadOnlyList<LoadedPlugin> LoadedPlugins => _loadedPlugins;
|
||||
|
||||
public IReadOnlyList<PluginLoadResult> LoadResults => _loadResults;
|
||||
|
||||
public IReadOnlyList<PluginCatalogEntry> Catalog => _catalog;
|
||||
|
||||
public IReadOnlyList<PluginSettingsPageContribution> SettingsPages => _settingsPages;
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents;
|
||||
|
||||
public void LoadInstalledPlugins()
|
||||
{
|
||||
Directory.CreateDirectory(PluginsDirectory);
|
||||
UnloadInstalledPlugins();
|
||||
|
||||
var disabledPluginIds = GetDisabledPluginIds();
|
||||
var hostProperties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["HostApplicationName"] = "LanMountainDesktop",
|
||||
["HostVersion"] = typeof(App).Assembly.GetName().Version?.ToString(),
|
||||
["PluginSdkApiVersion"] = PluginSdkInfo.ApiVersion
|
||||
};
|
||||
|
||||
var discoveryFailures = new List<PluginLoadResult>();
|
||||
var candidates = DiscoverCandidates(discoveryFailures);
|
||||
_loadResults.AddRange(discoveryFailures);
|
||||
|
||||
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!selectedPluginIds.Add(candidate.Manifest.Id))
|
||||
{
|
||||
var duplicateFailure = PluginLoadResult.Failure(
|
||||
candidate.SourcePath,
|
||||
candidate.Manifest,
|
||||
new InvalidOperationException(
|
||||
$"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected."));
|
||||
_loadResults.Add(duplicateFailure);
|
||||
continue;
|
||||
}
|
||||
|
||||
var isEnabled = !disabledPluginIds.Contains(candidate.Manifest.Id);
|
||||
if (!isEnabled)
|
||||
{
|
||||
_catalog.Add(new PluginCatalogEntry(
|
||||
candidate.Manifest,
|
||||
candidate.SourcePath,
|
||||
candidate.SourceKind == PluginCatalogSourceKind.Package,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
0,
|
||||
0));
|
||||
continue;
|
||||
}
|
||||
|
||||
var loadResult = candidate.SourceKind switch
|
||||
{
|
||||
PluginCatalogSourceKind.Package => _loader.LoadFromPackage(
|
||||
candidate.SourcePath,
|
||||
PluginsDirectory,
|
||||
services: null,
|
||||
hostProperties),
|
||||
_ => _loader.LoadFromManifest(
|
||||
candidate.SourcePath,
|
||||
services: null,
|
||||
hostProperties)
|
||||
};
|
||||
|
||||
_loadResults.Add(loadResult);
|
||||
|
||||
if (loadResult.IsSuccess && loadResult.LoadedPlugin is not null)
|
||||
{
|
||||
_loadedPlugins.Add(loadResult.LoadedPlugin);
|
||||
CollectContributions(loadResult.LoadedPlugin);
|
||||
_catalog.Add(new PluginCatalogEntry(
|
||||
loadResult.LoadedPlugin.Manifest,
|
||||
loadResult.SourcePath,
|
||||
candidate.SourceKind == PluginCatalogSourceKind.Package,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
loadResult.LoadedPlugin.SettingsPages.Count,
|
||||
loadResult.LoadedPlugin.DesktopComponents.Count));
|
||||
Debug.WriteLine($"[PluginRuntime] Loaded '{loadResult.Manifest?.Id}' from '{loadResult.SourcePath}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
_catalog.Add(new PluginCatalogEntry(
|
||||
candidate.Manifest,
|
||||
candidate.SourcePath,
|
||||
candidate.SourceKind == PluginCatalogSourceKind.Package,
|
||||
true,
|
||||
false,
|
||||
loadResult.Error?.Message,
|
||||
0,
|
||||
0));
|
||||
Debug.WriteLine($"[PluginRuntime] Failed to load plugin from '{loadResult.SourcePath}': {loadResult.Error}");
|
||||
}
|
||||
|
||||
if (_catalog.Count == 0 && discoveryFailures.Count == 0)
|
||||
{
|
||||
Debug.WriteLine($"[PluginRuntime] No .laapp packages or loose plugin manifests found under '{PluginsDirectory}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetPluginEnabled(string pluginId, bool isEnabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pluginId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var disabledPluginIds = snapshot.DisabledPluginIds is { Count: > 0 }
|
||||
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var changed = isEnabled
|
||||
? disabledPluginIds.Remove(pluginId)
|
||||
: disabledPluginIds.Add(pluginId);
|
||||
|
||||
if (!changed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot.DisabledPluginIds = disabledPluginIds
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
_appSettingsService.Save(snapshot);
|
||||
|
||||
for (var i = 0; i < _catalog.Count; i++)
|
||||
{
|
||||
if (string.Equals(_catalog[i].Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_catalog[i] = _catalog[i] with { IsEnabled = isEnabled };
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
UnloadInstalledPlugins();
|
||||
}
|
||||
|
||||
private void UnloadInstalledPlugins()
|
||||
{
|
||||
for (var i = _loadedPlugins.Count - 1; i >= 0; i--)
|
||||
{
|
||||
_loadedPlugins[i].Dispose();
|
||||
}
|
||||
|
||||
_loadedPlugins.Clear();
|
||||
_loadResults.Clear();
|
||||
_catalog.Clear();
|
||||
_settingsPages.Clear();
|
||||
_desktopComponents.Clear();
|
||||
}
|
||||
|
||||
private HashSet<string> GetDisabledPluginIds()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
return snapshot.DisabledPluginIds is { Count: > 0 }
|
||||
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private IReadOnlyList<PluginCandidate> DiscoverCandidates(List<PluginLoadResult> failures)
|
||||
{
|
||||
var candidates = new List<PluginCandidate>();
|
||||
|
||||
foreach (var packagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = ReadManifestFromPackage(packagePath);
|
||||
candidates.Add(new PluginCandidate(packagePath, manifest, PluginCatalogSourceKind.Package));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add(PluginLoadResult.Failure(packagePath, null, ex));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var manifestPath in EnumerateCandidatePaths("plugin.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = PluginManifest.Load(manifestPath);
|
||||
candidates.Add(new PluginCandidate(manifestPath, manifest, PluginCatalogSourceKind.Manifest));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add(PluginLoadResult.Failure(manifestPath, null, ex));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
.OrderBy(candidate => candidate.SourceKind)
|
||||
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private IEnumerable<string> EnumerateCandidatePaths(string searchPattern)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(PluginsDirectory), ".runtime"));
|
||||
|
||||
return Directory
|
||||
.EnumerateFiles(PluginsDirectory, searchPattern, SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, "plugin.json", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain 'plugin.json'.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
{
|
||||
throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple 'plugin.json' files.");
|
||||
}
|
||||
|
||||
using var stream = entries[0].Open();
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
? path
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private static PluginLoaderOptions CreateOptions()
|
||||
{
|
||||
var options = new PluginLoaderOptions();
|
||||
AddSharedAssembly(options, typeof(App).Assembly);
|
||||
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
var assemblyName = assembly.GetName().Name;
|
||||
if (string.IsNullOrWhiteSpace(assemblyName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assemblyName.StartsWith("Avalonia", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(assemblyName, "MicroCom.Runtime", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddSharedAssembly(options, assembly);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static void AddSharedAssembly(PluginLoaderOptions options, Assembly assembly)
|
||||
{
|
||||
var assemblyName = assembly.GetName().Name;
|
||||
if (!string.IsNullOrWhiteSpace(assemblyName))
|
||||
{
|
||||
options.SharedAssemblyNames.Add(assemblyName);
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectContributions(LoadedPlugin loadedPlugin)
|
||||
{
|
||||
foreach (var settingsPage in loadedPlugin.SettingsPages)
|
||||
{
|
||||
_settingsPages.Add(new PluginSettingsPageContribution(loadedPlugin, settingsPage));
|
||||
}
|
||||
|
||||
foreach (var desktopComponent in loadedPlugin.DesktopComponents)
|
||||
{
|
||||
_desktopComponents.Add(new PluginDesktopComponentContribution(loadedPlugin, desktopComponent));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PluginCandidate(
|
||||
string SourcePath,
|
||||
PluginManifest Manifest,
|
||||
PluginCatalogSourceKind SourceKind);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,12 @@ using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
|
||||
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -54,8 +55,11 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
|
||||
private const double DialSize = 258;
|
||||
private const double Center = DialSize / 2;
|
||||
private string _componentId = BuiltInComponentIds.DesktopClock;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private TimeZoneService? _timeZoneService;
|
||||
private double _currentCellSize = 48;
|
||||
@@ -111,6 +115,21 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
UpdateClock();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopClock
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
InitializeDialIfNeeded();
|
||||
@@ -357,15 +376,16 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
|
||||
private void LoadClockSettings()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var configuredTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
|
||||
var configuredTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
|
||||
? "China Standard Time"
|
||||
: snapshot.DesktopClockTimeZoneId.Trim();
|
||||
: componentSnapshot.DesktopClockTimeZoneId.Trim();
|
||||
|
||||
_clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId);
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode);
|
||||
}
|
||||
|
||||
private void ApplySecondHandTimerInterval()
|
||||
|
||||
@@ -4,11 +4,12 @@ using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
public partial class AnalogClockWidgetSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> ZhTimeZoneNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -28,10 +29,13 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly TimeZoneService _timeZoneService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _componentId = BuiltInComponentIds.DesktopClock;
|
||||
private string _placementId = string.Empty;
|
||||
private IReadOnlyList<TimeZoneInfo> _allTimeZones = Array.Empty<TimeZoneInfo>();
|
||||
private string _selectedTimeZoneId = string.Empty;
|
||||
private string _secondHandMode = ClockSecondHandMode.Tick;
|
||||
@@ -46,14 +50,34 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
PopulateTimeZoneComboBox();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopClock
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
PopulateTimeZoneComboBox();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
PopulateTimeZoneComboBox();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
_selectedTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
_selectedTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
|
||||
? "China Standard Time"
|
||||
: snapshot.DesktopClockTimeZoneId.Trim();
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
|
||||
: componentSnapshot.DesktopClockTimeZoneId.Trim();
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode);
|
||||
|
||||
_allTimeZones = _timeZoneService
|
||||
.GetAllTimeZones()
|
||||
@@ -147,10 +171,10 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
_selectedTimeZoneId = normalizedId;
|
||||
_secondHandMode = GetSelectedSecondHandMode();
|
||||
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
snapshot.DesktopClockTimeZoneId = normalizedId;
|
||||
snapshot.DesktopClockSecondHandMode = _secondHandMode;
|
||||
_appSettingsService.Save(snapshot);
|
||||
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
|
||||
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<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="420"
|
||||
d:DesignHeight="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.BaiduHotSearchSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="Baidu hot search settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Configure source, auto refresh and refresh interval."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="10"
|
||||
Margin="0,0,6,0">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="SourceLabelTextBlock"
|
||||
Text="Data source"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="SourceComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnSourceSelectionChanged">
|
||||
<ComboBoxItem x:Name="SourceOfficialItem"
|
||||
Tag="Official"
|
||||
Content="Official Source" />
|
||||
<ComboBoxItem x:Name="SourceThirdPartyRssItem"
|
||||
Tag="ThirdPartyRss"
|
||||
Content="Third-party RSS" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="AutoRefreshLabelTextBlock"
|
||||
Text="Auto refresh"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<CheckBox x:Name="AutoRefreshCheckBox"
|
||||
Content="Enable auto refresh"
|
||||
Checked="OnAutoRefreshChanged"
|
||||
Unchecked="OnAutoRefreshChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="FrequencyCardBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||
Text="Refresh interval"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="FrequencyComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnFrequencySelectionChanged">
|
||||
<ComboBoxItem x:Name="Frequency5mItem"
|
||||
Tag="5"
|
||||
Content="5 min" />
|
||||
<ComboBoxItem x:Name="Frequency10mItem"
|
||||
Tag="10"
|
||||
Content="10 min" />
|
||||
<ComboBoxItem x:Name="Frequency15mItem"
|
||||
Tag="15"
|
||||
Content="15 min" />
|
||||
<ComboBoxItem x:Name="Frequency30mItem"
|
||||
Tag="30"
|
||||
Content="30 min" />
|
||||
<ComboBoxItem x:Name="Frequency1hItem"
|
||||
Tag="60"
|
||||
Content="1 hour" />
|
||||
<ComboBoxItem x:Name="Frequency3hItem"
|
||||
Tag="180"
|
||||
Content="3 hours" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class BaiduHotSearchSettingsWindow : UserControl
|
||||
{
|
||||
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public BaiduHotSearchSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeFrequencyOptions();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var sourceType = BaiduHotSearchSourceTypes.Normalize(componentSnapshot.BaiduHotSearchSourceType);
|
||||
var enabled = componentSnapshot.BaiduHotSearchAutoRefreshEnabled;
|
||||
var interval = NormalizeInterval(componentSnapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
|
||||
|
||||
_suppressEvents = true;
|
||||
SelectSourceType(sourceType);
|
||||
AutoRefreshCheckBox.IsChecked = enabled;
|
||||
SelectInterval(interval);
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("baiduhot.settings.title", "Baidu hot search settings");
|
||||
DescriptionTextBlock.Text = L("baiduhot.settings.desc", "Configure source, auto refresh and refresh interval.");
|
||||
SourceLabelTextBlock.Text = L("baiduhot.settings.source_label", "Data source");
|
||||
SourceOfficialItem.Content = L("baiduhot.settings.source_official", "Official Source");
|
||||
SourceThirdPartyRssItem.Content = L("baiduhot.settings.source_rss", "Third-party RSS");
|
||||
AutoRefreshLabelTextBlock.Text = L("baiduhot.settings.auto_refresh_label", "Auto refresh");
|
||||
AutoRefreshCheckBox.Content = L("baiduhot.settings.auto_refresh_enabled", "Enable auto refresh");
|
||||
FrequencyLabelTextBlock.Text = L("baiduhot.settings.frequency_label", "Refresh interval");
|
||||
ApplyFrequencyLocalization();
|
||||
}
|
||||
|
||||
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.BaiduHotSearchSourceType = GetSelectedSourceType();
|
||||
snapshot.BaiduHotSearchAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
snapshot.BaiduHotSearchAutoRefreshIntervalMinutes = GetSelectedInterval();
|
||||
_componentSettingsService.Save(snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private string GetSelectedSourceType()
|
||||
{
|
||||
if (SourceComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string sourceTag)
|
||||
{
|
||||
return BaiduHotSearchSourceTypes.Normalize(sourceTag);
|
||||
}
|
||||
|
||||
return BaiduHotSearchSourceTypes.Official;
|
||||
}
|
||||
|
||||
private int GetSelectedInterval()
|
||||
{
|
||||
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
return NormalizeInterval(minutes);
|
||||
}
|
||||
|
||||
return 15;
|
||||
}
|
||||
|
||||
private void SelectSourceType(string sourceType)
|
||||
{
|
||||
var normalizedSourceType = BaiduHotSearchSourceTypes.Normalize(sourceType);
|
||||
var selected = SourceComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string sourceTag &&
|
||||
string.Equals(BaiduHotSearchSourceTypes.Normalize(sourceTag), normalizedSourceType, StringComparison.OrdinalIgnoreCase));
|
||||
SourceComboBox.SelectedItem = selected ?? SourceComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private void SelectInterval(int intervalMinutes)
|
||||
{
|
||||
var selected = FrequencyComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes) &&
|
||||
minutes == intervalMinutes);
|
||||
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int NormalizeInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 15);
|
||||
}
|
||||
|
||||
private void InitializeFrequencyOptions()
|
||||
{
|
||||
FrequencyComboBox.Items.Clear();
|
||||
foreach (var minutes in SupportedIntervals)
|
||||
{
|
||||
FrequencyComboBox.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Tag = minutes.ToString(),
|
||||
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFrequencyLocalization()
|
||||
{
|
||||
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
|
||||
{
|
||||
if (item.Tag is not string tagText ||
|
||||
!int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
|
||||
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
|
||||
}
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
189
LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml
Normal file
189
LanMountainDesktop/Views/Components/BaiduHotSearchWidget.axaml
Normal file
@@ -0,0 +1,189 @@
|
||||
<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"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.BaiduHotSearchWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFCFD"
|
||||
CornerRadius="34"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="16,14,16,14">
|
||||
<Grid x:Name="ContentGrid"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||
RowSpacing="6">
|
||||
<Grid x:Name="HeaderGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="BrandTextBlock"
|
||||
Text="百度热搜"
|
||||
Foreground="#2932E1"
|
||||
FontSize="24"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="1"
|
||||
Width="34"
|
||||
Height="34"
|
||||
CornerRadius="17"
|
||||
Background="#EFF1F5"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
Focusable="False"
|
||||
ToolTip.Tip="刷新"
|
||||
Click="OnRefreshButtonClick">
|
||||
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
|
||||
Symbol="ArrowClockwise"
|
||||
IconVariant="Regular"
|
||||
Foreground="#5E6671"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="HotItem1Host"
|
||||
Grid.Row="1"
|
||||
Tag="0"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem1Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem1IndexTextBlock"
|
||||
Text="1"
|
||||
Foreground="#2932E1"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right"
|
||||
TextAlignment="Right" />
|
||||
<TextBlock x:Name="HotItem1TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="热搜内容"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem2Host"
|
||||
Grid.Row="2"
|
||||
Tag="1"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem2Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem2IndexTextBlock"
|
||||
Text="2"
|
||||
Foreground="#2932E1"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right"
|
||||
TextAlignment="Right" />
|
||||
<TextBlock x:Name="HotItem2TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="热搜内容"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem3Host"
|
||||
Grid.Row="3"
|
||||
Tag="2"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem3Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem3IndexTextBlock"
|
||||
Text="3"
|
||||
Foreground="#2932E1"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right"
|
||||
TextAlignment="Right" />
|
||||
<TextBlock x:Name="HotItem3TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="热搜内容"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem4Host"
|
||||
Grid.Row="4"
|
||||
Tag="3"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem4Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem4IndexTextBlock"
|
||||
Text="4"
|
||||
Foreground="#2932E1"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right"
|
||||
TextAlignment="Right" />
|
||||
<TextBlock x:Name="HotItem4TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="热搜内容"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Loading"
|
||||
Foreground="#6A6F77"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,623 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
|
||||
{
|
||||
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
||||
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
|
||||
|
||||
private const double BaseCellSize = 48d;
|
||||
private const int BaseWidthCells = 4;
|
||||
private const int BaseHeightCells = 2;
|
||||
private const int MaxDisplayItemCount = 4;
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMinutes(15)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly List<BaiduHotSearchItemSnapshot> _activeItems = [];
|
||||
private readonly List<HotItemVisual> _hotItemVisuals = [];
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private string _languageCode = "zh-CN";
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private string _sourceType = BaiduHotSearchSourceTypes.Official;
|
||||
private bool _isNightVisual = true;
|
||||
|
||||
private sealed record HotItemVisual(
|
||||
Border Host,
|
||||
Grid RowGrid,
|
||||
TextBlock IndexTextBlock,
|
||||
TextBlock TitleTextBlock);
|
||||
|
||||
public BaiduHotSearchWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
BrandTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem1IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem2IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem3IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem4IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem1TextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem2TextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem3TextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem4TextBlock.FontFamily = MiSansFontFamily;
|
||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem1Host, HotItem1Grid, HotItem1IndexTextBlock, HotItem1TextBlock));
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem2Host, HotItem2Grid, HotItem2IndexTextBlock, HotItem2TextBlock));
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem3Host, HotItem3Grid, HotItem3IndexTextBlock, HotItem3TextBlock));
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem4Host, HotItem4Grid, HotItem4IndexTextBlock, HotItem4TextBlock));
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
UpdateLanguageCode();
|
||||
ApplyAutoRefreshSettings();
|
||||
ApplyLoadingState();
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||
{
|
||||
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshHotSearchAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
_recommendationService.ClearCache();
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshHotSearchAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
UpdateRefreshButtonState();
|
||||
_ = RefreshHotSearchAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_isNightVisual = ResolveNightMode();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private bool ResolveNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ActualThemeVariant == ThemeVariant.Light)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||
value is ISolidColorBrush brush)
|
||||
{
|
||||
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double CalculateRelativeLuminance(Color color)
|
||||
{
|
||||
static double ToLinear(double channel)
|
||||
{
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
var r = ToLinear(color.R / 255d);
|
||||
var g = ToLinear(color.G / 255d);
|
||||
var b = ToLinear(color.B / 255d);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
private void ApplyNightModeVisual()
|
||||
{
|
||||
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
|
||||
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
|
||||
|
||||
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
|
||||
|
||||
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
|
||||
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
|
||||
|
||||
foreach (var visual in _hotItemVisuals)
|
||||
{
|
||||
visual.IndexTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
|
||||
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||
}
|
||||
|
||||
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
|
||||
}
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshHotSearchAsync(forceRefresh: true);
|
||||
}
|
||||
|
||||
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
await RefreshHotSearchAsync(forceRefresh: true);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async Task RefreshHotSearchAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
UpdateLanguageCode();
|
||||
UpdateRefreshButtonState();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var query = new BaiduHotSearchQuery(
|
||||
Locale: _languageCode,
|
||||
ItemCount: MaxDisplayItemCount,
|
||||
SourceType: _sourceType,
|
||||
ForceRefresh: forceRefresh);
|
||||
var result = await _recommendationService.GetBaiduHotSearchAsync(query, cts.Token);
|
||||
if (!_isAttached || cts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success || result.Data is null)
|
||||
{
|
||||
ApplyFailedState();
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySnapshot(result.Data);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore canceled requests.
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (_isAttached && !cts.IsCancellationRequested)
|
||||
{
|
||||
ApplyFailedState();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCts, cts))
|
||||
{
|
||||
_refreshCts = null;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
_isRefreshing = false;
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySnapshot(BaiduHotSearchSnapshot snapshot)
|
||||
{
|
||||
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
|
||||
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
|
||||
|
||||
_activeItems.Clear();
|
||||
foreach (var item in snapshot.Items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_activeItems.Add(item);
|
||||
if (_activeItems.Count >= MaxDisplayItemCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackText = L("baiduhot.widget.fallback_item", "暂无热搜");
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.IndexTextBlock.Text = (i + 1).ToString();
|
||||
visual.TitleTextBlock.Text = i < _activeItems.Count
|
||||
? NormalizeCompactText(_activeItems[i].Title)
|
||||
: fallbackText;
|
||||
}
|
||||
|
||||
StatusTextBlock.IsVisible = false;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
|
||||
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
|
||||
_activeItems.Clear();
|
||||
|
||||
var loadingText = L("baiduhot.widget.loading_item", "加载中...");
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.IndexTextBlock.Text = (i + 1).ToString();
|
||||
visual.TitleTextBlock.Text = loadingText;
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = L("baiduhot.widget.loading", "加载中...");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
|
||||
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
|
||||
_activeItems.Clear();
|
||||
|
||||
var fallbackText = L("baiduhot.widget.fallback_item", "暂无热搜");
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.IndexTextBlock.Text = (i + 1).ToString();
|
||||
visual.TitleTextBlock.Text = fallbackText;
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = L("baiduhot.widget.fetch_failed", "热搜获取失败");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void OnHotItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
|
||||
sender is not Border host ||
|
||||
host.Tag is null ||
|
||||
!int.TryParse(host.Tag.ToString(), out var index) ||
|
||||
index < 0 ||
|
||||
index >= _activeItems.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenUrl(_activeItems[index].Url);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var softScale = Math.Clamp(scale, 0.84, 1.26);
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
|
||||
var horizontalPadding = Math.Clamp(16 * softScale, 8, 24);
|
||||
var verticalPadding = Math.Clamp(14 * softScale, 7, 20);
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
|
||||
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||
|
||||
var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d));
|
||||
var innerHeight = Math.Max(72, totalHeight - (verticalPadding * 2d));
|
||||
var rowSpacing = Math.Clamp(6 * softScale, 2, 9);
|
||||
ContentGrid.RowSpacing = rowSpacing;
|
||||
HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16);
|
||||
|
||||
var availableRowsHeight = Math.Max(40, innerHeight - rowSpacing * 4d);
|
||||
var minTopRowHeight = Math.Clamp(22 * softScale, 18, 34);
|
||||
var topRowHeight = Math.Clamp(availableRowsHeight * 0.30, minTopRowHeight, 54);
|
||||
var lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
|
||||
var minLineRowHeight = Math.Clamp(13 * softScale, 11, 24);
|
||||
if (lineRowHeight < minLineRowHeight)
|
||||
{
|
||||
lineRowHeight = minLineRowHeight;
|
||||
topRowHeight = Math.Max(minTopRowHeight, availableRowsHeight - lineRowHeight * 4d);
|
||||
lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
|
||||
}
|
||||
|
||||
if (ContentGrid.RowDefinitions.Count >= 5)
|
||||
{
|
||||
ContentGrid.RowDefinitions[0].Height = new GridLength(topRowHeight);
|
||||
for (var i = 1; i <= 4; i++)
|
||||
{
|
||||
ContentGrid.RowDefinitions[i].Height = new GridLength(lineRowHeight);
|
||||
}
|
||||
}
|
||||
|
||||
BrandTextBlock.FontSize = Math.Clamp(topRowHeight * 0.48, 12, 24);
|
||||
BrandTextBlock.MaxWidth = Math.Max(80, innerWidth - Math.Clamp(topRowHeight * 0.84, 20, 46));
|
||||
|
||||
var refreshButtonSize = Math.Clamp(topRowHeight * 0.84, 20, 46);
|
||||
RefreshButton.Width = refreshButtonSize;
|
||||
RefreshButton.Height = refreshButtonSize;
|
||||
RefreshButton.CornerRadius = new CornerRadius(refreshButtonSize / 2d);
|
||||
RefreshGlyphIcon.FontSize = Math.Clamp(refreshButtonSize * 0.46, 10, 20);
|
||||
|
||||
var lineColumnGap = Math.Clamp(lineRowHeight * 0.34, 5, 12);
|
||||
var indexWidth = Math.Clamp(lineRowHeight * 1.02, 16, 28);
|
||||
var indexFont = Math.Clamp(lineRowHeight * 0.50, 10, 16);
|
||||
var itemFont = Math.Clamp(lineRowHeight * 0.62, 12, 24);
|
||||
var rowPadding = Math.Clamp(lineRowHeight * 0.08, 1, 4);
|
||||
var itemTextWidth = Math.Max(56, innerWidth - indexWidth - lineColumnGap);
|
||||
|
||||
foreach (var visual in _hotItemVisuals)
|
||||
{
|
||||
visual.RowGrid.ColumnSpacing = lineColumnGap;
|
||||
if (visual.RowGrid.ColumnDefinitions.Count > 0)
|
||||
{
|
||||
visual.RowGrid.ColumnDefinitions[0].Width = new GridLength(indexWidth, GridUnitType.Pixel);
|
||||
}
|
||||
|
||||
visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding);
|
||||
visual.IndexTextBlock.FontSize = indexFont;
|
||||
visual.IndexTextBlock.MaxWidth = indexWidth;
|
||||
visual.TitleTextBlock.FontSize = itemFont;
|
||||
visual.TitleTextBlock.MaxWidth = itemTextWidth;
|
||||
visual.TitleTextBlock.TextAlignment = TextAlignment.Left;
|
||||
}
|
||||
|
||||
StatusTextBlock.FontSize = Math.Clamp(itemFont, 10, 20);
|
||||
ApplyNightModeVisual();
|
||||
}
|
||||
|
||||
private void UpdateInteractionState()
|
||||
{
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url);
|
||||
visual.Host.IsHitTestVisible = enabled;
|
||||
visual.Host.Opacity = enabled ? 1.0 : 0.68;
|
||||
visual.Host.Cursor = enabled
|
||||
? new Cursor(StandardCursorType.Hand)
|
||||
: new Cursor(StandardCursorType.Arrow);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateRefreshButtonState()
|
||||
{
|
||||
var enabled = _isAttached && !_isRefreshing;
|
||||
RefreshButton.IsEnabled = enabled;
|
||||
RefreshButton.Opacity = enabled ? 1.0 : 0.65;
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 15;
|
||||
var sourceType = BaiduHotSearchSourceTypes.Official;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.BaiduHotSearchAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
|
||||
sourceType = BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
_sourceType = sourceType;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoRefreshEnabled)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
}
|
||||
else if (_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 15;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(15);
|
||||
}
|
||||
|
||||
private static string NormalizeCompactText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
|
||||
}
|
||||
|
||||
private static string? NormalizeHttpUrl(string? rawUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidate = rawUrl.Trim();
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
private void TryOpenUrl(string? rawUrl)
|
||||
{
|
||||
var normalizedUrl = NormalizeHttpUrl(rawUrl);
|
||||
if (string.IsNullOrWhiteSpace(normalizedUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = normalizedUrl,
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore malformed URLs or shell launch failures.
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var expectedWidth = _currentCellSize * BaseWidthCells;
|
||||
var expectedHeight = _currentCellSize * BaseHeightCells;
|
||||
if (expectedWidth <= 0 || expectedHeight <= 0)
|
||||
{
|
||||
return 1d;
|
||||
}
|
||||
|
||||
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
|
||||
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
|
||||
var scaleX = actualWidth / expectedWidth;
|
||||
var scaleY = actualHeight / expectedHeight;
|
||||
return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.8);
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
if (cts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<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="420"
|
||||
d:DesignHeight="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.BilibiliHotSearchSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="Bilibili hot search settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Configure auto refresh and refresh interval."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="10"
|
||||
Margin="0,0,6,0">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="AutoRefreshLabelTextBlock"
|
||||
Text="Auto refresh"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<CheckBox x:Name="AutoRefreshCheckBox"
|
||||
Content="Enable auto refresh"
|
||||
Checked="OnAutoRefreshChanged"
|
||||
Unchecked="OnAutoRefreshChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="FrequencyCardBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||
Text="Refresh interval"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="FrequencyComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnFrequencySelectionChanged">
|
||||
<ComboBoxItem x:Name="Frequency5mItem"
|
||||
Tag="5"
|
||||
Content="5 min" />
|
||||
<ComboBoxItem x:Name="Frequency10mItem"
|
||||
Tag="10"
|
||||
Content="10 min" />
|
||||
<ComboBoxItem x:Name="Frequency15mItem"
|
||||
Tag="15"
|
||||
Content="15 min" />
|
||||
<ComboBoxItem x:Name="Frequency30mItem"
|
||||
Tag="30"
|
||||
Content="30 min" />
|
||||
<ComboBoxItem x:Name="Frequency1hItem"
|
||||
Tag="60"
|
||||
Content="1 hour" />
|
||||
<ComboBoxItem x:Name="Frequency3hItem"
|
||||
Tag="180"
|
||||
Content="3 hours" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class BilibiliHotSearchSettingsWindow : UserControl
|
||||
{
|
||||
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public BilibiliHotSearchSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeFrequencyOptions();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var enabled = componentSnapshot.BilibiliHotSearchAutoRefreshEnabled;
|
||||
var interval = NormalizeInterval(componentSnapshot.BilibiliHotSearchAutoRefreshIntervalMinutes);
|
||||
|
||||
_suppressEvents = true;
|
||||
AutoRefreshCheckBox.IsChecked = enabled;
|
||||
SelectInterval(interval);
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("bilihot.settings.title", "Bilibili hot search settings");
|
||||
DescriptionTextBlock.Text = L("bilihot.settings.desc", "Configure auto refresh and refresh interval.");
|
||||
AutoRefreshLabelTextBlock.Text = L("bilihot.settings.auto_refresh_label", "Auto refresh");
|
||||
AutoRefreshCheckBox.Content = L("bilihot.settings.auto_refresh_enabled", "Enable auto refresh");
|
||||
FrequencyLabelTextBlock.Text = L("bilihot.settings.frequency_label", "Refresh interval");
|
||||
ApplyFrequencyLocalization();
|
||||
}
|
||||
|
||||
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.BilibiliHotSearchAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes = GetSelectedInterval();
|
||||
_componentSettingsService.Save(snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private int GetSelectedInterval()
|
||||
{
|
||||
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
return NormalizeInterval(minutes);
|
||||
}
|
||||
|
||||
return 15;
|
||||
}
|
||||
|
||||
private void SelectInterval(int intervalMinutes)
|
||||
{
|
||||
var selected = FrequencyComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes) &&
|
||||
minutes == intervalMinutes);
|
||||
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int NormalizeInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 15);
|
||||
}
|
||||
|
||||
private void InitializeFrequencyOptions()
|
||||
{
|
||||
FrequencyComboBox.Items.Clear();
|
||||
foreach (var minutes in SupportedIntervals)
|
||||
{
|
||||
FrequencyComboBox.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Tag = minutes.ToString(),
|
||||
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFrequencyLocalization()
|
||||
{
|
||||
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
|
||||
{
|
||||
if (item.Tag is not string tagText ||
|
||||
!int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
|
||||
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
|
||||
}
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<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"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.BilibiliHotSearchWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFCFD"
|
||||
CornerRadius="34"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="16,14,16,14">
|
||||
<Grid x:Name="ContentGrid"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||
RowSpacing="6">
|
||||
<Grid x:Name="HeaderGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="10">
|
||||
<Border x:Name="SearchBoxBorder"
|
||||
Height="38"
|
||||
CornerRadius="19"
|
||||
Background="#F1F2F4"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="10,0"
|
||||
HorizontalAlignment="Left"
|
||||
PointerPressed="OnSearchBoxPointerPressed">
|
||||
<Grid ColumnDefinitions="Auto,Auto"
|
||||
ColumnSpacing="6"
|
||||
VerticalAlignment="Center">
|
||||
<fi:SymbolIcon x:Name="SearchGlyphIcon"
|
||||
Symbol="Search"
|
||||
IconVariant="Regular"
|
||||
Foreground="#7A8088"
|
||||
FontSize="17"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="SearchEntryTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Search"
|
||||
Foreground="#7A8088"
|
||||
FontSize="18"
|
||||
FontWeight="Medium"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="TopRightTitleTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="bilibili热搜"
|
||||
Foreground="#F44C9F"
|
||||
FontSize="24"
|
||||
FontWeight="Bold"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="HotItem1Host"
|
||||
Grid.Row="1"
|
||||
Tag="0"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem1Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem1IndexTextBlock"
|
||||
Text="1"
|
||||
Foreground="#F44C9F"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock x:Name="HotItem1TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Trending Topic"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem2Host"
|
||||
Grid.Row="2"
|
||||
Tag="1"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem2Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem2IndexTextBlock"
|
||||
Text="2"
|
||||
Foreground="#F44C9F"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock x:Name="HotItem2TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Trending Topic"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem3Host"
|
||||
Grid.Row="3"
|
||||
Tag="2"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem3Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem3IndexTextBlock"
|
||||
Text="3"
|
||||
Foreground="#F44C9F"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock x:Name="HotItem3TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Trending Topic"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem4Host"
|
||||
Grid.Row="4"
|
||||
Tag="3"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem4Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem4IndexTextBlock"
|
||||
Text="4"
|
||||
Foreground="#F44C9F"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock x:Name="HotItem4TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Trending Topic"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Loading"
|
||||
Foreground="#6A6F77"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,648 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
|
||||
{
|
||||
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
||||
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
|
||||
|
||||
private const double BaseCellSize = 48d;
|
||||
private const int BaseWidthCells = 4;
|
||||
private const int BaseHeightCells = 2;
|
||||
private const int MaxDisplayItemCount = 4;
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMinutes(15)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly List<BilibiliHotSearchItemSnapshot> _activeItems = [];
|
||||
private readonly List<HotItemVisual> _hotItemVisuals = [];
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string? _searchPageUrl;
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private bool _isNightVisual = true;
|
||||
|
||||
private sealed record HotItemVisual(
|
||||
Border Host,
|
||||
Grid RowGrid,
|
||||
TextBlock IndexTextBlock,
|
||||
TextBlock TitleTextBlock);
|
||||
|
||||
public BilibiliHotSearchWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
SearchEntryTextBlock.FontFamily = MiSansFontFamily;
|
||||
TopRightTitleTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem1IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem2IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem3IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem4IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem1TextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem2TextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem3TextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem4TextBlock.FontFamily = MiSansFontFamily;
|
||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem1Host, HotItem1Grid, HotItem1IndexTextBlock, HotItem1TextBlock));
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem2Host, HotItem2Grid, HotItem2IndexTextBlock, HotItem2TextBlock));
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem3Host, HotItem3Grid, HotItem3IndexTextBlock, HotItem3TextBlock));
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem4Host, HotItem4Grid, HotItem4IndexTextBlock, HotItem4TextBlock));
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
UpdateLanguageCode();
|
||||
ApplyAutoRefreshSettings();
|
||||
ApplyLoadingState();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||
{
|
||||
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshHotSearchAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
_recommendationService.ClearCache();
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshHotSearchAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
_ = RefreshHotSearchAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_isNightVisual = ResolveNightMode();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private bool ResolveNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ActualThemeVariant == ThemeVariant.Light)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||
value is ISolidColorBrush brush)
|
||||
{
|
||||
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double CalculateRelativeLuminance(Color color)
|
||||
{
|
||||
static double ToLinear(double channel)
|
||||
{
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
var r = ToLinear(color.R / 255d);
|
||||
var g = ToLinear(color.G / 255d);
|
||||
var b = ToLinear(color.B / 255d);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
private void ApplyNightModeVisual()
|
||||
{
|
||||
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
|
||||
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
|
||||
|
||||
SearchBoxBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#ECF2FA"));
|
||||
SearchBoxBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#3FFFFFFF") : Color.Parse("#22000000"));
|
||||
SearchEntryTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||
SearchGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
|
||||
|
||||
TopRightTitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#F472C4") : Color.Parse("#F44C9F"));
|
||||
|
||||
foreach (var visual in _hotItemVisuals)
|
||||
{
|
||||
visual.IndexTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#F472C4") : Color.Parse("#F44C9F"));
|
||||
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||
}
|
||||
|
||||
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
|
||||
}
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshHotSearchAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private async Task RefreshHotSearchAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
UpdateLanguageCode();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var query = new BilibiliHotSearchQuery(
|
||||
Locale: _languageCode,
|
||||
ItemCount: MaxDisplayItemCount,
|
||||
ForceRefresh: forceRefresh);
|
||||
var result = await _recommendationService.GetBilibiliHotSearchAsync(query, cts.Token);
|
||||
if (!_isAttached || cts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success || result.Data is null)
|
||||
{
|
||||
ApplyFailedState();
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySnapshot(result.Data);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore canceled requests.
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (_isAttached && !cts.IsCancellationRequested)
|
||||
{
|
||||
ApplyFailedState();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCts, cts))
|
||||
{
|
||||
_refreshCts = null;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
_isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySnapshot(BilibiliHotSearchSnapshot snapshot)
|
||||
{
|
||||
SearchEntryTextBlock.Text = ResolveSearchEntryText(snapshot.SearchPlaceholder);
|
||||
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
|
||||
|
||||
_searchPageUrl = NormalizeHttpUrl(snapshot.SearchUrl) ?? BuildDefaultSearchPageUrl();
|
||||
|
||||
_activeItems.Clear();
|
||||
foreach (var item in snapshot.Items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_activeItems.Add(item);
|
||||
if (_activeItems.Count >= MaxDisplayItemCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackText = L("bilihot.widget.fallback_item", "暂无热搜");
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.IndexTextBlock.Text = (i + 1).ToString();
|
||||
visual.TitleTextBlock.Text = i < _activeItems.Count
|
||||
? NormalizeCompactText(_activeItems[i].Title)
|
||||
: fallbackText;
|
||||
}
|
||||
|
||||
StatusTextBlock.IsVisible = false;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
SearchEntryTextBlock.Text = L("bilihot.widget.search_entry", "搜索");
|
||||
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
|
||||
_searchPageUrl = BuildDefaultSearchPageUrl();
|
||||
_activeItems.Clear();
|
||||
|
||||
var loadingText = L("bilihot.widget.loading_item", "加载中...");
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.IndexTextBlock.Text = (i + 1).ToString();
|
||||
visual.TitleTextBlock.Text = loadingText;
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = L("bilihot.widget.loading", "加载中...");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
SearchEntryTextBlock.Text = L("bilihot.widget.search_entry", "搜索");
|
||||
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
|
||||
_searchPageUrl = BuildDefaultSearchPageUrl();
|
||||
_activeItems.Clear();
|
||||
|
||||
var fallbackText = L("bilihot.widget.fallback_item", "暂无热搜");
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.IndexTextBlock.Text = (i + 1).ToString();
|
||||
visual.TitleTextBlock.Text = fallbackText;
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = L("bilihot.widget.fetch_failed", "热搜获取失败");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private string ResolveSearchEntryText(string? placeholder)
|
||||
{
|
||||
var compact = NormalizeCompactText(placeholder);
|
||||
if (string.IsNullOrWhiteSpace(compact))
|
||||
{
|
||||
return L("bilihot.widget.search_entry", "搜索");
|
||||
}
|
||||
|
||||
return compact;
|
||||
}
|
||||
|
||||
private void OnSearchBoxPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenUrl(_searchPageUrl);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnHotItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
|
||||
sender is not Border host ||
|
||||
host.Tag is null ||
|
||||
!int.TryParse(host.Tag.ToString(), out var index) ||
|
||||
index < 0 ||
|
||||
index >= _activeItems.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenUrl(_activeItems[index].Url);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var softScale = Math.Clamp(scale, 0.84, 1.26);
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
|
||||
var horizontalPadding = Math.Clamp(16 * softScale, 8, 24);
|
||||
var verticalPadding = Math.Clamp(14 * softScale, 7, 20);
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
|
||||
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||
|
||||
var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d));
|
||||
var innerHeight = Math.Max(72, totalHeight - (verticalPadding * 2d));
|
||||
var rowSpacing = Math.Clamp(6 * softScale, 2, 9);
|
||||
ContentGrid.RowSpacing = rowSpacing;
|
||||
HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16);
|
||||
|
||||
var availableRowsHeight = Math.Max(40, innerHeight - rowSpacing * 4d);
|
||||
var minTopRowHeight = Math.Clamp(20 * softScale, 18, 34);
|
||||
var topRowHeight = Math.Clamp(availableRowsHeight * 0.27, minTopRowHeight, 52);
|
||||
var lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
|
||||
var minLineRowHeight = Math.Clamp(13 * softScale, 11, 24);
|
||||
if (lineRowHeight < minLineRowHeight)
|
||||
{
|
||||
lineRowHeight = minLineRowHeight;
|
||||
topRowHeight = Math.Max(minTopRowHeight, availableRowsHeight - lineRowHeight * 4d);
|
||||
lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
|
||||
}
|
||||
|
||||
if (ContentGrid.RowDefinitions.Count >= 5)
|
||||
{
|
||||
ContentGrid.RowDefinitions[0].Height = new GridLength(topRowHeight);
|
||||
for (var i = 1; i <= 4; i++)
|
||||
{
|
||||
ContentGrid.RowDefinitions[i].Height = new GridLength(lineRowHeight);
|
||||
}
|
||||
}
|
||||
|
||||
var searchBoxHeight = Math.Clamp(topRowHeight * 0.84, 20, 46);
|
||||
SearchBoxBorder.Height = searchBoxHeight;
|
||||
SearchBoxBorder.Width = Math.Clamp(innerWidth * 0.30, 80, 180);
|
||||
SearchBoxBorder.CornerRadius = new CornerRadius(searchBoxHeight / 2d);
|
||||
SearchBoxBorder.Padding = new Thickness(
|
||||
Math.Clamp(searchBoxHeight * 0.24, 5, 10),
|
||||
0,
|
||||
Math.Clamp(searchBoxHeight * 0.24, 5, 10),
|
||||
0);
|
||||
SearchGlyphIcon.FontSize = Math.Clamp(searchBoxHeight * 0.45, 10, 20);
|
||||
SearchEntryTextBlock.FontSize = Math.Clamp(searchBoxHeight * 0.44, 10, 18);
|
||||
|
||||
TopRightTitleTextBlock.MaxWidth = Math.Max(80, innerWidth - SearchBoxBorder.Width - HeaderGrid.ColumnSpacing);
|
||||
TopRightTitleTextBlock.FontSize = Math.Clamp(topRowHeight * 0.46, 11, 22);
|
||||
|
||||
var lineColumnGap = Math.Clamp(lineRowHeight * 0.34, 5, 12);
|
||||
var indexWidth = Math.Clamp(lineRowHeight * 1.02, 16, 28);
|
||||
var indexFont = Math.Clamp(lineRowHeight * 0.50, 10, 16);
|
||||
var itemFont = Math.Clamp(lineRowHeight * 0.62, 12, 24);
|
||||
var rowPadding = Math.Clamp(lineRowHeight * 0.08, 1, 4);
|
||||
var itemTextWidth = Math.Max(56, innerWidth - indexWidth - lineColumnGap);
|
||||
|
||||
foreach (var visual in _hotItemVisuals)
|
||||
{
|
||||
visual.RowGrid.ColumnSpacing = lineColumnGap;
|
||||
if (visual.RowGrid.ColumnDefinitions.Count > 0)
|
||||
{
|
||||
visual.RowGrid.ColumnDefinitions[0].Width = new GridLength(indexWidth, GridUnitType.Pixel);
|
||||
}
|
||||
|
||||
visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding);
|
||||
visual.IndexTextBlock.FontSize = indexFont;
|
||||
visual.IndexTextBlock.MaxWidth = indexWidth;
|
||||
visual.IndexTextBlock.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right;
|
||||
visual.IndexTextBlock.TextAlignment = TextAlignment.Right;
|
||||
visual.TitleTextBlock.FontSize = itemFont;
|
||||
visual.TitleTextBlock.MaxWidth = itemTextWidth;
|
||||
visual.TitleTextBlock.TextAlignment = TextAlignment.Left;
|
||||
}
|
||||
|
||||
StatusTextBlock.FontSize = Math.Clamp(itemFont, 10, 20);
|
||||
ApplyNightModeVisual();
|
||||
}
|
||||
|
||||
private void UpdateInteractionState()
|
||||
{
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url);
|
||||
visual.Host.IsHitTestVisible = enabled;
|
||||
visual.Host.Opacity = enabled ? 1.0 : 0.68;
|
||||
visual.Host.Cursor = enabled
|
||||
? new Cursor(StandardCursorType.Hand)
|
||||
: new Cursor(StandardCursorType.Arrow);
|
||||
}
|
||||
|
||||
var searchEnabled = !string.IsNullOrWhiteSpace(_searchPageUrl);
|
||||
SearchBoxBorder.IsHitTestVisible = searchEnabled;
|
||||
SearchBoxBorder.Opacity = searchEnabled ? 1.0 : 0.72;
|
||||
SearchBoxBorder.Cursor = searchEnabled
|
||||
? new Cursor(StandardCursorType.Hand)
|
||||
: new Cursor(StandardCursorType.Arrow);
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 15;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.BilibiliHotSearchAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoRefreshEnabled)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
}
|
||||
else if (_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 15;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(15);
|
||||
}
|
||||
|
||||
private static string NormalizeCompactText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
|
||||
}
|
||||
|
||||
private static string BuildDefaultSearchPageUrl()
|
||||
{
|
||||
return "https://search.bilibili.com/all";
|
||||
}
|
||||
|
||||
private static string? NormalizeHttpUrl(string? rawUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidate = rawUrl.Trim();
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
private void TryOpenUrl(string? rawUrl)
|
||||
{
|
||||
var normalizedUrl = NormalizeHttpUrl(rawUrl);
|
||||
if (string.IsNullOrWhiteSpace(normalizedUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = normalizedUrl,
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore malformed URLs or shell launch failures.
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var expectedWidth = _currentCellSize * BaseWidthCells;
|
||||
var expectedHeight = _currentCellSize * BaseHeightCells;
|
||||
if (expectedWidth <= 0 || expectedHeight <= 0)
|
||||
{
|
||||
return 1d;
|
||||
}
|
||||
|
||||
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
|
||||
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
|
||||
var scaleX = actualWidth / expectedWidth;
|
||||
var scaleY = actualHeight / expectedHeight;
|
||||
return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.8);
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
if (cts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
@@ -12,7 +12,7 @@ using WebViewCore.Events;
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
, IDesktopPageVisibilityAwareComponentWidget
|
||||
, IDesktopPageVisibilityAwareComponentWidget, IDisposable
|
||||
{
|
||||
private static readonly Uri DefaultHomeUri = new("https://www.bing.com");
|
||||
private double _currentCellSize = 48;
|
||||
@@ -22,6 +22,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
private bool _isEditMode;
|
||||
private bool _isWebViewActive = true;
|
||||
private readonly WebView2RuntimeAvailability _runtimeAvailability;
|
||||
private bool _isDisposed;
|
||||
|
||||
public BrowserWidget()
|
||||
{
|
||||
@@ -48,6 +49,26 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
NavigateTo(DefaultHomeUri);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
SizeChanged -= OnSizeChanged;
|
||||
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
|
||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
|
||||
if (_runtimeAvailability.IsAvailable)
|
||||
{
|
||||
BrowserWebView.NavigationStarting -= OnBrowserWebViewNavigationStarting;
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
|
||||
@@ -10,18 +10,22 @@ using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ClassScheduleSettingsWindow : UserControl
|
||||
public partial class ClassScheduleSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
|
||||
private string _activeScheduleId = string.Empty;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
@@ -33,13 +37,33 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
||||
RenderImportedSchedules();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopClassSchedule
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
RenderImportedSchedules();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
RenderImportedSchedules();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
_importedSchedules.Clear();
|
||||
foreach (var item in snapshot.ImportedClassSchedules)
|
||||
foreach (var item in componentSnapshot.ImportedClassSchedules)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Id) ||
|
||||
string.IsNullOrWhiteSpace(item.FilePath))
|
||||
@@ -55,7 +79,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
||||
});
|
||||
}
|
||||
|
||||
_activeScheduleId = snapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty;
|
||||
_activeScheduleId = componentSnapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty;
|
||||
if (_importedSchedules.Count > 0 &&
|
||||
!_importedSchedules.Any(item => string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
@@ -297,7 +321,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
snapshot.ImportedClassSchedules = _importedSchedules
|
||||
.Select(item => new ImportedClassScheduleSnapshot
|
||||
{
|
||||
@@ -307,7 +331,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
||||
})
|
||||
.ToList();
|
||||
snapshot.ActiveImportedClassScheduleId = _activeScheduleId ?? string.Empty;
|
||||
_appSettingsService.Save(snapshot);
|
||||
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
|
||||
public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private sealed record CourseItemViewModel(
|
||||
string Name,
|
||||
@@ -26,6 +27,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
|
||||
|
||||
@@ -34,6 +36,8 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
private IReadOnlyList<CourseItemViewModel> _courseItems = Array.Empty<CourseItemViewModel>();
|
||||
private bool _isNightVisual = true;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
public ClassScheduleWidget()
|
||||
{
|
||||
@@ -112,14 +116,30 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
RefreshSchedule();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopClassSchedule
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshSchedule();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshSchedule();
|
||||
}
|
||||
|
||||
private void RefreshSchedule()
|
||||
{
|
||||
var appSettings = _appSettingsService.Load();
|
||||
var componentSettings = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
UpdateHeader(now);
|
||||
|
||||
var importedSchedulePath = ResolveImportedSchedulePath(appSettings);
|
||||
var importedSchedulePath = ResolveImportedSchedulePath(componentSettings);
|
||||
var readResult = _scheduleService.Load(importedSchedulePath);
|
||||
if (!readResult.Success || readResult.Snapshot is null)
|
||||
{
|
||||
@@ -273,7 +293,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
return dayOfWeek.ToString()[..3];
|
||||
}
|
||||
|
||||
private static string? ResolveImportedSchedulePath(AppSettingsSnapshot snapshot)
|
||||
private static string? ResolveImportedSchedulePath(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
if (snapshot.ImportedClassSchedules.Count == 0)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<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="420"
|
||||
d:DesignHeight="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.CnrDailyNewsSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="CNR news settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Configure auto-rotation and refresh interval."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="10"
|
||||
Margin="0,0,6,0">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="AutoRotateLabelTextBlock"
|
||||
Text="Auto-rotation"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<CheckBox x:Name="AutoRotateCheckBox"
|
||||
Content="Enable auto-rotation"
|
||||
Checked="OnAutoRotateChanged"
|
||||
Unchecked="OnAutoRotateChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="FrequencyCardBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||
Text="Rotation interval"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="FrequencyComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnFrequencySelectionChanged">
|
||||
<ComboBoxItem x:Name="Frequency5mItem"
|
||||
Tag="5"
|
||||
Content="5 min" />
|
||||
<ComboBoxItem x:Name="Frequency10mItem"
|
||||
Tag="10"
|
||||
Content="10 min" />
|
||||
<ComboBoxItem x:Name="Frequency40mItem"
|
||||
Tag="40"
|
||||
Content="40 min" />
|
||||
<ComboBoxItem x:Name="Frequency1hItem"
|
||||
Tag="60"
|
||||
Content="1 hour" />
|
||||
<ComboBoxItem x:Name="Frequency12hItem"
|
||||
Tag="720"
|
||||
Content="12 hours" />
|
||||
<ComboBoxItem x:Name="Frequency24hItem"
|
||||
Tag="1440"
|
||||
Content="24 hours" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class CnrDailyNewsSettingsWindow : UserControl
|
||||
{
|
||||
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public CnrDailyNewsSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeFrequencyOptions();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var enabled = componentSnapshot.CnrDailyNewsAutoRotateEnabled;
|
||||
var interval = NormalizeInterval(componentSnapshot.CnrDailyNewsAutoRotateIntervalMinutes);
|
||||
|
||||
_suppressEvents = true;
|
||||
AutoRotateCheckBox.IsChecked = enabled;
|
||||
SelectInterval(interval);
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("cnrnews.settings.title", "CNR news settings");
|
||||
DescriptionTextBlock.Text = L("cnrnews.settings.desc", "Configure auto-rotation and refresh interval.");
|
||||
AutoRotateLabelTextBlock.Text = L("cnrnews.settings.auto_rotate_label", "Auto-rotation");
|
||||
AutoRotateCheckBox.Content = L("cnrnews.settings.auto_rotate_enabled", "Enable auto-rotation");
|
||||
FrequencyLabelTextBlock.Text = L("cnrnews.settings.frequency_label", "Rotation interval");
|
||||
ApplyFrequencyLocalization();
|
||||
}
|
||||
|
||||
private void OnAutoRotateChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enabled = AutoRotateCheckBox.IsChecked == true;
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.CnrDailyNewsAutoRotateEnabled = AutoRotateCheckBox.IsChecked == true;
|
||||
snapshot.CnrDailyNewsAutoRotateIntervalMinutes = GetSelectedInterval();
|
||||
_componentSettingsService.Save(snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private int GetSelectedInterval()
|
||||
{
|
||||
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
return NormalizeInterval(minutes);
|
||||
}
|
||||
|
||||
return 60;
|
||||
}
|
||||
|
||||
private void SelectInterval(int intervalMinutes)
|
||||
{
|
||||
var selected = FrequencyComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes) &&
|
||||
minutes == intervalMinutes);
|
||||
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int NormalizeInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 60);
|
||||
}
|
||||
|
||||
private void InitializeFrequencyOptions()
|
||||
{
|
||||
FrequencyComboBox.Items.Clear();
|
||||
foreach (var minutes in SupportedIntervals)
|
||||
{
|
||||
FrequencyComboBox.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Tag = minutes.ToString(),
|
||||
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFrequencyLocalization()
|
||||
{
|
||||
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
|
||||
{
|
||||
if (item.Tag is not string tagText ||
|
||||
!int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
|
||||
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
|
||||
}
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
148
LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml
Normal file
148
LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml
Normal file
@@ -0,0 +1,148 @@
|
||||
<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"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.CnrDailyNewsWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFCFD"
|
||||
CornerRadius="34"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="16,14,16,14">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto"
|
||||
RowSpacing="8">
|
||||
<Grid Grid.Row="0"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="BrandPrimaryTextBlock"
|
||||
Text="央广网"
|
||||
Foreground="#D6272E"
|
||||
FontSize="28"
|
||||
FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="BrandSecondaryTextBlock"
|
||||
Text="·头条"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="1"
|
||||
Width="116"
|
||||
Height="42"
|
||||
CornerRadius="21"
|
||||
Background="#F0F0F0"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="10,0"
|
||||
Focusable="False">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="4"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
|
||||
Symbol="ArrowClockwise"
|
||||
IconVariant="Regular"
|
||||
Foreground="#52575F"
|
||||
FontSize="19"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="RefreshLabelTextBlock"
|
||||
Text="换一换"
|
||||
Foreground="#202327"
|
||||
FontSize="25"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid x:Name="NewsItem1Grid"
|
||||
Grid.Row="1"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="12"
|
||||
PointerPressed="OnNewsItem1PointerPressed">
|
||||
<TextBlock x:Name="News1TitleTextBlock"
|
||||
Text="Headline"
|
||||
Foreground="#202327"
|
||||
FontSize="21"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top"
|
||||
LineHeight="24" />
|
||||
|
||||
<Border x:Name="News1ImageHost"
|
||||
Grid.Column="1"
|
||||
Width="160"
|
||||
Height="90"
|
||||
CornerRadius="16"
|
||||
ClipToBounds="True"
|
||||
Background="#E6E6E6">
|
||||
<Image x:Name="News1Image"
|
||||
Stretch="UniformToFill" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid x:Name="NewsItem2Grid"
|
||||
Grid.Row="2"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="12"
|
||||
PointerPressed="OnNewsItem2PointerPressed">
|
||||
<TextBlock x:Name="News2TitleTextBlock"
|
||||
Text="Headline"
|
||||
Foreground="#202327"
|
||||
FontSize="21"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top"
|
||||
LineHeight="24" />
|
||||
|
||||
<Border x:Name="News2ImageHost"
|
||||
Grid.Column="1"
|
||||
Width="160"
|
||||
Height="90"
|
||||
CornerRadius="16"
|
||||
ClipToBounds="True"
|
||||
Background="#E6E6E6">
|
||||
<Image x:Name="News2Image"
|
||||
Stretch="UniformToFill" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<StackPanel x:Name="ExtraNewsItemsPanel"
|
||||
Grid.Row="3"
|
||||
Spacing="6"
|
||||
IsVisible="False" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Loading"
|
||||
Foreground="#6A6F77"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
878
LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs
Normal file
878
LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs
Normal file
@@ -0,0 +1,878 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
|
||||
{
|
||||
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
||||
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
|
||||
private static readonly HttpClient ImageHttpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(8)
|
||||
};
|
||||
|
||||
private const string BrowserUserAgent =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36";
|
||||
|
||||
private const double BaseCellSize = 48d;
|
||||
private const int BaseWidthCells = 4;
|
||||
private const int BaseHeightCells = 2;
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRotateIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMinutes(30)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2];
|
||||
private readonly List<string?> _newsUrls = [];
|
||||
private readonly List<ExtraNewsRowVisual> _extraNewsRows = [];
|
||||
private IReadOnlyList<DailyNewsItemSnapshot> _activeNewsItems = [];
|
||||
private int _renderedNewsCount = 2;
|
||||
|
||||
private sealed class ExtraNewsRowVisual
|
||||
{
|
||||
public ExtraNewsRowVisual(
|
||||
Grid rootGrid,
|
||||
TextBlock titleTextBlock,
|
||||
Border imageHost,
|
||||
Image imageControl,
|
||||
int newsIndex)
|
||||
{
|
||||
RootGrid = rootGrid;
|
||||
TitleTextBlock = titleTextBlock;
|
||||
ImageHost = imageHost;
|
||||
ImageControl = imageControl;
|
||||
NewsIndex = newsIndex;
|
||||
}
|
||||
|
||||
public Grid RootGrid { get; }
|
||||
|
||||
public TextBlock TitleTextBlock { get; }
|
||||
|
||||
public Border ImageHost { get; }
|
||||
|
||||
public Image ImageControl { get; }
|
||||
|
||||
public int NewsIndex { get; }
|
||||
|
||||
public Bitmap? Bitmap { get; set; }
|
||||
}
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private string _languageCode = "zh-CN";
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRotateEnabled = true;
|
||||
private bool _isNightVisual = true;
|
||||
|
||||
public CnrDailyNewsWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
BrandPrimaryTextBlock.FontFamily = MiSansFontFamily;
|
||||
BrandSecondaryTextBlock.FontFamily = MiSansFontFamily;
|
||||
RefreshLabelTextBlock.FontFamily = MiSansFontFamily;
|
||||
News1TitleTextBlock.FontFamily = MiSansFontFamily;
|
||||
News2TitleTextBlock.FontFamily = MiSansFontFamily;
|
||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
RefreshButton.Click += OnRefreshButtonClick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
UpdateLanguageCode();
|
||||
ApplyAutoRotateSettings();
|
||||
ApplyLoadingState();
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||
{
|
||||
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshNewsAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
_recommendationService.ClearCache();
|
||||
ApplyAutoRotateSettings();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshNewsAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRotateSettings();
|
||||
UpdateRefreshButtonState();
|
||||
_ = RefreshNewsAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
DisposeNewsBitmaps();
|
||||
ClearExtraNewsRows();
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_isNightVisual = ResolveNightMode();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private bool ResolveNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ActualThemeVariant == ThemeVariant.Light)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||
value is ISolidColorBrush brush)
|
||||
{
|
||||
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double CalculateRelativeLuminance(Color color)
|
||||
{
|
||||
static double ToLinear(double channel)
|
||||
{
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
var r = ToLinear(color.R / 255d);
|
||||
var g = ToLinear(color.G / 255d);
|
||||
var b = ToLinear(color.B / 255d);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
private void ApplyNightModeVisual()
|
||||
{
|
||||
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
|
||||
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
|
||||
|
||||
BrandPrimaryTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||
BrandSecondaryTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#6A6F77"));
|
||||
|
||||
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
|
||||
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
|
||||
RefreshLabelTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
|
||||
|
||||
News1TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||
News2TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||
|
||||
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
|
||||
}
|
||||
|
||||
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RefreshNewsAsync(forceRefresh: true);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshNewsAsync(forceRefresh: true);
|
||||
}
|
||||
|
||||
private void OnNewsItem1PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenNewsUrl(0);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnNewsItem2PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenNewsUrl(1);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnExtraNewsItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
|
||||
sender is not Control control ||
|
||||
control.Tag is not int index)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenNewsUrl(index);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async Task RefreshNewsAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
UpdateRefreshButtonState();
|
||||
UpdateLanguageCode();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var query = new DailyNewsQuery(
|
||||
Locale: _languageCode,
|
||||
ItemCount: ResolveDesiredNewsItemCount(),
|
||||
ForceRefresh: forceRefresh);
|
||||
var result = await _recommendationService.GetDailyNewsAsync(query, cts.Token);
|
||||
if (!_isAttached || cts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success || result.Data is null)
|
||||
{
|
||||
ApplyFailedState();
|
||||
return;
|
||||
}
|
||||
|
||||
await ApplySnapshotAsync(result.Data, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore canceled requests.
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (_isAttached && !cts.IsCancellationRequested)
|
||||
{
|
||||
ApplyFailedState();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCts, cts))
|
||||
{
|
||||
_refreshCts = null;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
_isRefreshing = false;
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = snapshot.Items is null
|
||||
? []
|
||||
: snapshot.Items.Take(2).ToArray();
|
||||
_activeNewsItems = items;
|
||||
|
||||
var item1 = items.Length > 0 ? items[0] : null;
|
||||
var item2 = items.Length > 1 ? items[1] : null;
|
||||
|
||||
UpdateHotHeadlineText(item1?.Title);
|
||||
News2TitleTextBlock.Text = NormalizeCompactText(item2?.Title);
|
||||
|
||||
_newsUrls.Clear();
|
||||
foreach (var item in items)
|
||||
{
|
||||
_newsUrls.Add(NormalizeHttpUrl(item.Url));
|
||||
}
|
||||
|
||||
RenderExtraNewsRows([]);
|
||||
UpdateNewsInteractionState();
|
||||
|
||||
StatusTextBlock.IsVisible = false;
|
||||
UpdateAdaptiveLayout();
|
||||
|
||||
var loadTasks = new[]
|
||||
{
|
||||
TryDownloadBitmapAsync(item1?.ImageUrl, cancellationToken),
|
||||
TryDownloadBitmapAsync(item2?.ImageUrl, cancellationToken)
|
||||
};
|
||||
var bitmaps = await Task.WhenAll(loadTasks);
|
||||
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
||||
{
|
||||
bitmaps[0]?.Dispose();
|
||||
bitmaps[1]?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
SetNewsBitmap(0, bitmaps[0]);
|
||||
SetNewsBitmap(1, bitmaps[1]);
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
_activeNewsItems = [];
|
||||
_newsUrls.Clear();
|
||||
UpdateHotHeadlineText(L("cnrnews.widget.loading_title", "Loading headlines"));
|
||||
News2TitleTextBlock.Text = L("cnrnews.widget.loading_subtitle", "Please wait");
|
||||
StatusTextBlock.Text = L("cnrnews.widget.loading", "Loading...");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
SetNewsBitmap(0, null);
|
||||
SetNewsBitmap(1, null);
|
||||
RenderExtraNewsRows([]);
|
||||
UpdateNewsInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
_activeNewsItems = [];
|
||||
_newsUrls.Clear();
|
||||
News1TitleTextBlock.Inlines = null;
|
||||
News1TitleTextBlock.Text = L("cnrnews.widget.fallback_title", "CNR news is temporarily unavailable");
|
||||
News2TitleTextBlock.Text = L("cnrnews.widget.fallback_subtitle", "Tap refresh and try again");
|
||||
StatusTextBlock.Text = L("cnrnews.widget.fetch_failed", "News fetch failed");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
SetNewsBitmap(0, null);
|
||||
SetNewsBitmap(1, null);
|
||||
RenderExtraNewsRows([]);
|
||||
UpdateNewsInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private int ResolveDesiredNewsItemCount()
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
private void UpdateHotHeadlineText(string? title)
|
||||
{
|
||||
var normalizedTitle = NormalizeCompactText(title);
|
||||
var hotLabel = L("cnrnews.widget.hot_label", "Hot");
|
||||
var primaryForeground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||
if (News1TitleTextBlock.Inlines is null)
|
||||
{
|
||||
News1TitleTextBlock.Text = $"{hotLabel} | {normalizedTitle}";
|
||||
News1TitleTextBlock.Foreground = primaryForeground;
|
||||
return;
|
||||
}
|
||||
|
||||
News1TitleTextBlock.Inlines.Clear();
|
||||
News1TitleTextBlock.Inlines.Add(new Run($"{hotLabel} | ")
|
||||
{
|
||||
Foreground = new SolidColorBrush(Color.Parse("#D6272E")),
|
||||
FontWeight = FontWeight.SemiBold
|
||||
});
|
||||
News1TitleTextBlock.Inlines.Add(new Run(normalizedTitle)
|
||||
{
|
||||
Foreground = primaryForeground,
|
||||
FontWeight = FontWeight.SemiBold
|
||||
});
|
||||
}
|
||||
|
||||
private void RenderExtraNewsRows(IReadOnlyList<DailyNewsItemSnapshot> extraItems)
|
||||
{
|
||||
ClearExtraNewsRows();
|
||||
if (extraItems.Count == 0)
|
||||
{
|
||||
ExtraNewsItemsPanel.IsVisible = false;
|
||||
_renderedNewsCount = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < extraItems.Count; i++)
|
||||
{
|
||||
var item = extraItems[i];
|
||||
var itemIndex = i + 2;
|
||||
var rowGrid = new Grid
|
||||
{
|
||||
ColumnSpacing = 12,
|
||||
Tag = itemIndex,
|
||||
Cursor = new Cursor(StandardCursorType.Hand),
|
||||
IsHitTestVisible = true
|
||||
};
|
||||
rowGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star)));
|
||||
rowGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
|
||||
rowGrid.PointerPressed += OnExtraNewsItemPointerPressed;
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = NormalizeCompactText(item.Title),
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327")),
|
||||
FontFamily = MiSansFontFamily,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
|
||||
var imageHost = new Border
|
||||
{
|
||||
Width = 160,
|
||||
Height = 90,
|
||||
CornerRadius = new CornerRadius(16),
|
||||
ClipToBounds = true,
|
||||
Background = new SolidColorBrush(Color.Parse("#E6E6E6")),
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
var image = new Image
|
||||
{
|
||||
Stretch = Stretch.UniformToFill,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
imageHost.Child = image;
|
||||
Grid.SetColumn(imageHost, 1);
|
||||
|
||||
rowGrid.Children.Add(textBlock);
|
||||
rowGrid.Children.Add(imageHost);
|
||||
ExtraNewsItemsPanel.Children.Add(rowGrid);
|
||||
_extraNewsRows.Add(new ExtraNewsRowVisual(rowGrid, textBlock, imageHost, image, itemIndex));
|
||||
}
|
||||
|
||||
ExtraNewsItemsPanel.IsVisible = true;
|
||||
_renderedNewsCount = 2 + extraItems.Count;
|
||||
}
|
||||
|
||||
private void ClearExtraNewsRows()
|
||||
{
|
||||
foreach (var row in _extraNewsRows)
|
||||
{
|
||||
row.RootGrid.PointerPressed -= OnExtraNewsItemPointerPressed;
|
||||
if (ReferenceEquals(row.ImageControl.Source, row.Bitmap))
|
||||
{
|
||||
row.ImageControl.Source = null;
|
||||
}
|
||||
|
||||
row.Bitmap?.Dispose();
|
||||
row.Bitmap = null;
|
||||
}
|
||||
|
||||
_extraNewsRows.Clear();
|
||||
ExtraNewsItemsPanel.Children.Clear();
|
||||
}
|
||||
|
||||
private void SetExtraNewsBitmap(int rowIndex, Bitmap? bitmap)
|
||||
{
|
||||
if (rowIndex < 0 || rowIndex >= _extraNewsRows.Count)
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
var row = _extraNewsRows[rowIndex];
|
||||
if (ReferenceEquals(row.ImageControl.Source, row.Bitmap))
|
||||
{
|
||||
row.ImageControl.Source = null;
|
||||
}
|
||||
|
||||
row.Bitmap?.Dispose();
|
||||
row.Bitmap = bitmap;
|
||||
row.ImageControl.Source = bitmap;
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
CardBorder.Padding = new Thickness(
|
||||
Math.Clamp(16 * scale, 8, 24),
|
||||
Math.Clamp(14 * scale, 7, 22),
|
||||
Math.Clamp(16 * scale, 8, 24),
|
||||
Math.Clamp(14 * scale, 7, 22));
|
||||
|
||||
var headlineFont = Math.Clamp(24 * scale, 12, 34);
|
||||
BrandPrimaryTextBlock.FontSize = headlineFont;
|
||||
BrandSecondaryTextBlock.FontSize = headlineFont;
|
||||
|
||||
var refreshHeight = Math.Clamp(42 * scale, 24, 52);
|
||||
var refreshWidth = Math.Clamp(116 * scale, 76, 152);
|
||||
RefreshButton.Height = refreshHeight;
|
||||
RefreshButton.Width = refreshWidth;
|
||||
RefreshButton.CornerRadius = new CornerRadius(refreshHeight / 2d);
|
||||
RefreshGlyphIcon.FontSize = Math.Clamp(19 * scale, 11, 24);
|
||||
RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29);
|
||||
|
||||
var imageWidth = Math.Clamp(totalWidth * 0.20, 60, 170);
|
||||
var imageHeight = Math.Clamp(imageWidth * 0.56, 38, 94);
|
||||
News1ImageHost.Width = imageWidth;
|
||||
News1ImageHost.Height = imageHeight;
|
||||
News2ImageHost.Width = imageWidth;
|
||||
News2ImageHost.Height = imageHeight;
|
||||
News1ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
|
||||
News2ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
|
||||
|
||||
var columnGap = Math.Clamp(12 * scale, 6, 18);
|
||||
NewsItem1Grid.ColumnSpacing = columnGap;
|
||||
NewsItem2Grid.ColumnSpacing = columnGap;
|
||||
NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
||||
NewsItem2Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
||||
|
||||
var availableTextWidth = Math.Max(
|
||||
84,
|
||||
totalWidth - imageWidth - columnGap - Math.Clamp(20 * scale, 10, 32));
|
||||
News1TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
News2TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
|
||||
var newsFont = Math.Clamp(21 * scale, 10.5, 28);
|
||||
News1TitleTextBlock.FontSize = newsFont;
|
||||
News2TitleTextBlock.FontSize = newsFont;
|
||||
var mainNewsLineHeight = newsFont * 1.14;
|
||||
News1TitleTextBlock.LineHeight = mainNewsLineHeight;
|
||||
News2TitleTextBlock.LineHeight = mainNewsLineHeight;
|
||||
var mainNewsMinHeight = mainNewsLineHeight * 2;
|
||||
News1TitleTextBlock.MinHeight = mainNewsMinHeight;
|
||||
News2TitleTextBlock.MinHeight = mainNewsMinHeight;
|
||||
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
|
||||
News1TitleTextBlock.MaxLines = 2;
|
||||
News2TitleTextBlock.MaxLines = 2;
|
||||
|
||||
foreach (var row in _extraNewsRows)
|
||||
{
|
||||
row.RootGrid.ColumnSpacing = columnGap;
|
||||
if (row.RootGrid.ColumnDefinitions.Count > 1)
|
||||
{
|
||||
row.RootGrid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
||||
}
|
||||
|
||||
row.ImageHost.Width = imageWidth;
|
||||
row.ImageHost.Height = imageHeight;
|
||||
row.ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
|
||||
|
||||
row.TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
row.TitleTextBlock.FontSize = Math.Clamp(19 * scale, 10, 25);
|
||||
row.TitleTextBlock.LineHeight = row.TitleTextBlock.FontSize * 1.12;
|
||||
row.TitleTextBlock.MinHeight = row.TitleTextBlock.LineHeight * 2;
|
||||
row.TitleTextBlock.MaxLines = 2;
|
||||
}
|
||||
|
||||
ExtraNewsItemsPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
|
||||
|
||||
ApplyNightModeVisual();
|
||||
}
|
||||
|
||||
private void UpdateRefreshButtonState()
|
||||
{
|
||||
RefreshButton.IsEnabled = !_isRefreshing;
|
||||
RefreshButton.Opacity = _isAttached ? 1.0 : 0.85;
|
||||
RefreshGlyphIcon.Opacity = _isRefreshing ? 0.56 : 1.0;
|
||||
RefreshLabelTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0;
|
||||
}
|
||||
|
||||
private void UpdateNewsInteractionState()
|
||||
{
|
||||
var item1Enabled = _newsUrls.Count > 0 && !string.IsNullOrWhiteSpace(_newsUrls[0]);
|
||||
var item2Enabled = _newsUrls.Count > 1 && !string.IsNullOrWhiteSpace(_newsUrls[1]);
|
||||
|
||||
NewsItem1Grid.IsHitTestVisible = item1Enabled;
|
||||
NewsItem2Grid.IsHitTestVisible = item2Enabled;
|
||||
NewsItem1Grid.Opacity = item1Enabled ? 1.0 : 0.72;
|
||||
NewsItem2Grid.Opacity = item2Enabled ? 1.0 : 0.72;
|
||||
|
||||
foreach (var row in _extraNewsRows)
|
||||
{
|
||||
var index = row.NewsIndex;
|
||||
var enabled = index >= 0 && index < _newsUrls.Count && !string.IsNullOrWhiteSpace(_newsUrls[index]);
|
||||
row.RootGrid.IsHitTestVisible = enabled;
|
||||
row.RootGrid.Opacity = enabled ? 1.0 : 0.72;
|
||||
row.RootGrid.Cursor = enabled
|
||||
? new Cursor(StandardCursorType.Hand)
|
||||
: new Cursor(StandardCursorType.Arrow);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Bitmap?> TryDownloadBitmapAsync(string? imageUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedUrl = NormalizeHttpUrl(imageUrl);
|
||||
if (string.IsNullOrWhiteSpace(normalizedUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
|
||||
request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
|
||||
using var response = await ImageHttpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var memory = new MemoryStream();
|
||||
await stream.CopyToAsync(memory, cancellationToken);
|
||||
memory.Position = 0;
|
||||
return new Bitmap(memory);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryOpenNewsUrl(int index)
|
||||
{
|
||||
if (index < 0 || index >= _newsUrls.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedUrl = NormalizeHttpUrl(_newsUrls[index]);
|
||||
if (string.IsNullOrWhiteSpace(normalizedUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = normalizedUrl,
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore malformed URLs or shell launch failures.
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeHttpUrl(string? rawUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidate = rawUrl.Trim();
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
private void SetNewsBitmap(int index, Bitmap? bitmap)
|
||||
{
|
||||
if (index < 0 || index >= _newsBitmaps.Length)
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
var imageControl = index == 0 ? News1Image : News2Image;
|
||||
var oldBitmap = _newsBitmaps[index];
|
||||
if (ReferenceEquals(imageControl.Source, oldBitmap))
|
||||
{
|
||||
imageControl.Source = null;
|
||||
}
|
||||
|
||||
oldBitmap?.Dispose();
|
||||
_newsBitmaps[index] = bitmap;
|
||||
imageControl.Source = bitmap;
|
||||
}
|
||||
|
||||
private void DisposeNewsBitmaps()
|
||||
{
|
||||
SetNewsBitmap(0, null);
|
||||
SetNewsBitmap(1, null);
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAutoRotateSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 60;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.CnrDailyNewsAutoRotateEnabled;
|
||||
intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.CnrDailyNewsAutoRotateIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRotateEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoRotateEnabled)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
}
|
||||
else if (_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRotateIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 60;
|
||||
}
|
||||
|
||||
if (SupportedAutoRotateIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRotateIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(60);
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
if (cts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.0);
|
||||
var widthScale = Bounds.Width > 1
|
||||
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0)
|
||||
: 1;
|
||||
var heightScale = Bounds.Height > 1
|
||||
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0)
|
||||
: 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0);
|
||||
}
|
||||
|
||||
private static string NormalizeCompactText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,24 @@ using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class DailyArtworkSettingsWindow : UserControl
|
||||
public partial class DailyArtworkSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _suppressEvents;
|
||||
private string _componentId = BuiltInComponentIds.DesktopDailyArtwork;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public string CurrentSource => GetSelectedSource();
|
||||
|
||||
public DailyArtworkSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -25,12 +27,30 @@ public partial class DailyArtworkSettingsWindow : UserControl
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopDailyArtwork
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var source = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
||||
var source = DailyArtworkMirrorSources.Normalize(componentSnapshot.DailyArtworkMirrorSource);
|
||||
_suppressEvents = true;
|
||||
MirrorSourceComboBox.SelectedIndex = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
|
||||
? 0
|
||||
@@ -59,9 +79,9 @@ public partial class DailyArtworkSettingsWindow : UserControl
|
||||
}
|
||||
|
||||
var source = GetSelectedSource();
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
snapshot.DailyArtworkMirrorSource = source;
|
||||
_appSettingsService.Save(snapshot);
|
||||
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
|
||||
|
||||
UpdateSourceStatus(source);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
FontWeight="Bold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
MaxLines="4"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<Border x:Name="RightPanelSeparator"
|
||||
@@ -102,16 +102,16 @@
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2" />
|
||||
MaxLines="3" />
|
||||
<TextBlock x:Name="YearTextBlock"
|
||||
Text="1754"
|
||||
Foreground="#D7DCE3"
|
||||
FontSize="22"
|
||||
FontWeight="Medium"
|
||||
FontFeatures="tnum"
|
||||
TextWrapping="NoWrap"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
MaxLines="2" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -13,12 +13,13 @@ using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
|
||||
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<DayOfWeek, string> ZhWeekdays =
|
||||
new Dictionary<DayOfWeek, string>
|
||||
@@ -34,6 +35,9 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
||||
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||
private static readonly FontWeight[] TitleWeightCandidates = new[] { FontWeight.Bold, FontWeight.SemiBold, FontWeight.Medium, FontWeight.Normal };
|
||||
private static readonly FontWeight[] ArtistWeightCandidates = new[] { FontWeight.SemiBold, FontWeight.Medium, FontWeight.Normal };
|
||||
private static readonly FontWeight[] SecondaryWeightCandidates = new[] { FontWeight.Medium, FontWeight.Normal, FontWeight.Light };
|
||||
|
||||
private static readonly HttpClient ImageHttpClient = new()
|
||||
{
|
||||
@@ -55,6 +59,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
@@ -64,6 +69,8 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private string _componentId = BuiltInComponentIds.DesktopDailyArtwork;
|
||||
private string _placementId = string.Empty;
|
||||
private string? _currentArtworkSourceUrl;
|
||||
private string? _currentArtworkImageUrl;
|
||||
|
||||
@@ -133,6 +140,21 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopDailyArtwork
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
@@ -200,6 +222,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
var query = new DailyArtworkQuery(
|
||||
Locale: _languageCode,
|
||||
MirrorSource: ResolveMirrorSource(),
|
||||
ForceRefresh: forceRefresh);
|
||||
var result = await _recommendationService.GetDailyArtworkAsync(query, cts.Token);
|
||||
if (!_isAttached || cts.IsCancellationRequested)
|
||||
@@ -452,55 +475,98 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
var bottomStackSpacing = Math.Clamp(3 * scale, 2, 8);
|
||||
var reservedHeight = titleBottomMargin + separatorBottomMargin + bottomStackSpacing + 3;
|
||||
var textHeightBudget = Math.Max(24, rightContentHeight - reservedHeight);
|
||||
var titleHeightBudget = Math.Max(16, textHeightBudget * 0.54);
|
||||
var bottomTextBudget = Math.Max(10, textHeightBudget - titleHeightBudget);
|
||||
var artistHeightBudget = Math.Max(8, bottomTextBudget * 0.66);
|
||||
var yearHeightBudget = Math.Max(8, bottomTextBudget - artistHeightBudget);
|
||||
|
||||
var titleBase = Math.Clamp(44 * scale, 16, 58);
|
||||
PaintingTitleTextBlock.MaxWidth = rightContentWidth;
|
||||
PaintingTitleTextBlock.Margin = new Thickness(0, 0, 0, titleBottomMargin);
|
||||
PaintingTitleTextBlock.FontSize = FitFontSize(
|
||||
var artistBase = Math.Clamp(26 * scale, 11, 34);
|
||||
var yearBase = Math.Clamp(22 * scale, 10, 30);
|
||||
var titleMin = Math.Max(9.2, titleBase * 0.42);
|
||||
var artistMin = Math.Max(8.4, artistBase * 0.50);
|
||||
var yearMin = Math.Max(8.0, yearBase * 0.54);
|
||||
|
||||
var titleDemand = Math.Clamp(NormalizeCompactText(PaintingTitleTextBlock.Text).Length, 6, 96);
|
||||
var artistDemand = Math.Clamp(NormalizeCompactText(ArtistTextBlock.Text).Length, 4, 72);
|
||||
var yearDemand = Math.Clamp(NormalizeCompactText(YearTextBlock.Text).Length, 2, 48);
|
||||
|
||||
var minTitleHeight = Math.Max(10, titleMin * 1.10 * 2);
|
||||
var minArtistHeight = Math.Max(8, artistMin * 1.14);
|
||||
var minYearHeight = Math.Max(8, yearMin * 1.08);
|
||||
var minTextHeightTotal = minTitleHeight + minArtistHeight + minYearHeight;
|
||||
|
||||
double titleHeightBudget;
|
||||
double artistHeightBudget;
|
||||
double yearHeightBudget;
|
||||
if (textHeightBudget <= minTextHeightTotal + 0.6)
|
||||
{
|
||||
var compression = textHeightBudget / Math.Max(1, minTextHeightTotal);
|
||||
titleHeightBudget = Math.Max(9, minTitleHeight * compression);
|
||||
artistHeightBudget = Math.Max(7, minArtistHeight * compression);
|
||||
yearHeightBudget = Math.Max(7, minYearHeight * compression);
|
||||
}
|
||||
else
|
||||
{
|
||||
var extraHeight = textHeightBudget - minTextHeightTotal;
|
||||
var titleWeight = titleDemand + 8d;
|
||||
var artistWeight = artistDemand + 4d;
|
||||
var yearWeight = yearDemand + 2d;
|
||||
var weightSum = Math.Max(1d, titleWeight + artistWeight + yearWeight);
|
||||
|
||||
titleHeightBudget = minTitleHeight + extraHeight * (titleWeight / weightSum);
|
||||
artistHeightBudget = minArtistHeight + extraHeight * (artistWeight / weightSum);
|
||||
yearHeightBudget = minYearHeight + extraHeight * (yearWeight / weightSum);
|
||||
}
|
||||
|
||||
var titleLayout = FitAdaptiveTextLayout(
|
||||
PaintingTitleTextBlock.Text,
|
||||
rightContentWidth,
|
||||
titleHeightBudget,
|
||||
maxLines: 2,
|
||||
minFontSize: Math.Max(12, titleBase * 0.62),
|
||||
minLines: 2,
|
||||
maxLines: 5,
|
||||
minFontSize: titleMin,
|
||||
maxFontSize: titleBase,
|
||||
weight: FontWeight.Bold,
|
||||
lineHeightFactor: 1.12);
|
||||
PaintingTitleTextBlock.LineHeight = PaintingTitleTextBlock.FontSize * 1.12;
|
||||
weightCandidates: TitleWeightCandidates,
|
||||
lineHeightFactor: 1.10);
|
||||
PaintingTitleTextBlock.MaxWidth = rightContentWidth;
|
||||
PaintingTitleTextBlock.Margin = new Thickness(0, 0, 0, titleBottomMargin);
|
||||
PaintingTitleTextBlock.MaxLines = titleLayout.MaxLines;
|
||||
PaintingTitleTextBlock.FontWeight = titleLayout.Weight;
|
||||
PaintingTitleTextBlock.FontSize = titleLayout.FontSize;
|
||||
PaintingTitleTextBlock.LineHeight = titleLayout.LineHeight;
|
||||
|
||||
var artistBase = Math.Clamp(26 * scale, 11, 34);
|
||||
if (ArtistTextBlock.Parent is StackPanel artistInfoStack)
|
||||
{
|
||||
artistInfoStack.Spacing = bottomStackSpacing;
|
||||
}
|
||||
|
||||
ArtistTextBlock.MaxWidth = rightContentWidth;
|
||||
ArtistTextBlock.FontSize = FitFontSize(
|
||||
var artistLayout = FitAdaptiveTextLayout(
|
||||
ArtistTextBlock.Text,
|
||||
rightContentWidth,
|
||||
artistHeightBudget,
|
||||
maxLines: 2,
|
||||
minFontSize: Math.Max(10, artistBase * 0.72),
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
minFontSize: artistMin,
|
||||
maxFontSize: artistBase,
|
||||
weight: FontWeight.SemiBold,
|
||||
weightCandidates: ArtistWeightCandidates,
|
||||
lineHeightFactor: 1.14);
|
||||
ArtistTextBlock.LineHeight = ArtistTextBlock.FontSize * 1.14;
|
||||
ArtistTextBlock.MaxWidth = rightContentWidth;
|
||||
ArtistTextBlock.MaxLines = artistLayout.MaxLines;
|
||||
ArtistTextBlock.FontWeight = artistLayout.Weight;
|
||||
ArtistTextBlock.FontSize = artistLayout.FontSize;
|
||||
ArtistTextBlock.LineHeight = artistLayout.LineHeight;
|
||||
|
||||
var yearBase = Math.Clamp(22 * scale, 10, 30);
|
||||
YearTextBlock.MaxWidth = rightContentWidth;
|
||||
YearTextBlock.FontSize = FitFontSize(
|
||||
var yearLayout = FitAdaptiveTextLayout(
|
||||
YearTextBlock.Text,
|
||||
rightContentWidth,
|
||||
yearHeightBudget,
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Max(9.5, yearBase * 0.78),
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
minFontSize: yearMin,
|
||||
maxFontSize: yearBase,
|
||||
weight: FontWeight.Medium,
|
||||
weightCandidates: SecondaryWeightCandidates,
|
||||
lineHeightFactor: 1.08);
|
||||
YearTextBlock.LineHeight = YearTextBlock.FontSize * 1.08;
|
||||
YearTextBlock.MaxWidth = rightContentWidth;
|
||||
YearTextBlock.MaxLines = yearLayout.MaxLines;
|
||||
YearTextBlock.FontWeight = yearLayout.Weight;
|
||||
YearTextBlock.FontSize = yearLayout.FontSize;
|
||||
YearTextBlock.LineHeight = yearLayout.LineHeight;
|
||||
|
||||
RightPanelSeparator.Width = Math.Clamp(rightContentWidth * 0.58, 42, 136);
|
||||
RightPanelSeparator.Margin = new Thickness(0, 0, 0, separatorBottomMargin);
|
||||
@@ -594,6 +660,19 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveMirrorSource()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DailyArtworkMirrorSources.Overseas;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDateLabels()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
@@ -726,6 +805,170 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
return best;
|
||||
}
|
||||
|
||||
private static AdaptiveTextLayout FitAdaptiveTextLayout(
|
||||
string? text,
|
||||
double maxWidth,
|
||||
double maxHeight,
|
||||
int minLines,
|
||||
int maxLines,
|
||||
double minFontSize,
|
||||
double maxFontSize,
|
||||
FontWeight[] weightCandidates,
|
||||
double lineHeightFactor)
|
||||
{
|
||||
var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
|
||||
var safeMinLines = Math.Max(1, minLines);
|
||||
var safeMaxLines = Math.Max(safeMinLines, maxLines);
|
||||
var linesByHeight = ResolveMaxLinesByHeight(maxHeight, minFontSize, lineHeightFactor, safeMinLines, safeMaxLines);
|
||||
|
||||
var candidates = weightCandidates is { Length: > 0 }
|
||||
? weightCandidates
|
||||
: new[] { FontWeight.Normal };
|
||||
|
||||
AdaptiveTextLayout? best = null;
|
||||
foreach (var weight in candidates)
|
||||
{
|
||||
for (var lineLimit = linesByHeight; lineLimit >= safeMinLines; lineLimit--)
|
||||
{
|
||||
var fontSize = FitFontSize(
|
||||
content,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
lineLimit,
|
||||
minFontSize,
|
||||
maxFontSize,
|
||||
weight,
|
||||
lineHeightFactor);
|
||||
var lineHeight = fontSize * lineHeightFactor;
|
||||
var measuredSize = MeasureTextSize(content, fontSize, weight, Math.Max(1, maxWidth), lineHeight);
|
||||
var measuredLineCount = ResolveLineCount(measuredSize.Height, lineHeight);
|
||||
var overflowLines = Math.Max(0, measuredLineCount - lineLimit);
|
||||
var overflowHeight = Math.Max(0, measuredSize.Height - maxHeight);
|
||||
var overflowScore = overflowLines * 1000d + overflowHeight;
|
||||
var fitsCompletely = overflowLines == 0 && overflowHeight <= 0.6;
|
||||
var candidate = new AdaptiveTextLayout(fontSize, weight, lineLimit, lineHeight, overflowScore, fitsCompletely);
|
||||
|
||||
if (best is null || IsBetterAdaptiveTextCandidate(candidate, best.Value))
|
||||
{
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best is not null)
|
||||
{
|
||||
return best.Value;
|
||||
}
|
||||
|
||||
var fallbackFontSize = Math.Max(6, minFontSize);
|
||||
return new AdaptiveTextLayout(
|
||||
fallbackFontSize,
|
||||
FontWeight.Normal,
|
||||
safeMinLines,
|
||||
fallbackFontSize * lineHeightFactor,
|
||||
double.MaxValue,
|
||||
fitsCompletely: false);
|
||||
}
|
||||
|
||||
private static bool IsBetterAdaptiveTextCandidate(AdaptiveTextLayout candidate, AdaptiveTextLayout best)
|
||||
{
|
||||
if (candidate.FitsCompletely && !best.FitsCompletely)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!candidate.FitsCompletely && best.FitsCompletely)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.FitsCompletely && best.FitsCompletely)
|
||||
{
|
||||
if (candidate.FontSize > best.FontSize + 0.12)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 && candidate.MaxLines < best.MaxLines)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.OverflowScore < best.OverflowScore - 0.2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 &&
|
||||
candidate.FontSize > best.FontSize + 0.12)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 &&
|
||||
Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 &&
|
||||
candidate.MaxLines > best.MaxLines)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int ResolveMaxLinesByHeight(
|
||||
double maxHeight,
|
||||
double minFontSize,
|
||||
double lineHeightFactor,
|
||||
int minLines,
|
||||
int maxLines)
|
||||
{
|
||||
var safeMinLines = Math.Max(1, minLines);
|
||||
var safeMaxLines = Math.Max(safeMinLines, maxLines);
|
||||
var lineHeight = Math.Max(1, Math.Max(6, minFontSize) * lineHeightFactor);
|
||||
var maxHeightWithTolerance = Math.Max(1, maxHeight + 0.6);
|
||||
var linesByHeight = (int)Math.Floor(maxHeightWithTolerance / lineHeight);
|
||||
return Math.Clamp(linesByHeight, safeMinLines, safeMaxLines);
|
||||
}
|
||||
|
||||
private static int ResolveLineCount(double measuredHeight, double lineHeight)
|
||||
{
|
||||
return Math.Max(1, (int)Math.Ceiling(measuredHeight / Math.Max(1, lineHeight)));
|
||||
}
|
||||
|
||||
private readonly struct AdaptiveTextLayout
|
||||
{
|
||||
public AdaptiveTextLayout(
|
||||
double fontSize,
|
||||
FontWeight weight,
|
||||
int maxLines,
|
||||
double lineHeight,
|
||||
double overflowScore,
|
||||
bool fitsCompletely)
|
||||
{
|
||||
FontSize = fontSize;
|
||||
Weight = weight;
|
||||
MaxLines = Math.Max(1, maxLines);
|
||||
LineHeight = lineHeight;
|
||||
OverflowScore = overflowScore;
|
||||
FitsCompletely = fitsCompletely;
|
||||
}
|
||||
|
||||
public double FontSize { get; }
|
||||
|
||||
public FontWeight Weight { get; }
|
||||
|
||||
public int MaxLines { get; }
|
||||
|
||||
public double LineHeight { get; }
|
||||
|
||||
public double OverflowScore { get; }
|
||||
|
||||
public bool FitsCompletely { get; }
|
||||
}
|
||||
|
||||
private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight)
|
||||
{
|
||||
var probe = new TextBlock
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<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"
|
||||
@@ -14,9 +14,9 @@
|
||||
BorderThickness="0"
|
||||
Background="#C20A0A"
|
||||
Padding="20,16,20,14">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<Canvas x:Name="DayDecorationCanvas"
|
||||
Grid.RowSpan="3"
|
||||
Grid.RowSpan="2"
|
||||
IsVisible="False"
|
||||
Width="212"
|
||||
Height="148"
|
||||
@@ -43,34 +43,32 @@
|
||||
Data="M8,54 L38,24 L64,52 L8,54 Z" />
|
||||
</Canvas>
|
||||
|
||||
<TextBlock x:Name="QuoteMarkTextBlock"
|
||||
Text="“"
|
||||
Foreground="#5CFAD0B7"
|
||||
FontSize="96"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Margin="1,0,0,0"
|
||||
LineHeight="86" />
|
||||
<Grid Grid.Row="0" RowDefinitions="Auto,*">
|
||||
<TextBlock x:Name="QuoteMarkTextBlock"
|
||||
Text="“"
|
||||
Foreground="#5CFAD0B7"
|
||||
FontSize="72"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Margin="1,0,0,0"
|
||||
LineHeight="65" />
|
||||
|
||||
<TextBlock x:Name="PoetryContentTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="芳草年年惹恨幽。想前事悠悠。"
|
||||
Foreground="#F8D8A8"
|
||||
FontSize="54"
|
||||
FontWeight="Medium"
|
||||
LineHeight="60"
|
||||
TextWrapping="Wrap"
|
||||
VerticalAlignment="Top"
|
||||
Margin="8,2,0,0" />
|
||||
<TextBlock x:Name="PoetryContentTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="芳草年年惹恨幽。想前事悠悠。"
|
||||
Foreground="#F8D8A8"
|
||||
FontSize="54"
|
||||
FontWeight="Medium"
|
||||
LineHeight="60"
|
||||
TextWrapping="Wrap"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="8,2,0,0" />
|
||||
</Grid>
|
||||
|
||||
<Grid x:Name="AuthorPanel"
|
||||
Grid.Row="2"
|
||||
ColumnDefinitions="Auto,*"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,6,4,0"
|
||||
IsHitTestVisible="False">
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*" VerticalAlignment="Bottom" Margin="0,6,0,0">
|
||||
<Border x:Name="AuthorAccent"
|
||||
Grid.Column="0"
|
||||
Width="6"
|
||||
@@ -92,6 +90,7 @@
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Grid.Row="0"
|
||||
Text="Loading..."
|
||||
IsVisible="False"
|
||||
Foreground="#D9FFFFFF"
|
||||
@@ -100,10 +99,10 @@
|
||||
VerticalAlignment="Top" />
|
||||
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.RowSpan="3"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,12,16,0"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,8,4,0"
|
||||
Width="42"
|
||||
Height="42"
|
||||
CornerRadius="21"
|
||||
@@ -113,13 +112,12 @@
|
||||
Padding="0"
|
||||
Focusable="False">
|
||||
<TextBlock x:Name="RefreshGlyphTextBlock"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="↻"
|
||||
Text=""
|
||||
FontFamily="{StaticResource SymbolFontFamily}"
|
||||
FontSize="22"
|
||||
Foreground="#8C9097"
|
||||
FontSize="26"
|
||||
FontWeight="SemiLight"
|
||||
LineHeight="26" />
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
@@ -45,8 +45,8 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
|
||||
private const double BaseCellSize = 48d;
|
||||
private const int BaseWidthCells = 4;
|
||||
private const int BaseHeightCells = 2;
|
||||
private const double MinPoetryFontSize = 12;
|
||||
private const double MinAuthorFontSize = 10.5;
|
||||
private const double MinPoetryFontSize = 8;
|
||||
private const double MinAuthorFontSize = 7;
|
||||
|
||||
private readonly record struct TextFitResult(double FontSize, FontWeight FontWeight, double LineHeight);
|
||||
|
||||
@@ -109,7 +109,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
|
||||
0,
|
||||
0);
|
||||
|
||||
AuthorPanel.Margin = new Thickness(0, Math.Clamp(5 * scale, 2, 10), Math.Clamp(4 * scale, 2, 8), 0);
|
||||
AuthorAccent.Width = Math.Clamp(6 * scale, 3.2, 9.5);
|
||||
AuthorAccent.Height = Math.Clamp(24 * scale, 12, 34);
|
||||
AuthorAccent.Margin = new Thickness(0, 0, Math.Clamp(8 * scale, 4, 13), 0);
|
||||
@@ -351,11 +350,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
|
||||
|
||||
AuthorTextBlock.Foreground = CreateBrush("#F4D7A7");
|
||||
AuthorAccent.Background = CreateBrush("#63F2AF90");
|
||||
AuthorPanel.Margin = new Thickness(
|
||||
0,
|
||||
Math.Clamp(6 * scale, 2, 10),
|
||||
Math.Clamp(6 * scale, 2, 10),
|
||||
Math.Clamp(1 * scale, 0, 3));
|
||||
|
||||
DayDecorationCanvas.IsVisible = false;
|
||||
RefreshButton.IsVisible = true;
|
||||
@@ -380,11 +374,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
|
||||
|
||||
AuthorTextBlock.Foreground = CreateBrush("#272D38");
|
||||
AuthorAccent.Background = CreateBrush("#C8090D");
|
||||
AuthorPanel.Margin = new Thickness(
|
||||
0,
|
||||
Math.Clamp(6 * scale, 2, 10),
|
||||
Math.Clamp(6 * scale, 2, 10),
|
||||
Math.Clamp(2 * scale, 0, 4));
|
||||
|
||||
DayDecorationCanvas.IsVisible = true;
|
||||
RefreshButton.IsVisible = true;
|
||||
@@ -475,83 +464,19 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
|
||||
DayDecorationCanvas.IsVisible = showDayDecorations;
|
||||
RefreshButton.IsVisible = true;
|
||||
|
||||
var refreshReservedWidth = RefreshButton.Width + Math.Clamp(8 * scale, 5, 14);
|
||||
var decorationReservedWidth = showDayDecorations
|
||||
? Math.Clamp(innerWidth * 0.24, 34, 96)
|
||||
: 0;
|
||||
var quoteReservedWidth = QuoteMarkTextBlock.IsVisible
|
||||
? Math.Clamp(10 * scale, 5, 16)
|
||||
: 0;
|
||||
var poemReservedRight = Math.Max(refreshReservedWidth, decorationReservedWidth);
|
||||
var poemWidth = innerWidth - poemReservedRight - quoteReservedWidth;
|
||||
var poemMinWidth = Math.Max(66, innerWidth * 0.56);
|
||||
if (poemWidth < poemMinWidth)
|
||||
{
|
||||
poemWidth = poemMinWidth;
|
||||
}
|
||||
poemWidth = Math.Min(Math.Max(64, poemWidth), innerWidth);
|
||||
var refreshButtonWidth = 42 + Math.Clamp(8 * scale, 5, 14);
|
||||
var quoteMarkWidth = QuoteMarkTextBlock.IsVisible ? Math.Clamp(10 * scale, 5, 16) : 0;
|
||||
|
||||
var poemWidth = innerWidth - quoteMarkWidth - Math.Clamp(12 * scale, 6, 20);
|
||||
poemWidth = Math.Min(Math.Max(64, poemWidth), innerWidth - Math.Clamp(16 * scale, 8, 24));
|
||||
|
||||
var authorMaxLines = innerWidth < Math.Max(_currentCellSize * 5.2, 252) ? 2 : 1;
|
||||
var authorUnitsTarget = authorMaxLines == 1 ? 20 : 12;
|
||||
var authorWidth = Math.Max(72, Math.Min(innerWidth * (isNightMode ? 0.5 : 0.56), innerWidth - 8));
|
||||
var authorPrepared = PrepareAuthorText(_authorRawText, authorUnitsTarget, authorMaxLines);
|
||||
var authorPreferredFontSize = Math.Clamp((isNightMode ? 25 : 23) * scale, 12, 34);
|
||||
var authorMinFontSize = Math.Clamp(authorPreferredFontSize * 0.72, MinAuthorFontSize, authorPreferredFontSize);
|
||||
var authorMinWeight = isNightMode ? 500 : 470;
|
||||
var authorMaxWeight = isNightMode ? 650 : 600;
|
||||
authorPrepared = EnsureTextFitsAtMinSize(
|
||||
preparedText: authorPrepared,
|
||||
sourceText: _authorRawText,
|
||||
targetUnits: authorUnitsTarget,
|
||||
maxLines: authorMaxLines,
|
||||
maxWidth: authorWidth,
|
||||
maxHeight: Math.Max(20, innerHeight * (authorMaxLines > 1 ? 0.38 : 0.28)),
|
||||
minFontSize: authorMinFontSize,
|
||||
minFontWeight: ToVariableWeight(authorMinWeight),
|
||||
lineHeightFactor: 1.12);
|
||||
|
||||
var authorFit = FitTextStable(
|
||||
authorPrepared,
|
||||
authorWidth,
|
||||
Math.Max(20, innerHeight * (authorMaxLines > 1 ? 0.38 : 0.28)),
|
||||
minFontSize: authorMinFontSize,
|
||||
maxFontSize: Math.Clamp(authorPreferredFontSize * 1.15, authorMinFontSize, 42),
|
||||
maxLines: authorMaxLines,
|
||||
lineHeightFactor: 1.12,
|
||||
minWeight: authorMinWeight,
|
||||
maxWeight: authorMaxWeight);
|
||||
|
||||
AuthorTextBlock.Text = authorPrepared;
|
||||
AuthorTextBlock.TextWrapping = authorMaxLines > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap;
|
||||
AuthorTextBlock.MaxLines = authorMaxLines;
|
||||
AuthorTextBlock.MaxWidth = authorWidth;
|
||||
AuthorTextBlock.FontSize = authorFit.FontSize;
|
||||
AuthorTextBlock.LineHeight = authorFit.LineHeight;
|
||||
AuthorTextBlock.FontWeight = authorFit.FontWeight;
|
||||
AuthorPanel.MaxWidth = authorWidth + AuthorAccent.Width + AuthorAccent.Margin.Right + Math.Clamp(4 * scale, 2, 8);
|
||||
|
||||
var authorMeasured = MeasureTextSize(
|
||||
authorPrepared,
|
||||
authorFit.FontSize,
|
||||
authorFit.FontWeight,
|
||||
authorWidth,
|
||||
authorFit.LineHeight);
|
||||
var authorHeight = Math.Min(authorMeasured.Height, authorFit.LineHeight * authorMaxLines);
|
||||
var authorBlockHeight = Math.Max(authorHeight, AuthorAccent.Height) +
|
||||
AuthorPanel.Margin.Top +
|
||||
AuthorPanel.Margin.Bottom +
|
||||
Math.Clamp(4 * scale, 2, 8);
|
||||
|
||||
var poemMaxLines = innerHeight < _currentCellSize * 1.58
|
||||
? 4
|
||||
: innerHeight < _currentCellSize * 2.05
|
||||
? 3
|
||||
: 2;
|
||||
var poemMaxLines = 2;
|
||||
var poemUnitsTarget = EstimateTargetUnitsPerLine(poemWidth, scale, isNightMode);
|
||||
var poemPrepared = PreparePoetryText(_poetryRawText, poemUnitsTarget, poemMaxLines);
|
||||
var poemHeight = Math.Max(30, innerHeight - authorBlockHeight);
|
||||
var poemPreferredFontSize = Math.Clamp((isNightMode ? 34 : 32) * scale, 16, 56);
|
||||
var poemMinFontSize = Math.Clamp(poemPreferredFontSize * 0.72, MinPoetryFontSize, poemPreferredFontSize);
|
||||
|
||||
var availablePoemHeight = innerHeight * 0.72;
|
||||
var poemPreferredFontSize = Math.Clamp((isNightMode ? 34 : 32) * scale, 14, 56);
|
||||
var poemMinFontSize = Math.Clamp(poemPreferredFontSize * 0.65, MinPoetryFontSize, poemPreferredFontSize);
|
||||
var poemMinWeight = isNightMode ? 540 : 500;
|
||||
var poemMaxWeight = isNightMode ? 760 : 680;
|
||||
poemPrepared = EnsureTextFitsAtMinSize(
|
||||
@@ -560,19 +485,19 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
|
||||
targetUnits: poemUnitsTarget,
|
||||
maxLines: poemMaxLines,
|
||||
maxWidth: poemWidth,
|
||||
maxHeight: poemHeight,
|
||||
maxHeight: availablePoemHeight,
|
||||
minFontSize: poemMinFontSize,
|
||||
minFontWeight: ToVariableWeight(poemMinWeight),
|
||||
lineHeightFactor: 1.1);
|
||||
lineHeightFactor: 1.12);
|
||||
|
||||
var poemFit = FitTextStable(
|
||||
poemPrepared,
|
||||
poemWidth,
|
||||
poemHeight,
|
||||
availablePoemHeight,
|
||||
minFontSize: poemMinFontSize,
|
||||
maxFontSize: Math.Clamp(poemPreferredFontSize * 1.20, poemMinFontSize, 62),
|
||||
maxLines: poemMaxLines,
|
||||
lineHeightFactor: 1.10,
|
||||
lineHeightFactor: 1.12,
|
||||
minWeight: poemMinWeight,
|
||||
maxWeight: poemMaxWeight);
|
||||
|
||||
@@ -582,6 +507,43 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
|
||||
PoetryContentTextBlock.FontSize = poemFit.FontSize;
|
||||
PoetryContentTextBlock.LineHeight = poemFit.LineHeight;
|
||||
PoetryContentTextBlock.FontWeight = poemFit.FontWeight;
|
||||
|
||||
var authorWidth = Math.Max(72, Math.Min(innerWidth * (isNightMode ? 0.5 : 0.56), innerWidth - 8));
|
||||
var authorUnitsTarget = 20;
|
||||
var authorPrepared = PrepareAuthorText(_authorRawText, authorUnitsTarget, 1);
|
||||
var authorPreferredFontSize = Math.Clamp((isNightMode ? 25 : 23) * scale, 10, 34);
|
||||
var authorMinFontSize = Math.Clamp(authorPreferredFontSize * 0.65, MinAuthorFontSize, authorPreferredFontSize);
|
||||
var authorMinWeight = isNightMode ? 500 : 470;
|
||||
var authorMaxWeight = isNightMode ? 650 : 600;
|
||||
authorPrepared = EnsureTextFitsAtMinSize(
|
||||
preparedText: authorPrepared,
|
||||
sourceText: _authorRawText,
|
||||
targetUnits: authorUnitsTarget,
|
||||
maxLines: 1,
|
||||
maxWidth: authorWidth,
|
||||
maxHeight: AuthorAccent.Height,
|
||||
minFontSize: authorMinFontSize,
|
||||
minFontWeight: ToVariableWeight(authorMinWeight),
|
||||
lineHeightFactor: 1.12);
|
||||
|
||||
var authorFit = FitTextStable(
|
||||
authorPrepared,
|
||||
authorWidth,
|
||||
AuthorAccent.Height,
|
||||
minFontSize: authorMinFontSize,
|
||||
maxFontSize: Math.Clamp(authorPreferredFontSize * 1.15, authorMinFontSize, 42),
|
||||
maxLines: 1,
|
||||
lineHeightFactor: 1.12,
|
||||
minWeight: authorMinWeight,
|
||||
maxWeight: authorMaxWeight);
|
||||
|
||||
AuthorTextBlock.Text = authorPrepared;
|
||||
AuthorTextBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
AuthorTextBlock.MaxLines = 1;
|
||||
AuthorTextBlock.MaxWidth = authorWidth;
|
||||
AuthorTextBlock.FontSize = authorFit.FontSize;
|
||||
AuthorTextBlock.LineHeight = authorFit.LineHeight;
|
||||
AuthorTextBlock.FontWeight = authorFit.FontWeight;
|
||||
}
|
||||
|
||||
private void UpdateRefreshButtonState()
|
||||
|
||||
89
LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml
Normal file
89
LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml
Normal file
@@ -0,0 +1,89 @@
|
||||
<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"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="220"
|
||||
d:DesignHeight="220"
|
||||
x:Class="LanMountainDesktop.Views.Components.DailyWord2x2Widget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="30"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFBFA"
|
||||
CornerRadius="30"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="12,11,12,11"
|
||||
PointerPressed="OnCardPointerPressed">
|
||||
<Grid RowDefinitions="Auto,*"
|
||||
RowSpacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="6">
|
||||
<TextBlock x:Name="WordTextBlock"
|
||||
Text="design"
|
||||
Foreground="#2B2F35"
|
||||
FontSize="38"
|
||||
FontWeight="Bold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="1"
|
||||
Width="30"
|
||||
Height="30"
|
||||
CornerRadius="15"
|
||||
Background="#EEF1F4"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
Focusable="False"
|
||||
Click="OnRefreshButtonClick">
|
||||
<fi:SymbolIcon x:Name="RefreshIcon"
|
||||
Symbol="ArrowClockwise"
|
||||
IconVariant="Regular"
|
||||
FontSize="14"
|
||||
Foreground="#5E6671" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1">
|
||||
<TextBlock x:Name="MeaningTextBlock"
|
||||
Text="n. design; plan; layout"
|
||||
Foreground="#5A6069"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="5"
|
||||
IsVisible="False" />
|
||||
|
||||
<TextBlock x:Name="HiddenHintTextBlock"
|
||||
Text="Tap to reveal meaning"
|
||||
Foreground="#8A9099"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="4" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Loading..."
|
||||
Foreground="#6A6F77"
|
||||
FontSize="14"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
566
LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml.cs
Normal file
566
LanMountainDesktop/Views/Components/DailyWord2x2Widget.axaml.cs
Normal file
@@ -0,0 +1,566 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.VisualTree;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class DailyWord2x2Widget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
|
||||
{
|
||||
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
||||
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
|
||||
|
||||
private const double BaseCellSize = 48d;
|
||||
private const int BaseWidthCells = 2;
|
||||
private const int BaseHeightCells = 2;
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromHours(6)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private DailyWordSnapshot? _latestSnapshot;
|
||||
private string _languageCode = "zh-CN";
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private bool _isNightVisual = true;
|
||||
private bool _isMeaningVisible;
|
||||
|
||||
public DailyWord2x2Widget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
WordTextBlock.FontFamily = MiSansFontFamily;
|
||||
MeaningTextBlock.FontFamily = MiSansFontFamily;
|
||||
HiddenHintTextBlock.FontFamily = MiSansFontFamily;
|
||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
UpdateLanguageCode();
|
||||
ApplyAutoRefreshSettings();
|
||||
ApplyLoadingState();
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||
{
|
||||
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshWordAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
_recommendationService.ClearCache();
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshWordAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
UpdateRefreshButtonState();
|
||||
_ = RefreshWordAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_isNightVisual = ResolveNightMode();
|
||||
ApplyNightModeVisual();
|
||||
}
|
||||
|
||||
private bool ResolveNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ActualThemeVariant == ThemeVariant.Light)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||
value is ISolidColorBrush brush)
|
||||
{
|
||||
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double CalculateRelativeLuminance(Color color)
|
||||
{
|
||||
static double ToLinear(double channel)
|
||||
{
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
var r = ToLinear(color.R / 255d);
|
||||
var g = ToLinear(color.G / 255d);
|
||||
var b = ToLinear(color.B / 255d);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
private void ApplyNightModeVisual()
|
||||
{
|
||||
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFBFA"));
|
||||
|
||||
WordTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#2B2F35"));
|
||||
MeaningTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5A6069"));
|
||||
HiddenHintTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#8A9099"));
|
||||
|
||||
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EEF1F4"));
|
||||
RefreshIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
|
||||
|
||||
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
|
||||
}
|
||||
|
||||
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RefreshWordAsync(forceRefresh: true);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshWordAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnCardPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (_latestSnapshot is null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Source is Visual sourceVisual)
|
||||
{
|
||||
for (Visual? current = sourceVisual; current is not null; current = current.GetVisualParent())
|
||||
{
|
||||
if (ReferenceEquals(current, RefreshButton))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_isMeaningVisible = !_isMeaningVisible;
|
||||
UpdateRevealState();
|
||||
UpdateAdaptiveLayout();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async Task RefreshWordAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
UpdateRefreshButtonState();
|
||||
UpdateLanguageCode();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var query = new DailyWordQuery(
|
||||
Locale: _languageCode,
|
||||
ForceRefresh: forceRefresh);
|
||||
var result = await _recommendationService.GetDailyWordAsync(query, cts.Token);
|
||||
if (!_isAttached || cts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success || result.Data is null)
|
||||
{
|
||||
ApplyFailedState();
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySnapshot(result.Data);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore canceled requests.
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (_isAttached && !cts.IsCancellationRequested)
|
||||
{
|
||||
ApplyFailedState();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCts, cts))
|
||||
{
|
||||
_refreshCts = null;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
_isRefreshing = false;
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySnapshot(DailyWordSnapshot snapshot)
|
||||
{
|
||||
_latestSnapshot = snapshot;
|
||||
WordTextBlock.Text = NormalizeCompactText(snapshot.Word);
|
||||
MeaningTextBlock.Text = BuildMeaningPreview(snapshot.Meaning);
|
||||
HiddenHintTextBlock.Text = L("dailyword2x2.widget.tap_to_show", "Tap to reveal meaning");
|
||||
StatusTextBlock.IsVisible = false;
|
||||
|
||||
UpdateRevealState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
_latestSnapshot = null;
|
||||
_isMeaningVisible = false;
|
||||
WordTextBlock.Text = L("dailyword.widget.loading_word", "daily word");
|
||||
MeaningTextBlock.Text = L("dailyword.widget.loading_meaning", "Fetching meaning...");
|
||||
HiddenHintTextBlock.Text = L("dailyword.widget.loading", "Loading...");
|
||||
StatusTextBlock.Text = L("dailyword.widget.loading", "Loading...");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateRevealState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
_latestSnapshot = null;
|
||||
_isMeaningVisible = false;
|
||||
WordTextBlock.Text = L("dailyword.widget.fallback_word", "daily word");
|
||||
MeaningTextBlock.Text = L("dailyword.widget.fallback_meaning", "Youdao dictionary is temporarily unavailable.");
|
||||
HiddenHintTextBlock.Text = L("dailyword.widget.fetch_failed", "Daily word fetch failed");
|
||||
StatusTextBlock.Text = L("dailyword.widget.fetch_failed", "Daily word fetch failed");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateRevealState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void UpdateRevealState()
|
||||
{
|
||||
var canShowMeaning = _latestSnapshot is not null && !string.IsNullOrWhiteSpace(MeaningTextBlock.Text);
|
||||
var showMeaning = _isMeaningVisible && canShowMeaning;
|
||||
MeaningTextBlock.IsVisible = showMeaning;
|
||||
HiddenHintTextBlock.IsVisible = !showMeaning;
|
||||
|
||||
if (!showMeaning && _latestSnapshot is not null)
|
||||
{
|
||||
HiddenHintTextBlock.Text = L("dailyword2x2.widget.tap_to_show", "Tap to reveal meaning");
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * scale, 14, 40));
|
||||
CardBorder.CornerRadius = RootBorder.CornerRadius;
|
||||
CardBorder.Padding = new Thickness(
|
||||
Math.Clamp(12 * scale, 8, 18),
|
||||
Math.Clamp(11 * scale, 7, 16),
|
||||
Math.Clamp(12 * scale, 8, 18),
|
||||
Math.Clamp(11 * scale, 7, 16));
|
||||
|
||||
var refreshSize = Math.Clamp(30 * scale, 20, 38);
|
||||
RefreshButton.Width = refreshSize;
|
||||
RefreshButton.Height = refreshSize;
|
||||
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
|
||||
RefreshIcon.FontSize = Math.Clamp(14 * scale, 10, 20);
|
||||
|
||||
var contentWidth = Math.Max(80, totalWidth - CardBorder.Padding.Left - CardBorder.Padding.Right);
|
||||
var wordWidth = Math.Max(48, contentWidth - refreshSize - Math.Clamp(6 * scale, 4, 10));
|
||||
WordTextBlock.MaxWidth = wordWidth;
|
||||
|
||||
var contentHeight = Math.Max(52, totalHeight - CardBorder.Padding.Top - CardBorder.Padding.Bottom);
|
||||
var wordHeightBudget = Math.Max(18, contentHeight * 0.34);
|
||||
var detailHeightBudget = Math.Max(18, contentHeight - wordHeightBudget - Math.Clamp(8 * scale, 4, 14));
|
||||
|
||||
WordTextBlock.FontSize = FitFontSize(
|
||||
WordTextBlock.Text,
|
||||
wordWidth,
|
||||
wordHeightBudget,
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Clamp(18 * scale, 12, 22),
|
||||
maxFontSize: Math.Clamp(38 * scale, 20, 50),
|
||||
weight: FontWeight.Bold,
|
||||
lineHeightFactor: 1.02);
|
||||
WordTextBlock.LineHeight = WordTextBlock.FontSize * 1.02;
|
||||
|
||||
var detailFont = FitFontSize(
|
||||
MeaningTextBlock.IsVisible ? MeaningTextBlock.Text : HiddenHintTextBlock.Text,
|
||||
contentWidth,
|
||||
detailHeightBudget,
|
||||
maxLines: MeaningTextBlock.IsVisible ? 5 : 4,
|
||||
minFontSize: Math.Clamp(12 * scale, 9, 14),
|
||||
maxFontSize: Math.Clamp(18 * scale, 12, 22),
|
||||
weight: FontWeight.SemiBold,
|
||||
lineHeightFactor: 1.10);
|
||||
|
||||
MeaningTextBlock.MaxWidth = contentWidth;
|
||||
MeaningTextBlock.FontSize = detailFont;
|
||||
MeaningTextBlock.LineHeight = detailFont * 1.10;
|
||||
MeaningTextBlock.MaxLines = totalHeight < _currentCellSize * 1.8 ? 4 : 5;
|
||||
|
||||
HiddenHintTextBlock.MaxWidth = contentWidth;
|
||||
HiddenHintTextBlock.FontSize = detailFont;
|
||||
HiddenHintTextBlock.LineHeight = detailFont * 1.10;
|
||||
HiddenHintTextBlock.MaxLines = totalHeight < _currentCellSize * 1.8 ? 3 : 4;
|
||||
|
||||
StatusTextBlock.FontSize = Math.Clamp(14 * scale, 9, 18);
|
||||
}
|
||||
|
||||
private void UpdateRefreshButtonState()
|
||||
{
|
||||
RefreshButton.IsEnabled = !_isRefreshing;
|
||||
RefreshButton.Opacity = _isRefreshing ? 0.60 : 1.0;
|
||||
RefreshIcon.Opacity = _isRefreshing ? 0.60 : 1.0;
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 360;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.DailyWordAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.DailyWordAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoRefreshEnabled)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
}
|
||||
else if (_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 360;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(360);
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
if (cts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.0);
|
||||
var widthScale = Bounds.Width > 1
|
||||
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0)
|
||||
: 1;
|
||||
var heightScale = Bounds.Height > 1
|
||||
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0)
|
||||
: 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0);
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private static string BuildMeaningPreview(string? rawMeaning)
|
||||
{
|
||||
var normalized = NormalizeCompactText(rawMeaning);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return "Meaning unavailable";
|
||||
}
|
||||
|
||||
var compact = normalized.Replace(";", "; ", StringComparison.Ordinal);
|
||||
return compact.Length <= 160 ? compact : $"{compact[..160]}...";
|
||||
}
|
||||
|
||||
private static string NormalizeCompactText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
|
||||
}
|
||||
|
||||
private static double FitFontSize(
|
||||
string? text,
|
||||
double maxWidth,
|
||||
double maxHeight,
|
||||
int maxLines,
|
||||
double minFontSize,
|
||||
double maxFontSize,
|
||||
FontWeight weight,
|
||||
double lineHeightFactor)
|
||||
{
|
||||
var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
|
||||
var min = Math.Max(6, minFontSize);
|
||||
var max = Math.Max(min, maxFontSize);
|
||||
var low = min;
|
||||
var high = max;
|
||||
var best = min;
|
||||
|
||||
for (var i = 0; i < 18; i++)
|
||||
{
|
||||
var candidate = (low + high) / 2d;
|
||||
var lineHeight = candidate * lineHeightFactor;
|
||||
var size = MeasureTextSize(content, candidate, weight, Math.Max(1, maxWidth), lineHeight);
|
||||
var lineCount = Math.Max(1, (int)Math.Ceiling(size.Height / Math.Max(1, lineHeight)));
|
||||
var fits = size.Height <= maxHeight + 0.6 && lineCount <= Math.Max(1, maxLines);
|
||||
|
||||
if (fits)
|
||||
{
|
||||
best = candidate;
|
||||
low = candidate;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight)
|
||||
{
|
||||
var probe = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
FontFamily = MiSansFontFamily,
|
||||
FontSize = fontSize,
|
||||
FontWeight = weight,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
LineHeight = lineHeight
|
||||
};
|
||||
|
||||
probe.Measure(new Size(Math.Max(1, maxWidth), double.PositiveInfinity));
|
||||
return probe.DesiredSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<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="420"
|
||||
d:DesignHeight="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.DailyWordSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="Daily word settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Configure auto refresh and refresh interval."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="10"
|
||||
Margin="0,0,6,0">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="AutoRefreshLabelTextBlock"
|
||||
Text="Auto refresh"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<CheckBox x:Name="AutoRefreshCheckBox"
|
||||
Content="Enable auto refresh"
|
||||
Checked="OnAutoRefreshChanged"
|
||||
Unchecked="OnAutoRefreshChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="FrequencyCardBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||
Text="Refresh interval"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="FrequencyComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnFrequencySelectionChanged">
|
||||
<ComboBoxItem x:Name="Frequency30mItem"
|
||||
Tag="30"
|
||||
Content="30 min" />
|
||||
<ComboBoxItem x:Name="Frequency1hItem"
|
||||
Tag="60"
|
||||
Content="1 hour" />
|
||||
<ComboBoxItem x:Name="Frequency3hItem"
|
||||
Tag="180"
|
||||
Content="3 hours" />
|
||||
<ComboBoxItem x:Name="Frequency6hItem"
|
||||
Tag="360"
|
||||
Content="6 hours" />
|
||||
<ComboBoxItem x:Name="Frequency12hItem"
|
||||
Tag="720"
|
||||
Content="12 hours" />
|
||||
<ComboBoxItem x:Name="Frequency24hItem"
|
||||
Tag="1440"
|
||||
Content="24 hours" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class DailyWordSettingsWindow : UserControl
|
||||
{
|
||||
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public DailyWordSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeFrequencyOptions();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var enabled = componentSnapshot.DailyWordAutoRefreshEnabled;
|
||||
var interval = NormalizeInterval(componentSnapshot.DailyWordAutoRefreshIntervalMinutes);
|
||||
|
||||
_suppressEvents = true;
|
||||
AutoRefreshCheckBox.IsChecked = enabled;
|
||||
SelectInterval(interval);
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("dailyword.settings.title", "Daily word settings");
|
||||
DescriptionTextBlock.Text = L("dailyword.settings.desc", "Configure auto refresh and refresh interval.");
|
||||
AutoRefreshLabelTextBlock.Text = L("dailyword.settings.auto_refresh_label", "Auto refresh");
|
||||
AutoRefreshCheckBox.Content = L("dailyword.settings.auto_refresh_enabled", "Enable auto refresh");
|
||||
FrequencyLabelTextBlock.Text = L("dailyword.settings.frequency_label", "Refresh interval");
|
||||
ApplyFrequencyLocalization();
|
||||
}
|
||||
|
||||
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.DailyWordAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
snapshot.DailyWordAutoRefreshIntervalMinutes = GetSelectedInterval();
|
||||
_componentSettingsService.Save(snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private int GetSelectedInterval()
|
||||
{
|
||||
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
return NormalizeInterval(minutes);
|
||||
}
|
||||
|
||||
return 360;
|
||||
}
|
||||
|
||||
private void SelectInterval(int intervalMinutes)
|
||||
{
|
||||
var selected = FrequencyComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes) &&
|
||||
minutes == intervalMinutes);
|
||||
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int NormalizeInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 360);
|
||||
}
|
||||
|
||||
private void InitializeFrequencyOptions()
|
||||
{
|
||||
FrequencyComboBox.Items.Clear();
|
||||
foreach (var minutes in SupportedIntervals)
|
||||
{
|
||||
FrequencyComboBox.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Tag = minutes.ToString(),
|
||||
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFrequencyLocalization()
|
||||
{
|
||||
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
|
||||
{
|
||||
if (item.Tag is not string tagText ||
|
||||
!int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
|
||||
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
|
||||
}
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
129
LanMountainDesktop/Views/Components/DailyWordWidget.axaml
Normal file
129
LanMountainDesktop/Views/Components/DailyWordWidget.axaml
Normal file
@@ -0,0 +1,129 @@
|
||||
<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"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:shapes="clr-namespace:Avalonia.Controls.Shapes;assembly=Avalonia.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.DailyWordWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFBFA"
|
||||
CornerRadius="34"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="16,14,16,14">
|
||||
<Grid>
|
||||
<Grid IsHitTestVisible="False">
|
||||
<shapes:Ellipse x:Name="HaloEllipse"
|
||||
Width="290"
|
||||
Height="290"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-106,-52,0"
|
||||
Fill="#14F3C9B4" />
|
||||
|
||||
<Border x:Name="AccentCorner"
|
||||
Width="116"
|
||||
Height="116"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,-34,-34"
|
||||
CornerRadius="58"
|
||||
Background="#23F29A7A" />
|
||||
</Grid>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto"
|
||||
RowSpacing="7">
|
||||
<Grid Grid.Row="0"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="WordTextBlock"
|
||||
Text="illustrate"
|
||||
Foreground="#F07541"
|
||||
FontSize="56"
|
||||
FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="1"
|
||||
Width="38"
|
||||
Height="38"
|
||||
CornerRadius="19"
|
||||
Background="#14A0A6AF"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
Focusable="False">
|
||||
<fi:SymbolIcon x:Name="RefreshIcon"
|
||||
Symbol="ArrowClockwise"
|
||||
IconVariant="Regular"
|
||||
FontSize="19"
|
||||
Foreground="#626870" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="PronunciationTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="英 /ˈɪləstreɪt/ · 美 /ˈɪləstreɪt/"
|
||||
Foreground="#6B7078"
|
||||
FontSize="27"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
|
||||
<TextBlock x:Name="MeaningTextBlock"
|
||||
Grid.Row="2"
|
||||
Text="vt. 说明;阐明;举例证明;加插图"
|
||||
Foreground="#2B2F35"
|
||||
FontSize="25"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top" />
|
||||
|
||||
<StackPanel Grid.Row="3"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="ExampleTextBlock"
|
||||
Text="One example will suffice to illustrate the point."
|
||||
Foreground="#2B2F35"
|
||||
FontSize="22"
|
||||
FontWeight="Medium"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2" />
|
||||
<TextBlock x:Name="ExampleTranslationTextBlock"
|
||||
Text="一个例子就足以说明这个观点。"
|
||||
Foreground="#7A8088"
|
||||
FontSize="20"
|
||||
FontWeight="Medium"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Loading"
|
||||
Foreground="#6A6F77"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
643
LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs
Normal file
643
LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs
Normal file
@@ -0,0 +1,643 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
|
||||
{
|
||||
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
||||
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
|
||||
|
||||
private const double BaseCellSize = 48d;
|
||||
private const int BaseWidthCells = 4;
|
||||
private const int BaseHeightCells = 2;
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromHours(6)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private string _languageCode = "zh-CN";
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private bool _isNightVisual = true;
|
||||
|
||||
public DailyWordWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
WordTextBlock.FontFamily = MiSansFontFamily;
|
||||
PronunciationTextBlock.FontFamily = MiSansFontFamily;
|
||||
MeaningTextBlock.FontFamily = MiSansFontFamily;
|
||||
ExampleTextBlock.FontFamily = MiSansFontFamily;
|
||||
ExampleTranslationTextBlock.FontFamily = MiSansFontFamily;
|
||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
RefreshButton.Click += OnRefreshButtonClick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
UpdateLanguageCode();
|
||||
ApplyAutoRefreshSettings();
|
||||
ApplyLoadingState();
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||
{
|
||||
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshWordAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
_recommendationService.ClearCache();
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshWordAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
UpdateRefreshButtonState();
|
||||
_ = RefreshWordAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_isNightVisual = ResolveNightMode();
|
||||
ApplyNightModeVisual();
|
||||
}
|
||||
|
||||
private bool ResolveNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ActualThemeVariant == ThemeVariant.Light)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||
value is ISolidColorBrush brush)
|
||||
{
|
||||
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double CalculateRelativeLuminance(Color color)
|
||||
{
|
||||
static double ToLinear(double channel)
|
||||
{
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
var r = ToLinear(color.R / 255d);
|
||||
var g = ToLinear(color.G / 255d);
|
||||
var b = ToLinear(color.B / 255d);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
private void ApplyNightModeVisual()
|
||||
{
|
||||
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFBFA"));
|
||||
|
||||
WordTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#FF9D6C") : Color.Parse("#F07541"));
|
||||
PronunciationTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#6B7078"));
|
||||
MeaningTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#2B2F35"));
|
||||
ExampleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#2B2F35"));
|
||||
ExampleTranslationTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#7A8088"));
|
||||
|
||||
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#14A0A6AF"));
|
||||
RefreshIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#626870"));
|
||||
|
||||
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
|
||||
}
|
||||
|
||||
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RefreshWordAsync(forceRefresh: true);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshWordAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private async Task RefreshWordAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
UpdateRefreshButtonState();
|
||||
UpdateLanguageCode();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var query = new DailyWordQuery(
|
||||
Locale: _languageCode,
|
||||
ForceRefresh: forceRefresh);
|
||||
var result = await _recommendationService.GetDailyWordAsync(query, cts.Token);
|
||||
if (!_isAttached || cts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success || result.Data is null)
|
||||
{
|
||||
ApplyFailedState();
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySnapshot(result.Data);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore canceled requests.
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (_isAttached && !cts.IsCancellationRequested)
|
||||
{
|
||||
ApplyFailedState();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCts, cts))
|
||||
{
|
||||
_refreshCts = null;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
_isRefreshing = false;
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySnapshot(DailyWordSnapshot snapshot)
|
||||
{
|
||||
WordTextBlock.Text = NormalizeCompactText(snapshot.Word);
|
||||
PronunciationTextBlock.Text = BuildPronunciationText(snapshot);
|
||||
MeaningTextBlock.Text = BuildMeaningText(snapshot.Meaning);
|
||||
ExampleTextBlock.Text = BuildExampleText(snapshot.ExampleSentence);
|
||||
ExampleTranslationTextBlock.Text = BuildExampleTranslation(snapshot.ExampleTranslation);
|
||||
|
||||
StatusTextBlock.IsVisible = false;
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
WordTextBlock.Text = L("dailyword.widget.loading_word", "daily word");
|
||||
PronunciationTextBlock.Text = L("dailyword.widget.loading_pronunciation", "Fetching pronunciation...");
|
||||
MeaningTextBlock.Text = L("dailyword.widget.loading_meaning", "Fetching meaning...");
|
||||
ExampleTextBlock.Text = L("dailyword.widget.loading_example", "Fetching example sentence...");
|
||||
ExampleTranslationTextBlock.Text = L("dailyword.widget.loading_example_translation", "Loading...");
|
||||
StatusTextBlock.Text = L("dailyword.widget.loading", "Loading...");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
WordTextBlock.Text = L("dailyword.widget.fallback_word", "daily word");
|
||||
PronunciationTextBlock.Text = L("dailyword.widget.fallback_pronunciation", "Pronunciation unavailable");
|
||||
MeaningTextBlock.Text = L("dailyword.widget.fallback_meaning", "Youdao dictionary is temporarily unavailable.");
|
||||
ExampleTextBlock.Text = L("dailyword.widget.fallback_example", "Tap the refresh button and try again.");
|
||||
ExampleTranslationTextBlock.Text = L("dailyword.widget.fallback_example_translation", "It will retry when network recovers.");
|
||||
StatusTextBlock.Text = L("dailyword.widget.fetch_failed", "Daily word fetch failed");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
var isFourByThree = false;
|
||||
if (Bounds.Width > 1 && Bounds.Height > 1)
|
||||
{
|
||||
var widthRatio = Bounds.Width / (_currentCellSize * BaseWidthCells);
|
||||
var heightRatio = Bounds.Height / (_currentCellSize * BaseHeightCells);
|
||||
isFourByThree = widthRatio >= 0.9 && heightRatio >= 1.35;
|
||||
}
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
CardBorder.Padding = new Thickness(
|
||||
Math.Clamp(16 * scale, 8, 24),
|
||||
Math.Clamp(14 * scale, 7, 22),
|
||||
Math.Clamp(16 * scale, 8, 24),
|
||||
Math.Clamp(14 * scale, 7, 22));
|
||||
|
||||
var refreshSize = Math.Clamp(38 * scale, 22, 48);
|
||||
RefreshButton.Width = refreshSize;
|
||||
RefreshButton.Height = refreshSize;
|
||||
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
|
||||
RefreshIcon.FontSize = Math.Clamp(19 * scale, 12, 26);
|
||||
|
||||
HaloEllipse.Width = Math.Clamp(totalWidth * 0.52, 120, 340);
|
||||
HaloEllipse.Height = HaloEllipse.Width;
|
||||
AccentCorner.Width = Math.Clamp(totalWidth * 0.20, 66, 132);
|
||||
AccentCorner.Height = AccentCorner.Width;
|
||||
AccentCorner.CornerRadius = new CornerRadius(AccentCorner.Width / 2d);
|
||||
|
||||
var horizontalPadding = RootBorder.Padding.Left + RootBorder.Padding.Right + CardBorder.Padding.Left + CardBorder.Padding.Right;
|
||||
var contentWidth = Math.Max(98, totalWidth - horizontalPadding);
|
||||
var wordWidth = Math.Max(70, contentWidth - refreshSize - Math.Clamp(8 * scale, 5, 14));
|
||||
WordTextBlock.MaxWidth = wordWidth;
|
||||
PronunciationTextBlock.MaxWidth = contentWidth;
|
||||
MeaningTextBlock.MaxWidth = contentWidth;
|
||||
ExampleTextBlock.MaxWidth = contentWidth;
|
||||
ExampleTranslationTextBlock.MaxWidth = contentWidth;
|
||||
|
||||
var compactLayout = totalHeight < _currentCellSize * 1.72;
|
||||
MeaningTextBlock.MaxLines = compactLayout ? 1 : (isFourByThree ? 3 : 2);
|
||||
ExampleTextBlock.MaxLines = compactLayout ? 1 : (isFourByThree ? 4 : 2);
|
||||
ExampleTranslationTextBlock.IsVisible = !compactLayout || isFourByThree;
|
||||
ExampleTranslationTextBlock.MaxLines = isFourByThree ? 2 : 1;
|
||||
|
||||
var contentHeight = Math.Max(52, totalHeight - RootBorder.Padding.Top - RootBorder.Padding.Bottom - CardBorder.Padding.Top - CardBorder.Padding.Bottom);
|
||||
var wordHeightBudget = Math.Max(18, contentHeight * 0.24);
|
||||
var pronunciationHeightBudget = Math.Max(14, contentHeight * 0.16);
|
||||
var meaningHeightBudget = Math.Max(16, contentHeight * (compactLayout ? 0.26 : (isFourByThree ? 0.35 : 0.30)));
|
||||
var exampleHeightBudget = Math.Max(16, contentHeight - wordHeightBudget - pronunciationHeightBudget - meaningHeightBudget - Math.Clamp(16 * scale, 8, 24));
|
||||
if (!ExampleTranslationTextBlock.IsVisible)
|
||||
{
|
||||
exampleHeightBudget += Math.Clamp(11 * scale, 5, 18);
|
||||
}
|
||||
|
||||
var wordBase = Math.Clamp(56 * scale, 18, 72);
|
||||
WordTextBlock.FontSize = FitFontSize(
|
||||
WordTextBlock.Text,
|
||||
wordWidth,
|
||||
wordHeightBudget,
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Max(14, wordBase * 0.56),
|
||||
maxFontSize: wordBase,
|
||||
weight: FontWeight.Bold,
|
||||
lineHeightFactor: 1.04);
|
||||
WordTextBlock.LineHeight = WordTextBlock.FontSize * 1.04;
|
||||
|
||||
var pronunciationBase = Math.Clamp(27 * scale, 10, 36);
|
||||
PronunciationTextBlock.FontSize = FitFontSize(
|
||||
PronunciationTextBlock.Text,
|
||||
contentWidth,
|
||||
pronunciationHeightBudget,
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Max(8.6, pronunciationBase * 0.62),
|
||||
maxFontSize: pronunciationBase,
|
||||
weight: FontWeight.SemiBold,
|
||||
lineHeightFactor: 1.08);
|
||||
PronunciationTextBlock.LineHeight = PronunciationTextBlock.FontSize * 1.08;
|
||||
|
||||
var meaningBase = Math.Clamp(25 * scale, 10, 34);
|
||||
MeaningTextBlock.FontSize = FitFontSize(
|
||||
MeaningTextBlock.Text,
|
||||
contentWidth,
|
||||
meaningHeightBudget,
|
||||
maxLines: Math.Max(1, MeaningTextBlock.MaxLines),
|
||||
minFontSize: Math.Max(9.2, meaningBase * 0.60),
|
||||
maxFontSize: meaningBase,
|
||||
weight: FontWeight.SemiBold,
|
||||
lineHeightFactor: 1.10);
|
||||
MeaningTextBlock.LineHeight = MeaningTextBlock.FontSize * 1.10;
|
||||
|
||||
var exampleBase = Math.Clamp(22 * scale, 9, 30);
|
||||
ExampleTextBlock.FontSize = FitFontSize(
|
||||
ExampleTextBlock.Text,
|
||||
contentWidth,
|
||||
exampleHeightBudget,
|
||||
maxLines: Math.Max(1, ExampleTextBlock.MaxLines),
|
||||
minFontSize: Math.Max(8.8, exampleBase * 0.58),
|
||||
maxFontSize: exampleBase,
|
||||
weight: FontWeight.Medium,
|
||||
lineHeightFactor: 1.08);
|
||||
ExampleTextBlock.LineHeight = ExampleTextBlock.FontSize * 1.08;
|
||||
|
||||
var translationBase = Math.Clamp(20 * scale, 8, 28);
|
||||
ExampleTranslationTextBlock.FontSize = FitFontSize(
|
||||
ExampleTranslationTextBlock.Text,
|
||||
contentWidth,
|
||||
Math.Max(10, exampleHeightBudget * 0.44),
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Max(7.8, translationBase * 0.62),
|
||||
maxFontSize: translationBase,
|
||||
weight: FontWeight.Medium,
|
||||
lineHeightFactor: 1.06);
|
||||
ExampleTranslationTextBlock.LineHeight = ExampleTranslationTextBlock.FontSize * 1.06;
|
||||
|
||||
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
|
||||
}
|
||||
|
||||
private void UpdateRefreshButtonState()
|
||||
{
|
||||
RefreshButton.IsEnabled = !_isRefreshing;
|
||||
RefreshButton.Opacity = _isAttached ? 1.0 : 0.85;
|
||||
RefreshIcon.Opacity = _isRefreshing ? 0.56 : 1.0;
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 360;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.DailyWordAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.DailyWordAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoRefreshEnabled)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
}
|
||||
else if (_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 360;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(360);
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
if (cts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.0);
|
||||
|
||||
var widthCells = BaseWidthCells;
|
||||
var heightCells = BaseHeightCells;
|
||||
|
||||
if (Bounds.Width > 1 && Bounds.Height > 1)
|
||||
{
|
||||
var widthRatio = Bounds.Width / (_currentCellSize * widthCells);
|
||||
var heightRatio = Bounds.Height / (_currentCellSize * heightCells);
|
||||
|
||||
if (widthRatio >= 0.9 && heightRatio >= 1.35)
|
||||
{
|
||||
heightCells = 3;
|
||||
}
|
||||
}
|
||||
|
||||
var widthScale = Bounds.Width > 1
|
||||
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * widthCells), 0.56, 2.0)
|
||||
: 1;
|
||||
var heightScale = Bounds.Height > 1
|
||||
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * heightCells), 0.56, 2.0)
|
||||
: 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0);
|
||||
}
|
||||
|
||||
private string BuildPronunciationText(DailyWordSnapshot snapshot)
|
||||
{
|
||||
var uk = NormalizeCompactText(snapshot.UkPronunciation);
|
||||
var us = NormalizeCompactText(snapshot.UsPronunciation);
|
||||
var isZh = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(uk) && !string.IsNullOrWhiteSpace(us))
|
||||
{
|
||||
return isZh
|
||||
? $"英 /{uk}/ · 美 /{us}/"
|
||||
: $"UK /{uk}/ · US /{us}/";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(uk))
|
||||
{
|
||||
return isZh ? $"英 /{uk}/" : $"UK /{uk}/";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(us))
|
||||
{
|
||||
return isZh ? $"美 /{us}/" : $"US /{us}/";
|
||||
}
|
||||
|
||||
return isZh ? "英/美 发音暂无" : "Pronunciation unavailable";
|
||||
}
|
||||
|
||||
private static string BuildMeaningText(string? rawMeaning)
|
||||
{
|
||||
var normalized = NormalizeCompactText(rawMeaning);
|
||||
return string.IsNullOrWhiteSpace(normalized)
|
||||
? "Meaning unavailable"
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static string BuildExampleText(string? sentence)
|
||||
{
|
||||
var normalized = NormalizeCompactText(sentence);
|
||||
return string.IsNullOrWhiteSpace(normalized)
|
||||
? "No example sentence."
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static string BuildExampleTranslation(string? translation)
|
||||
{
|
||||
var normalized = NormalizeCompactText(translation);
|
||||
return string.IsNullOrWhiteSpace(normalized)
|
||||
? string.Empty
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeCompactText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
|
||||
}
|
||||
|
||||
private static double FitFontSize(
|
||||
string? text,
|
||||
double maxWidth,
|
||||
double maxHeight,
|
||||
int maxLines,
|
||||
double minFontSize,
|
||||
double maxFontSize,
|
||||
FontWeight weight,
|
||||
double lineHeightFactor)
|
||||
{
|
||||
var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
|
||||
var min = Math.Max(6, minFontSize);
|
||||
var max = Math.Max(min, maxFontSize);
|
||||
var low = min;
|
||||
var high = max;
|
||||
var best = min;
|
||||
|
||||
for (var i = 0; i < 18; i++)
|
||||
{
|
||||
var candidate = (low + high) / 2d;
|
||||
var lineHeight = candidate * lineHeightFactor;
|
||||
var size = MeasureTextSize(content, candidate, weight, Math.Max(1, maxWidth), lineHeight);
|
||||
var lineCount = Math.Max(1, (int)Math.Ceiling(size.Height / Math.Max(1, lineHeight)));
|
||||
var fits = size.Height <= maxHeight + 0.6 && lineCount <= Math.Max(1, maxLines);
|
||||
|
||||
if (fits)
|
||||
{
|
||||
best = candidate;
|
||||
low = candidate;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight)
|
||||
{
|
||||
var probe = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
FontFamily = MiSansFontFamily,
|
||||
FontSize = fontSize,
|
||||
FontWeight = weight,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
LineHeight = lineHeight
|
||||
};
|
||||
|
||||
probe.Measure(new Size(Math.Max(1, maxWidth), double.PositiveInfinity));
|
||||
return probe.DesiredSize;
|
||||
}
|
||||
}
|
||||
@@ -7,24 +7,65 @@ using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public sealed record DesktopComponentRuntimeRegistration(
|
||||
string ComponentId,
|
||||
string DisplayNameLocalizationKey,
|
||||
Func<Control> ControlFactory,
|
||||
Func<double, double>? CornerRadiusResolver = null);
|
||||
public sealed record DesktopComponentControlFactoryContext(
|
||||
DesktopComponentDefinition Definition,
|
||||
double CellSize,
|
||||
TimeZoneService TimeZoneService,
|
||||
IWeatherInfoService WeatherInfoService,
|
||||
IRecommendationInfoService RecommendationInfoService,
|
||||
ICalculatorDataService CalculatorDataService,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore,
|
||||
string? PlacementId = null);
|
||||
|
||||
public sealed class DesktopComponentRuntimeRegistration
|
||||
{
|
||||
public DesktopComponentRuntimeRegistration(
|
||||
string componentId,
|
||||
string? displayNameLocalizationKey,
|
||||
Func<Control> controlFactory,
|
||||
Func<double, double>? cornerRadiusResolver = null)
|
||||
: this(componentId, displayNameLocalizationKey, _ => controlFactory(), cornerRadiusResolver)
|
||||
{
|
||||
}
|
||||
|
||||
public DesktopComponentRuntimeRegistration(
|
||||
string componentId,
|
||||
string? displayNameLocalizationKey,
|
||||
Func<DesktopComponentControlFactoryContext, Control> controlFactory,
|
||||
Func<double, double>? cornerRadiusResolver = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||
|
||||
ComponentId = componentId.Trim();
|
||||
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
|
||||
? null
|
||||
: displayNameLocalizationKey.Trim();
|
||||
ControlFactory = controlFactory;
|
||||
CornerRadiusResolver = cornerRadiusResolver;
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string? DisplayNameLocalizationKey { get; }
|
||||
|
||||
public Func<DesktopComponentControlFactoryContext, Control> ControlFactory { get; }
|
||||
|
||||
public Func<double, double>? CornerRadiusResolver { get; }
|
||||
}
|
||||
|
||||
public sealed class DesktopComponentRuntimeDescriptor
|
||||
{
|
||||
private static readonly Func<double, double> DefaultCornerRadiusResolver =
|
||||
cellSize => Math.Clamp(cellSize * 0.22, 8, 18);
|
||||
|
||||
private readonly Func<Control> _controlFactory;
|
||||
private readonly Func<DesktopComponentControlFactoryContext, Control> _controlFactory;
|
||||
private readonly Func<double, double> _cornerRadiusResolver;
|
||||
|
||||
internal DesktopComponentRuntimeDescriptor(
|
||||
DesktopComponentDefinition definition,
|
||||
string displayNameLocalizationKey,
|
||||
Func<Control> controlFactory,
|
||||
string? displayNameLocalizationKey,
|
||||
Func<DesktopComponentControlFactoryContext, Control> controlFactory,
|
||||
Func<double, double>? cornerRadiusResolver)
|
||||
{
|
||||
Definition = definition;
|
||||
@@ -35,15 +76,48 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
|
||||
public DesktopComponentDefinition Definition { get; }
|
||||
|
||||
public string DisplayNameLocalizationKey { get; }
|
||||
public string? DisplayNameLocalizationKey { get; }
|
||||
|
||||
public Control CreateControl(
|
||||
double cellSize,
|
||||
TimeZoneService timeZoneService,
|
||||
IWeatherInfoService weatherInfoService,
|
||||
IRecommendationInfoService recommendationInfoService)
|
||||
IRecommendationInfoService recommendationInfoService,
|
||||
ICalculatorDataService calculatorDataService,
|
||||
IComponentInstanceSettingsStore componentSettingsStore,
|
||||
string? placementId = null)
|
||||
{
|
||||
var control = _controlFactory();
|
||||
var control = _controlFactory(new DesktopComponentControlFactoryContext(
|
||||
Definition,
|
||||
cellSize,
|
||||
timeZoneService,
|
||||
weatherInfoService,
|
||||
recommendationInfoService,
|
||||
calculatorDataService,
|
||||
componentSettingsStore,
|
||||
placementId));
|
||||
var runtimeContext = new DesktopComponentRuntimeContext(
|
||||
Definition.Id,
|
||||
placementId,
|
||||
componentSettingsStore);
|
||||
|
||||
if (control is IComponentRuntimeContextAware runtimeContextAwareComponent)
|
||||
{
|
||||
runtimeContextAwareComponent.SetComponentRuntimeContext(runtimeContext);
|
||||
}
|
||||
|
||||
if (control is IComponentPlacementContextAware placementAwareComponent)
|
||||
{
|
||||
placementAwareComponent.SetComponentPlacementContext(Definition.Id, placementId);
|
||||
}
|
||||
|
||||
if (control is IComponentSettingsStoreAware settingsStoreAwareComponent)
|
||||
{
|
||||
settingsStoreAwareComponent.SetComponentSettingsStore(componentSettingsStore);
|
||||
}
|
||||
|
||||
ComponentSettingsService.ApplyScopedContextToTarget(control, Definition.Id, placementId);
|
||||
|
||||
if (control is IDesktopComponentWidget sizedComponent)
|
||||
{
|
||||
sizedComponent.ApplyCellSize(cellSize);
|
||||
@@ -64,6 +138,11 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
recommendationInfoAwareComponent.SetRecommendationInfoService(recommendationInfoService);
|
||||
}
|
||||
|
||||
if (control is ICalculatorInfoAwareComponentWidget calculatorInfoAwareComponent)
|
||||
{
|
||||
calculatorInfoAwareComponent.SetCalculatorDataService(calculatorDataService);
|
||||
}
|
||||
|
||||
return control;
|
||||
}
|
||||
|
||||
@@ -103,12 +182,10 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry)
|
||||
public static IReadOnlyList<DesktopComponentRuntimeRegistration> GetDefaultRegistrations()
|
||||
{
|
||||
return new DesktopComponentRuntimeRegistry(
|
||||
componentRegistry,
|
||||
new[]
|
||||
{
|
||||
return
|
||||
[
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.Date,
|
||||
"component.date",
|
||||
@@ -229,6 +306,46 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
"component.daily_artwork",
|
||||
() => new DailyArtworkWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopDailyWord,
|
||||
"component.daily_word",
|
||||
() => new DailyWordWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopDailyWord2x2,
|
||||
"component.daily_word_2x2",
|
||||
() => new DailyWord2x2Widget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 12, 26)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopCnrDailyNews,
|
||||
"component.cnr_daily_news",
|
||||
() => new CnrDailyNewsWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopIfengNews,
|
||||
"component.ifeng_news",
|
||||
() => new IfengNewsWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.30, 12, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||
"component.bilibili_hot_search",
|
||||
() => new BilibiliHotSearchWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopBaiduHotSearch,
|
||||
"component.baidu_hot_search",
|
||||
() => new BaiduHotSearchWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopStcn24Forum,
|
||||
"component.stcn24_forum",
|
||||
() => new Stcn24ForumWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.28, 12, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopExchangeRateCalculator,
|
||||
"component.exchange_rate_converter",
|
||||
() => new ExchangeRateCalculatorWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.28, 12, 26)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
"component.whiteboard",
|
||||
@@ -249,7 +366,12 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
"component.holiday_calendar",
|
||||
() => new HolidayCalendarWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.32, 12, 28))
|
||||
});
|
||||
];
|
||||
}
|
||||
|
||||
public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry)
|
||||
{
|
||||
return new DesktopComponentRuntimeRegistry(componentRegistry, GetDefaultRegistrations());
|
||||
}
|
||||
|
||||
public bool TryGetDescriptor(string componentId, out DesktopComponentRuntimeDescriptor descriptor)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user