mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-07-01 07:34:26 +08:00
settings_re8
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed record ComponentEditorThemePalette(
|
||||
bool IsNightMode,
|
||||
Color PrimaryColor,
|
||||
Color SecondaryColor,
|
||||
Color TertiaryColor,
|
||||
Color WindowBackgroundColor,
|
||||
Color SurfaceColor,
|
||||
Color SurfaceContainerColor,
|
||||
Color SurfaceContainerHighColor,
|
||||
Color TopAppBarColor,
|
||||
Color HeaderIconBackgroundColor,
|
||||
Color TitleBarButtonHoverColor,
|
||||
Color OutlineColor,
|
||||
Color DividerColor,
|
||||
Color OnSurfaceColor,
|
||||
Color OnSurfaceVariantColor,
|
||||
Color OnPrimaryColor);
|
||||
|
||||
internal static class ComponentEditorMaterialThemeAdapter
|
||||
{
|
||||
private static readonly Color DefaultPrimary = Color.Parse("#FF6750A4");
|
||||
private static readonly Color DarkBackgroundBase = Color.Parse("#FF0B0F14");
|
||||
private static readonly Color DarkSurfaceBase = Color.Parse("#FF10161D");
|
||||
private static readonly Color DarkSurfaceContainerBase = Color.Parse("#FF151C24");
|
||||
private static readonly Color DarkSurfaceContainerHighBase = Color.Parse("#FF1A232D");
|
||||
private static readonly Color LightBackgroundBase = Color.Parse("#FFFCFCFF");
|
||||
private static readonly Color LightSurfaceBase = Color.Parse("#FFFFFFFF");
|
||||
private static readonly Color LightSurfaceContainerBase = Color.Parse("#FFF6F8FD");
|
||||
private static readonly Color LightSurfaceContainerHighBase = Color.Parse("#FFF0F4FA");
|
||||
private static readonly Color LightOnSurfaceBase = Color.Parse("#FF101316");
|
||||
private static readonly Color DarkOnSurfaceBase = Color.Parse("#FFF6F8FC");
|
||||
|
||||
public static ComponentEditorThemePalette Build(
|
||||
ThemeAppearanceSettingsState themeState,
|
||||
WallpaperSettingsState wallpaperState,
|
||||
MonetPalette monetPalette,
|
||||
WallpaperMediaType wallpaperMediaType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monetPalette);
|
||||
|
||||
var isNightMode = themeState.IsNightMode;
|
||||
var monetColors = monetPalette.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
|
||||
var fallbackThemeColor = TryParseColor(themeState.ThemeColor);
|
||||
var useWallpaperPalette = wallpaperMediaType == WallpaperMediaType.Image && monetColors.Length > 0;
|
||||
|
||||
var primary = useWallpaperPalette
|
||||
? monetColors[0]
|
||||
: fallbackThemeColor ?? monetColors.FirstOrDefault(DefaultPrimary);
|
||||
var secondary = ResolveSecondaryColor(primary, monetColors, isNightMode);
|
||||
var tertiary = ResolveTertiaryColor(primary, secondary, monetColors, isNightMode);
|
||||
|
||||
var backgroundBase = isNightMode ? DarkBackgroundBase : LightBackgroundBase;
|
||||
var surfaceBase = isNightMode ? DarkSurfaceBase : LightSurfaceBase;
|
||||
var surfaceContainerBase = isNightMode ? DarkSurfaceContainerBase : LightSurfaceContainerBase;
|
||||
var surfaceContainerHighBase = isNightMode ? DarkSurfaceContainerHighBase : LightSurfaceContainerHighBase;
|
||||
|
||||
var background = ColorMath.Blend(backgroundBase, primary, isNightMode ? 0.10 : 0.025);
|
||||
var surface = ColorMath.Blend(surfaceBase, primary, isNightMode ? 0.12 : 0.035);
|
||||
var surfaceContainer = ColorMath.Blend(surfaceContainerBase, primary, isNightMode ? 0.18 : 0.065);
|
||||
var surfaceContainerHigh = ColorMath.Blend(surfaceContainerHighBase, primary, isNightMode ? 0.24 : 0.09);
|
||||
var topAppBar = ColorMath.Blend(surfaceContainerHigh, primary, isNightMode ? 0.10 : 0.06);
|
||||
|
||||
var onSurfaceBase = isNightMode ? DarkOnSurfaceBase : LightOnSurfaceBase;
|
||||
var onSurface = ColorMath.EnsureContrast(onSurfaceBase, background, 7.0);
|
||||
var onSurfaceVariantBase = ColorMath.Blend(
|
||||
onSurface,
|
||||
surfaceContainer,
|
||||
isNightMode ? 0.30 : 0.42);
|
||||
var onSurfaceVariant = ColorMath.EnsureContrast(onSurfaceVariantBase, surfaceContainer, 4.5);
|
||||
var outlineBase = ColorMath.Blend(onSurface, surfaceContainer, isNightMode ? 0.74 : 0.82);
|
||||
var outline = Color.FromArgb(
|
||||
isNightMode ? (byte)0x66 : (byte)0x42,
|
||||
outlineBase.R,
|
||||
outlineBase.G,
|
||||
outlineBase.B);
|
||||
var divider = Color.FromArgb(
|
||||
isNightMode ? (byte)0x52 : (byte)0x26,
|
||||
outlineBase.R,
|
||||
outlineBase.G,
|
||||
outlineBase.B);
|
||||
var headerIconBackground = Color.FromArgb(
|
||||
isNightMode ? (byte)0x36 : (byte)0x1F,
|
||||
primary.R,
|
||||
primary.G,
|
||||
primary.B);
|
||||
var titleBarButtonHover = Color.FromArgb(
|
||||
isNightMode ? (byte)0x24 : (byte)0x12,
|
||||
onSurface.R,
|
||||
onSurface.G,
|
||||
onSurface.B);
|
||||
var onPrimaryBase = isNightMode ? Color.Parse("#FF111318") : Color.Parse("#FFFFFFFF");
|
||||
var onPrimary = ColorMath.EnsureContrast(onPrimaryBase, primary, 4.5);
|
||||
|
||||
return new ComponentEditorThemePalette(
|
||||
isNightMode,
|
||||
primary,
|
||||
secondary,
|
||||
tertiary,
|
||||
background,
|
||||
surface,
|
||||
surfaceContainer,
|
||||
surfaceContainerHigh,
|
||||
topAppBar,
|
||||
headerIconBackground,
|
||||
titleBarButtonHover,
|
||||
outline,
|
||||
divider,
|
||||
onSurface,
|
||||
onSurfaceVariant,
|
||||
onPrimary);
|
||||
}
|
||||
|
||||
private static Color ResolveSecondaryColor(Color primary, IReadOnlyList<Color> monetColors, bool isNightMode)
|
||||
{
|
||||
if (monetColors.Count > 1)
|
||||
{
|
||||
return monetColors[1];
|
||||
}
|
||||
|
||||
return ColorMath.Blend(
|
||||
primary,
|
||||
isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF1F1B24"),
|
||||
isNightMode ? 0.18 : 0.16);
|
||||
}
|
||||
|
||||
private static Color ResolveTertiaryColor(
|
||||
Color primary,
|
||||
Color secondary,
|
||||
IReadOnlyList<Color> monetColors,
|
||||
bool isNightMode)
|
||||
{
|
||||
if (monetColors.Count > 2)
|
||||
{
|
||||
return monetColors[2];
|
||||
}
|
||||
|
||||
var blendTarget = isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF2A2230");
|
||||
return ColorMath.Blend(ColorMath.Blend(primary, secondary, 0.5), blendTarget, isNightMode ? 0.12 : 0.14);
|
||||
}
|
||||
|
||||
private static Color? TryParseColor(string? value)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value) && Color.TryParse(value, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
}
|
||||
169
LanMountainDesktop/Services/ComponentEditorWindowService.cs
Normal file
169
LanMountainDesktop/Services/ComponentEditorWindowService.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Views;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public readonly record struct ComponentEditorOpenRequest(
|
||||
Window Owner,
|
||||
DesktopComponentEditorDescriptor Descriptor,
|
||||
string ComponentId,
|
||||
string PlacementId,
|
||||
Action RefreshAction,
|
||||
Action<string?>? RestartAction = null);
|
||||
|
||||
public interface IComponentEditorWindowService
|
||||
{
|
||||
bool IsOpen { get; }
|
||||
|
||||
string? CurrentPlacementId { get; }
|
||||
|
||||
void Open(ComponentEditorOpenRequest request);
|
||||
|
||||
void Close();
|
||||
}
|
||||
|
||||
internal sealed class ComponentEditorWindowService : IComponentEditorWindowService
|
||||
{
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private ComponentEditorWindow? _window;
|
||||
private string? _currentPlacementId;
|
||||
|
||||
public ComponentEditorWindowService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_settingsFacade.Settings.Changed += OnSettingsChanged;
|
||||
}
|
||||
|
||||
public bool IsOpen => _window is { IsVisible: true };
|
||||
|
||||
public string? CurrentPlacementId => _currentPlacementId;
|
||||
|
||||
public void Open(ComponentEditorOpenRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request.Owner);
|
||||
ArgumentNullException.ThrowIfNull(request.RefreshAction);
|
||||
|
||||
_window ??= CreateWindow();
|
||||
|
||||
var settingsService = _settingsFacade.Settings;
|
||||
var accessor = settingsService.GetComponentAccessor(request.ComponentId, request.PlacementId);
|
||||
var scopedStore = new ComponentSettingsService(settingsService);
|
||||
scopedStore.SetScopedComponentContext(request.ComponentId, request.PlacementId);
|
||||
|
||||
var hostContext = new HostContext(this, request.RefreshAction, request.RestartAction);
|
||||
var context = new DesktopComponentEditorContext(
|
||||
request.Descriptor.Definition,
|
||||
request.ComponentId,
|
||||
request.PlacementId,
|
||||
_settingsFacade,
|
||||
settingsService,
|
||||
accessor,
|
||||
scopedStore,
|
||||
hostContext);
|
||||
|
||||
_currentPlacementId = request.PlacementId;
|
||||
_window.ApplyDescriptor(request.Descriptor, context);
|
||||
|
||||
if (!_window.IsVisible)
|
||||
{
|
||||
_window.Show(request.Owner);
|
||||
return;
|
||||
}
|
||||
|
||||
_window.Activate();
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
_window?.Close();
|
||||
}
|
||||
|
||||
private ComponentEditorWindow CreateWindow()
|
||||
{
|
||||
var window = new ComponentEditorWindow();
|
||||
ApplyTheme(window);
|
||||
window.ShowInTaskbar = false;
|
||||
window.Closed += (_, _) =>
|
||||
{
|
||||
_window = null;
|
||||
_currentPlacementId = null;
|
||||
};
|
||||
return window;
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||
{
|
||||
if (_window is null || e.Scope != SettingsScope.App)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var changedKeys = e.ChangedKeys?.ToArray() ?? [];
|
||||
if (changedKeys.Length > 0 &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyTheme(_window);
|
||||
}
|
||||
|
||||
private void ApplyTheme(ComponentEditorWindow window)
|
||||
{
|
||||
var themeState = _settingsFacade.Theme.Get();
|
||||
var wallpaperState = _settingsFacade.Wallpaper.Get();
|
||||
var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperState.WallpaperPath);
|
||||
var monetPalette = _settingsFacade.Theme.BuildPalette(themeState.IsNightMode, wallpaperState.WallpaperPath);
|
||||
var palette = ComponentEditorMaterialThemeAdapter.Build(
|
||||
themeState,
|
||||
wallpaperState,
|
||||
monetPalette,
|
||||
wallpaperMediaType);
|
||||
|
||||
window.ApplyTheme(palette);
|
||||
window.ApplyChromeMode(themeState.UseSystemChrome);
|
||||
}
|
||||
|
||||
private sealed class HostContext : IComponentEditorHostContext
|
||||
{
|
||||
private readonly ComponentEditorWindowService _owner;
|
||||
private readonly Action _refreshAction;
|
||||
private readonly Action<string?>? _restartAction;
|
||||
|
||||
public HostContext(
|
||||
ComponentEditorWindowService owner,
|
||||
Action refreshAction,
|
||||
Action<string?>? restartAction)
|
||||
{
|
||||
_owner = owner;
|
||||
_refreshAction = refreshAction;
|
||||
_restartAction = restartAction;
|
||||
}
|
||||
|
||||
public void RequestRefresh()
|
||||
{
|
||||
_refreshAction();
|
||||
}
|
||||
|
||||
public void CloseEditor()
|
||||
{
|
||||
_owner.Close();
|
||||
}
|
||||
|
||||
public void RequestRestart(string? reason = null)
|
||||
{
|
||||
_restartAction?.Invoke(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class DesktopComponentEditorRegistryFactory
|
||||
{
|
||||
public static DesktopComponentEditorRegistry Create(
|
||||
ComponentRegistry componentRegistry,
|
||||
PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(componentRegistry);
|
||||
|
||||
var registrations = GetBuiltInRegistrations(componentRegistry).ToList();
|
||||
var registeredIds = new HashSet<string>(
|
||||
registrations.Select(registration => registration.ComponentId),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (pluginRuntimeService is not null)
|
||||
{
|
||||
foreach (var contribution in pluginRuntimeService.DesktopComponentEditors)
|
||||
{
|
||||
var registration = contribution.Registration;
|
||||
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out var definition) ||
|
||||
!definition.AllowDesktopPlacement ||
|
||||
!registeredIds.Add(registration.ComponentId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
registrations.Add(new DesktopComponentEditorRegistration(
|
||||
registration.ComponentId,
|
||||
context => CreatePluginEditor(contribution, context),
|
||||
registration.PreferredWidth,
|
||||
registration.PreferredHeight,
|
||||
registration.MinScale,
|
||||
registration.MaxScale));
|
||||
}
|
||||
}
|
||||
|
||||
return new DesktopComponentEditorRegistry(componentRegistry, registrations);
|
||||
}
|
||||
|
||||
private static IEnumerable<DesktopComponentEditorRegistration> GetBuiltInRegistrations(ComponentRegistry componentRegistry)
|
||||
{
|
||||
var registrations = new Dictionary<string, DesktopComponentEditorRegistration>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[BuiltInComponentIds.DesktopClock] = new(
|
||||
BuiltInComponentIds.DesktopClock,
|
||||
context => new ClockComponentEditor(context)),
|
||||
[BuiltInComponentIds.DesktopWorldClock] = new(
|
||||
BuiltInComponentIds.DesktopWorldClock,
|
||||
context => new WorldClockComponentEditor(context),
|
||||
preferredWidth: 820d,
|
||||
preferredHeight: 620d),
|
||||
[BuiltInComponentIds.DesktopClassSchedule] = new(
|
||||
BuiltInComponentIds.DesktopClassSchedule,
|
||||
context => new ClassScheduleComponentEditor(context),
|
||||
preferredWidth: 860d,
|
||||
preferredHeight: 640d),
|
||||
[BuiltInComponentIds.DesktopDailyArtwork] = new(
|
||||
BuiltInComponentIds.DesktopDailyArtwork,
|
||||
context => new DailyArtworkComponentEditor(context)),
|
||||
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
|
||||
BuiltInComponentIds.DesktopStudyEnvironment,
|
||||
context => new StudyEnvironmentComponentEditor(context)),
|
||||
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
|
||||
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
|
||||
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
|
||||
[BuiltInComponentIds.DesktopMultiDayWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopMultiDayWeather),
|
||||
[BuiltInComponentIds.DesktopExtendedWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopExtendedWeather),
|
||||
[BuiltInComponentIds.DesktopCnrDailyNews] = new(
|
||||
BuiltInComponentIds.DesktopCnrDailyNews,
|
||||
context => new ToggleIntervalComponentEditor(
|
||||
context,
|
||||
new ToggleIntervalComponentEditorOptions
|
||||
{
|
||||
DescriptionKey = "cnr.settings.desc",
|
||||
DescriptionFallback = "Configure auto rotation for this CNR news widget.",
|
||||
ToggleLabelKey = "cnr.settings.auto_rotate",
|
||||
ToggleLabelFallback = "Auto rotate",
|
||||
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||
IntervalLabelKey = "cnr.settings.rotate_interval",
|
||||
IntervalLabelFallback = "Rotate interval",
|
||||
DefaultInterval = 60,
|
||||
GetEnabled = snapshot => snapshot.CnrDailyNewsAutoRotateEnabled,
|
||||
SetEnabled = (snapshot, value) => snapshot.CnrDailyNewsAutoRotateEnabled = value,
|
||||
GetInterval = snapshot => snapshot.CnrDailyNewsAutoRotateIntervalMinutes,
|
||||
SetInterval = (snapshot, value) => snapshot.CnrDailyNewsAutoRotateIntervalMinutes = value,
|
||||
ChangedKeys =
|
||||
[
|
||||
nameof(ComponentSettingsSnapshot.CnrDailyNewsAutoRotateEnabled),
|
||||
nameof(ComponentSettingsSnapshot.CnrDailyNewsAutoRotateIntervalMinutes)
|
||||
]
|
||||
})),
|
||||
[BuiltInComponentIds.DesktopIfengNews] = new(
|
||||
BuiltInComponentIds.DesktopIfengNews,
|
||||
context => new ToggleIntervalComponentEditor(
|
||||
context,
|
||||
new ToggleIntervalComponentEditorOptions
|
||||
{
|
||||
DescriptionKey = "ifeng.settings.desc",
|
||||
DescriptionFallback = "Configure auto refresh and source channel for this iFeng widget.",
|
||||
ToggleLabelKey = "ifeng.settings.auto_refresh",
|
||||
ToggleLabelFallback = "Auto refresh",
|
||||
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||
IntervalLabelKey = "ifeng.settings.refresh_interval",
|
||||
IntervalLabelFallback = "Refresh interval",
|
||||
DefaultInterval = 20,
|
||||
GetEnabled = snapshot => snapshot.IfengNewsAutoRefreshEnabled,
|
||||
SetEnabled = (snapshot, value) => snapshot.IfengNewsAutoRefreshEnabled = value,
|
||||
GetInterval = snapshot => snapshot.IfengNewsAutoRefreshIntervalMinutes,
|
||||
SetInterval = (snapshot, value) => snapshot.IfengNewsAutoRefreshIntervalMinutes = value,
|
||||
ExtraSelectorLabelKey = "ifeng.settings.channel",
|
||||
ExtraSelectorLabelFallback = "Channel",
|
||||
ExtraOptions =
|
||||
[
|
||||
new ComponentEditorSelectionOption(
|
||||
IfengNewsChannelTypes.Comprehensive,
|
||||
"ifeng.settings.channel.comprehensive",
|
||||
"Comprehensive"),
|
||||
new ComponentEditorSelectionOption(
|
||||
IfengNewsChannelTypes.Mainland,
|
||||
"ifeng.settings.channel.mainland",
|
||||
"Mainland"),
|
||||
new ComponentEditorSelectionOption(
|
||||
IfengNewsChannelTypes.Taiwan,
|
||||
"ifeng.settings.channel.taiwan",
|
||||
"Taiwan")
|
||||
],
|
||||
GetExtraValue = snapshot => IfengNewsChannelTypes.Normalize(snapshot.IfengNewsChannelType),
|
||||
SetExtraValue = (snapshot, value) => snapshot.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(value),
|
||||
ChangedKeys =
|
||||
[
|
||||
nameof(ComponentSettingsSnapshot.IfengNewsAutoRefreshEnabled),
|
||||
nameof(ComponentSettingsSnapshot.IfengNewsAutoRefreshIntervalMinutes),
|
||||
nameof(ComponentSettingsSnapshot.IfengNewsChannelType)
|
||||
]
|
||||
})),
|
||||
[BuiltInComponentIds.DesktopDailyWord] = CreateDailyWordRegistration(BuiltInComponentIds.DesktopDailyWord),
|
||||
[BuiltInComponentIds.DesktopDailyWord2x2] = CreateDailyWordRegistration(BuiltInComponentIds.DesktopDailyWord2x2),
|
||||
[BuiltInComponentIds.DesktopBilibiliHotSearch] = new(
|
||||
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||
context => new ToggleIntervalComponentEditor(
|
||||
context,
|
||||
new ToggleIntervalComponentEditorOptions
|
||||
{
|
||||
DescriptionKey = "bilibili.settings.desc",
|
||||
DescriptionFallback = "Configure auto refresh for this Bilibili hot search widget.",
|
||||
ToggleLabelKey = "bilibili.settings.auto_refresh",
|
||||
ToggleLabelFallback = "Auto refresh",
|
||||
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||
IntervalLabelKey = "bilibili.settings.refresh_interval",
|
||||
IntervalLabelFallback = "Refresh interval",
|
||||
DefaultInterval = 15,
|
||||
GetEnabled = snapshot => snapshot.BilibiliHotSearchAutoRefreshEnabled,
|
||||
SetEnabled = (snapshot, value) => snapshot.BilibiliHotSearchAutoRefreshEnabled = value,
|
||||
GetInterval = snapshot => snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes,
|
||||
SetInterval = (snapshot, value) => snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes = value,
|
||||
ChangedKeys =
|
||||
[
|
||||
nameof(ComponentSettingsSnapshot.BilibiliHotSearchAutoRefreshEnabled),
|
||||
nameof(ComponentSettingsSnapshot.BilibiliHotSearchAutoRefreshIntervalMinutes)
|
||||
]
|
||||
})),
|
||||
[BuiltInComponentIds.DesktopBaiduHotSearch] = new(
|
||||
BuiltInComponentIds.DesktopBaiduHotSearch,
|
||||
context => new ToggleIntervalComponentEditor(
|
||||
context,
|
||||
new ToggleIntervalComponentEditorOptions
|
||||
{
|
||||
DescriptionKey = "baidu.settings.desc",
|
||||
DescriptionFallback = "Configure auto refresh and source for this Baidu hot search widget.",
|
||||
ToggleLabelKey = "baidu.settings.auto_refresh",
|
||||
ToggleLabelFallback = "Auto refresh",
|
||||
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||
IntervalLabelKey = "baidu.settings.refresh_interval",
|
||||
IntervalLabelFallback = "Refresh interval",
|
||||
DefaultInterval = 15,
|
||||
GetEnabled = snapshot => snapshot.BaiduHotSearchAutoRefreshEnabled,
|
||||
SetEnabled = (snapshot, value) => snapshot.BaiduHotSearchAutoRefreshEnabled = value,
|
||||
GetInterval = snapshot => snapshot.BaiduHotSearchAutoRefreshIntervalMinutes,
|
||||
SetInterval = (snapshot, value) => snapshot.BaiduHotSearchAutoRefreshIntervalMinutes = value,
|
||||
ExtraSelectorLabelKey = "baidu.settings.source",
|
||||
ExtraSelectorLabelFallback = "Source",
|
||||
ExtraOptions =
|
||||
[
|
||||
new ComponentEditorSelectionOption(
|
||||
BaiduHotSearchSourceTypes.Official,
|
||||
"baidu.settings.source.official",
|
||||
"Official"),
|
||||
new ComponentEditorSelectionOption(
|
||||
BaiduHotSearchSourceTypes.ThirdPartyRss,
|
||||
"baidu.settings.source.third_party",
|
||||
"Third-party RSS")
|
||||
],
|
||||
GetExtraValue = snapshot => BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType),
|
||||
SetExtraValue = (snapshot, value) => snapshot.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(value),
|
||||
ChangedKeys =
|
||||
[
|
||||
nameof(ComponentSettingsSnapshot.BaiduHotSearchAutoRefreshEnabled),
|
||||
nameof(ComponentSettingsSnapshot.BaiduHotSearchAutoRefreshIntervalMinutes),
|
||||
nameof(ComponentSettingsSnapshot.BaiduHotSearchSourceType)
|
||||
]
|
||||
})),
|
||||
[BuiltInComponentIds.DesktopStcn24Forum] = new(
|
||||
BuiltInComponentIds.DesktopStcn24Forum,
|
||||
context => new ToggleIntervalComponentEditor(
|
||||
context,
|
||||
new ToggleIntervalComponentEditorOptions
|
||||
{
|
||||
DescriptionKey = "stcn.settings.desc",
|
||||
DescriptionFallback = "Configure auto refresh and sort mode for this STCN forum widget.",
|
||||
ToggleLabelKey = "stcn.settings.auto_refresh",
|
||||
ToggleLabelFallback = "Auto refresh",
|
||||
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||
IntervalLabelKey = "stcn.settings.refresh_interval",
|
||||
IntervalLabelFallback = "Refresh interval",
|
||||
DefaultInterval = 20,
|
||||
GetEnabled = snapshot => snapshot.Stcn24ForumAutoRefreshEnabled,
|
||||
SetEnabled = (snapshot, value) => snapshot.Stcn24ForumAutoRefreshEnabled = value,
|
||||
GetInterval = snapshot => snapshot.Stcn24ForumAutoRefreshIntervalMinutes,
|
||||
SetInterval = (snapshot, value) => snapshot.Stcn24ForumAutoRefreshIntervalMinutes = value,
|
||||
ExtraSelectorLabelKey = "stcn.settings.sort_mode",
|
||||
ExtraSelectorLabelFallback = "Sort mode",
|
||||
ExtraOptions = Stcn24ForumSourceTypes.SupportedValues
|
||||
.Select(value => new ComponentEditorSelectionOption(
|
||||
value,
|
||||
$"stcn.settings.source.{value}",
|
||||
value))
|
||||
.ToArray(),
|
||||
GetExtraValue = snapshot => Stcn24ForumSourceTypes.Normalize(snapshot.Stcn24ForumSourceType),
|
||||
SetExtraValue = (snapshot, value) => snapshot.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(value),
|
||||
ChangedKeys =
|
||||
[
|
||||
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshEnabled),
|
||||
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshIntervalMinutes),
|
||||
nameof(ComponentSettingsSnapshot.Stcn24ForumSourceType)
|
||||
]
|
||||
}))
|
||||
};
|
||||
|
||||
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
|
||||
{
|
||||
if (registrations.ContainsKey(componentId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
registrations[componentId] = new DesktopComponentEditorRegistration(
|
||||
componentId,
|
||||
context => new InformationalComponentEditor(
|
||||
context,
|
||||
$"This {context.Definition.DisplayName} component currently exposes instance-scoped editor metadata only."));
|
||||
}
|
||||
|
||||
return registrations.Values;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetBuiltInDesktopComponentIds(ComponentRegistry componentRegistry)
|
||||
{
|
||||
return typeof(BuiltInComponentIds)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(field => field.FieldType == typeof(string))
|
||||
.Select(field => field.GetRawConstantValue() as string)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id!)
|
||||
.Where(id => componentRegistry.TryGetDefinition(id, out var definition) && definition.AllowDesktopPlacement)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static DesktopComponentEditorRegistration CreateWeatherRegistration(string componentId)
|
||||
{
|
||||
return new DesktopComponentEditorRegistration(
|
||||
componentId,
|
||||
context => new ToggleIntervalComponentEditor(
|
||||
context,
|
||||
new ToggleIntervalComponentEditorOptions
|
||||
{
|
||||
DescriptionKey = "weather.settings.desc",
|
||||
DescriptionFallback = "Configure weather auto refresh for this component instance.",
|
||||
ToggleLabelKey = "weather.settings.auto_refresh",
|
||||
ToggleLabelFallback = "Auto refresh",
|
||||
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||
IntervalLabelKey = "weather.settings.refresh_interval",
|
||||
IntervalLabelFallback = "Refresh interval",
|
||||
DefaultInterval = 12,
|
||||
GetEnabled = snapshot => snapshot.WeatherAutoRefreshEnabled,
|
||||
SetEnabled = (snapshot, value) => snapshot.WeatherAutoRefreshEnabled = value,
|
||||
GetInterval = snapshot => snapshot.WeatherAutoRefreshIntervalMinutes,
|
||||
SetInterval = (snapshot, value) => snapshot.WeatherAutoRefreshIntervalMinutes = value,
|
||||
ChangedKeys =
|
||||
[
|
||||
nameof(ComponentSettingsSnapshot.WeatherAutoRefreshEnabled),
|
||||
nameof(ComponentSettingsSnapshot.WeatherAutoRefreshIntervalMinutes)
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
||||
private static DesktopComponentEditorRegistration CreateDailyWordRegistration(string componentId)
|
||||
{
|
||||
return new DesktopComponentEditorRegistration(
|
||||
componentId,
|
||||
context => new ToggleIntervalComponentEditor(
|
||||
context,
|
||||
new ToggleIntervalComponentEditorOptions
|
||||
{
|
||||
DescriptionKey = "dailyword.settings.desc",
|
||||
DescriptionFallback = "Configure auto refresh for this Daily Word component.",
|
||||
ToggleLabelKey = "dailyword.settings.auto_refresh",
|
||||
ToggleLabelFallback = "Auto refresh",
|
||||
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||
IntervalLabelKey = "dailyword.settings.refresh_interval",
|
||||
IntervalLabelFallback = "Refresh interval",
|
||||
DefaultInterval = 360,
|
||||
GetEnabled = snapshot => snapshot.DailyWordAutoRefreshEnabled,
|
||||
SetEnabled = (snapshot, value) => snapshot.DailyWordAutoRefreshEnabled = value,
|
||||
GetInterval = snapshot => snapshot.DailyWordAutoRefreshIntervalMinutes,
|
||||
SetInterval = (snapshot, value) => snapshot.DailyWordAutoRefreshIntervalMinutes = value,
|
||||
ChangedKeys =
|
||||
[
|
||||
nameof(ComponentSettingsSnapshot.DailyWordAutoRefreshEnabled),
|
||||
nameof(ComponentSettingsSnapshot.DailyWordAutoRefreshIntervalMinutes)
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
||||
private static Control CreatePluginEditor(
|
||||
PluginDesktopComponentEditorContribution contribution,
|
||||
DesktopComponentEditorContext context)
|
||||
{
|
||||
var settingsService = contribution.Plugin.Services.GetService(typeof(ISettingsService)) as ISettingsService
|
||||
?? context.SettingsService;
|
||||
var pluginSettings = new PluginScopedSettingsService(
|
||||
contribution.Plugin.Manifest.Id,
|
||||
settingsService);
|
||||
var pluginContext = new PluginDesktopComponentEditorContext(
|
||||
contribution.Plugin.Manifest,
|
||||
contribution.Plugin.Context.PluginDirectory,
|
||||
contribution.Plugin.Context.DataDirectory,
|
||||
contribution.Plugin.Services,
|
||||
contribution.Plugin.Context.Properties,
|
||||
context.ComponentId,
|
||||
context.PlacementId,
|
||||
pluginSettings,
|
||||
context.HostContext);
|
||||
|
||||
return contribution.Registration.EditorFactory(contribution.Plugin.Services, pluginContext);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,12 @@ public sealed record WeatherQueryResult<T>(
|
||||
public interface IWeatherInfoService
|
||||
{
|
||||
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(WeatherQuery query, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
string? locale = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IWeatherDataService : IWeatherInfoService
|
||||
|
||||
351
LanMountainDesktop/Services/LocationService.cs
Normal file
351
LanMountainDesktop/Services/LocationService.cs
Normal file
@@ -0,0 +1,351 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public enum LocationFailureReason
|
||||
{
|
||||
None = 0,
|
||||
Unsupported = 1,
|
||||
PermissionDenied = 2,
|
||||
Disabled = 3,
|
||||
Timeout = 4,
|
||||
Cancelled = 5,
|
||||
Unavailable = 6,
|
||||
Unknown = 7
|
||||
}
|
||||
|
||||
public readonly record struct LocationCoordinate(
|
||||
double Latitude,
|
||||
double Longitude,
|
||||
double? AccuracyMeters = null);
|
||||
|
||||
public sealed record LocationRequestResult(
|
||||
bool Success,
|
||||
bool IsSupported,
|
||||
LocationCoordinate? Coordinate = null,
|
||||
LocationFailureReason FailureReason = LocationFailureReason.None,
|
||||
string? ErrorMessage = null)
|
||||
{
|
||||
public static LocationRequestResult Unsupported(string? errorMessage = null)
|
||||
=> new(false, false, null, LocationFailureReason.Unsupported, errorMessage);
|
||||
|
||||
public static LocationRequestResult Ok(LocationCoordinate coordinate)
|
||||
=> new(true, true, coordinate, LocationFailureReason.None, null);
|
||||
|
||||
public static LocationRequestResult Fail(LocationFailureReason reason, string? errorMessage = null)
|
||||
=> new(false, true, null, reason, errorMessage);
|
||||
}
|
||||
|
||||
public interface ILocationService
|
||||
{
|
||||
bool IsSupported { get; }
|
||||
|
||||
Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class UnsupportedLocationService : ILocationService
|
||||
{
|
||||
public bool IsSupported => false;
|
||||
|
||||
public Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
return Task.FromResult(LocationRequestResult.Unsupported("Location service is not supported on this platform."));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WindowsLocationService : ILocationService
|
||||
{
|
||||
private static readonly Type? GeolocatorType = ResolveWinRtType("Windows.Devices.Geolocation.Geolocator");
|
||||
private static readonly MethodInfo? RequestAccessAsyncMethod =
|
||||
GeolocatorType?.GetMethod("RequestAccessAsync", BindingFlags.Public | BindingFlags.Static);
|
||||
private static readonly MethodInfo? AsTaskGenericMethodDefinition = ResolveAsTaskGenericMethod();
|
||||
|
||||
public bool IsSupported =>
|
||||
OperatingSystem.IsWindows() &&
|
||||
GeolocatorType is not null &&
|
||||
RequestAccessAsyncMethod is not null &&
|
||||
AsTaskGenericMethodDefinition is not null;
|
||||
|
||||
public async Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsSupported)
|
||||
{
|
||||
return LocationRequestResult.Unsupported();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var access = await AwaitWinRtOperationAsync(RequestAccessAsyncMethod!.Invoke(null, null), cancellationToken);
|
||||
var accessText = access?.ToString();
|
||||
if (string.Equals(accessText, "Denied", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LocationRequestResult.Fail(
|
||||
LocationFailureReason.PermissionDenied,
|
||||
"Location permission was denied by the system.");
|
||||
}
|
||||
|
||||
if (string.Equals(accessText, "Unspecified", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LocationRequestResult.Fail(
|
||||
LocationFailureReason.Disabled,
|
||||
"Location access is unavailable on this device.");
|
||||
}
|
||||
|
||||
var geolocator = Activator.CreateInstance(GeolocatorType!);
|
||||
if (geolocator is null)
|
||||
{
|
||||
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Failed to create a Windows geolocator instance.");
|
||||
}
|
||||
|
||||
SetPropertyValue(geolocator, "DesiredAccuracyInMeters", (uint)50);
|
||||
SetPropertyValue(geolocator, "MovementThreshold", 0d);
|
||||
SetPropertyValue(geolocator, "ReportInterval", (uint)0);
|
||||
|
||||
var geoposition = await AwaitWinRtOperationAsync(
|
||||
InvokeMethod(geolocator, "GetGeopositionAsync"),
|
||||
cancellationToken);
|
||||
if (geoposition is null)
|
||||
{
|
||||
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Location request returned no position.");
|
||||
}
|
||||
|
||||
var coordinate = GetPropertyValue(geoposition, "Coordinate");
|
||||
var point = GetPropertyValue(coordinate, "Point");
|
||||
var position = GetPropertyValue(point, "Position");
|
||||
|
||||
var latitude = ReadDoubleProperty(position, "Latitude");
|
||||
var longitude = ReadDoubleProperty(position, "Longitude");
|
||||
if (!latitude.HasValue || !longitude.HasValue)
|
||||
{
|
||||
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Location coordinates are not available.");
|
||||
}
|
||||
|
||||
var accuracy = ReadDoubleProperty(coordinate, "Accuracy");
|
||||
return LocationRequestResult.Ok(new LocationCoordinate(latitude.Value, longitude.Value, accuracy));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return LocationRequestResult.Fail(
|
||||
cancellationToken.IsCancellationRequested ? LocationFailureReason.Cancelled : LocationFailureReason.Timeout,
|
||||
"Location request was cancelled.");
|
||||
}
|
||||
catch (TargetInvocationException ex) when (ex.InnerException is not null)
|
||||
{
|
||||
return MapException(ex.InnerException);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return MapException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static LocationRequestResult MapException(Exception ex)
|
||||
{
|
||||
if (ex is UnauthorizedAccessException)
|
||||
{
|
||||
return LocationRequestResult.Fail(LocationFailureReason.PermissionDenied, ex.Message);
|
||||
}
|
||||
|
||||
if (ex is TimeoutException)
|
||||
{
|
||||
return LocationRequestResult.Fail(LocationFailureReason.Timeout, ex.Message);
|
||||
}
|
||||
|
||||
var hr = ex.HResult;
|
||||
if (hr == unchecked((int)0x80070422))
|
||||
{
|
||||
return LocationRequestResult.Fail(LocationFailureReason.Disabled, ex.Message);
|
||||
}
|
||||
|
||||
return LocationRequestResult.Fail(LocationFailureReason.Unknown, ex.Message);
|
||||
}
|
||||
|
||||
private static async Task<object?> AwaitWinRtOperationAsync(object? operation, CancellationToken cancellationToken)
|
||||
{
|
||||
if (operation is null || AsTaskGenericMethodDefinition is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resultType = ResolveWinRtOperationResultType(operation.GetType());
|
||||
if (resultType is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var asTaskMethod = AsTaskGenericMethodDefinition.MakeGenericMethod(resultType);
|
||||
var taskObject = asTaskMethod.Invoke(null, [operation]) as Task;
|
||||
if (taskObject is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await taskObject.WaitAsync(cancellationToken);
|
||||
return taskObject
|
||||
.GetType()
|
||||
.GetProperty("Result", BindingFlags.Public | BindingFlags.Instance)?
|
||||
.GetValue(taskObject);
|
||||
}
|
||||
|
||||
private static Type? ResolveWinRtOperationResultType(Type operationType)
|
||||
{
|
||||
if (operationType.IsGenericType)
|
||||
{
|
||||
var genericArguments = operationType.GetGenericArguments();
|
||||
if (genericArguments.Length == 1)
|
||||
{
|
||||
return genericArguments[0];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var iface in operationType.GetInterfaces())
|
||||
{
|
||||
if (!iface.IsGenericType)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var genericTypeDef = iface.GetGenericTypeDefinition();
|
||||
if (string.Equals(genericTypeDef.FullName, "Windows.Foundation.IAsyncOperation`1", StringComparison.Ordinal))
|
||||
{
|
||||
return iface.GetGenericArguments()[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MethodInfo? ResolveAsTaskGenericMethod()
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = Type.GetType("System.WindowsRuntimeSystemExtensions, System.Runtime.WindowsRuntime", throwOnError: false);
|
||||
if (type is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.Equals(method.Name, "AsTask", StringComparison.Ordinal) ||
|
||||
!method.IsGenericMethodDefinition)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length == 1)
|
||||
{
|
||||
return method;
|
||||
}
|
||||
}
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
// Some WinRT bridge overloads throw during metadata inspection on unsupported runtimes.
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unusable overloads and keep probing for a compatible AsTask<T>.
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If the WinRT bridge is unavailable, the location service will gracefully report unsupported.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Type? ResolveWinRtType(string typeName)
|
||||
{
|
||||
return Type.GetType($"{typeName}, Windows, ContentType=WindowsRuntime", throwOnError: false);
|
||||
}
|
||||
|
||||
private static object? InvokeMethod(object? target, string methodName)
|
||||
{
|
||||
return target?.GetType().GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance)?.Invoke(target, null);
|
||||
}
|
||||
|
||||
private static object? GetPropertyValue(object? target, string propertyName)
|
||||
{
|
||||
return target?.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)?.GetValue(target);
|
||||
}
|
||||
|
||||
private static void SetPropertyValue(object target, string propertyName, object value)
|
||||
{
|
||||
var property = target.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (property is null || !property.CanWrite)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
property.SetValue(target, value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static double? ReadDoubleProperty(object? target, string propertyName)
|
||||
{
|
||||
var value = GetPropertyValue(target, propertyName);
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.ToDouble(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class HostLocationServiceProvider
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static ILocationService? _instance;
|
||||
|
||||
public static ILocationService GetOrCreate()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (_instance is not null)
|
||||
{
|
||||
return _instance;
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
_instance = new UnsupportedLocationService();
|
||||
return _instance;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_instance = new WindowsLocationService();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Location", "Failed to initialize Windows location service. Falling back to unsupported mode.", ex);
|
||||
_instance = new UnsupportedLocationService();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,12 +120,27 @@ public interface IWeatherProvider
|
||||
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
|
||||
WeatherQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
string? locale = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IWeatherSettingsService
|
||||
{
|
||||
WeatherSettingsState Get();
|
||||
void Save(WeatherSettingsState state);
|
||||
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
|
||||
string keyword,
|
||||
string? locale = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
string? locale = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
IWeatherInfoService GetWeatherInfoService();
|
||||
}
|
||||
|
||||
|
||||
@@ -350,6 +350,15 @@ internal sealed class WeatherProviderAdapter : IWeatherProvider, IWeatherInfoSer
|
||||
return _weatherDataService.GetWeatherAsync(query, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
string? locale = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _weatherDataService.ResolveLocationAsync(latitude, longitude, locale, cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_weatherDataService is IDisposable disposable)
|
||||
@@ -380,7 +389,7 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
||||
snapshot.WeatherLongitude,
|
||||
snapshot.WeatherAutoRefreshLocation,
|
||||
snapshot.WeatherExcludedAlerts,
|
||||
snapshot.WeatherIconPackId,
|
||||
NormalizeIconPackId(snapshot.WeatherIconPackId),
|
||||
snapshot.WeatherNoTlsRequests,
|
||||
snapshot.WeatherLocationQuery);
|
||||
}
|
||||
@@ -395,7 +404,7 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
||||
snapshot.WeatherLongitude = state.Longitude;
|
||||
snapshot.WeatherAutoRefreshLocation = state.AutoRefreshLocation;
|
||||
snapshot.WeatherExcludedAlerts = state.ExcludedAlerts;
|
||||
snapshot.WeatherIconPackId = state.IconPackId;
|
||||
snapshot.WeatherIconPackId = NormalizeIconPackId(state.IconPackId);
|
||||
snapshot.WeatherNoTlsRequests = state.NoTlsRequests;
|
||||
snapshot.WeatherLocationQuery = state.LocationQuery;
|
||||
_settingsService.SaveSnapshot(
|
||||
@@ -416,6 +425,23 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
||||
]);
|
||||
}
|
||||
|
||||
public Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
|
||||
string keyword,
|
||||
string? locale = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _weatherProvider.SearchLocationsAsync(keyword, locale, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
string? locale = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _weatherProvider.ResolveLocationAsync(latitude, longitude, locale, cancellationToken);
|
||||
}
|
||||
|
||||
public IWeatherInfoService GetWeatherInfoService()
|
||||
{
|
||||
return _weatherProvider;
|
||||
@@ -425,6 +451,13 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
||||
{
|
||||
_weatherProvider.Dispose();
|
||||
}
|
||||
|
||||
private static string NormalizeIconPackId(string? iconPackId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(iconPackId)
|
||||
? "HyperOS3"
|
||||
: "HyperOS3";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RegionSettingsService : IRegionSettingsService
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Reflection;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views.SettingsPages;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -177,6 +178,8 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
|
||||
services.AddSingleton(_settingsFacade.Catalog);
|
||||
services.AddSingleton(_hostApplicationLifecycle);
|
||||
services.AddSingleton(_localizationService);
|
||||
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());
|
||||
services.AddSingleton<WeatherLocationRefreshService>();
|
||||
|
||||
var pluginRuntime = _pluginRuntimeAccessor();
|
||||
if (pluginRuntime is not null)
|
||||
|
||||
158
LanMountainDesktop/Services/WeatherLocationRefreshService.cs
Normal file
158
LanMountainDesktop/Services/WeatherLocationRefreshService.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record WeatherLocationRefreshResult(
|
||||
bool Success,
|
||||
bool IsSupported,
|
||||
WeatherSettingsState? AppliedState = null,
|
||||
WeatherLocation? ResolvedLocation = null,
|
||||
LocationRequestResult? LocationResult = null,
|
||||
string? ErrorMessage = null)
|
||||
{
|
||||
public static WeatherLocationRefreshResult Unsupported(LocationRequestResult result)
|
||||
=> new(false, false, null, null, result, result.ErrorMessage);
|
||||
|
||||
public static WeatherLocationRefreshResult Fail(LocationRequestResult? locationResult, string? errorMessage)
|
||||
=> new(false, locationResult?.IsSupported ?? true, null, null, locationResult, errorMessage);
|
||||
|
||||
public static WeatherLocationRefreshResult Ok(
|
||||
WeatherSettingsState state,
|
||||
WeatherLocation? resolvedLocation,
|
||||
LocationRequestResult locationResult)
|
||||
=> new(true, true, state, resolvedLocation, locationResult, null);
|
||||
}
|
||||
|
||||
public sealed class WeatherLocationRefreshService
|
||||
{
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly ILocationService _locationService;
|
||||
private readonly LocalizationService _localizationService;
|
||||
|
||||
public WeatherLocationRefreshService(
|
||||
ISettingsFacadeService settingsFacade,
|
||||
ILocationService locationService,
|
||||
LocalizationService localizationService)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_locationService = locationService ?? throw new ArgumentNullException(nameof(locationService));
|
||||
_localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
|
||||
}
|
||||
|
||||
public bool IsSupported => _locationService.IsSupported;
|
||||
|
||||
public async Task<WeatherLocationRefreshResult> RefreshCurrentLocationAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var locationResult = await _locationService.TryGetCurrentLocationAsync(cancellationToken);
|
||||
if (!locationResult.IsSupported)
|
||||
{
|
||||
return WeatherLocationRefreshResult.Unsupported(locationResult);
|
||||
}
|
||||
|
||||
if (!locationResult.Success || locationResult.Coordinate is null)
|
||||
{
|
||||
return WeatherLocationRefreshResult.Fail(locationResult, locationResult.ErrorMessage);
|
||||
}
|
||||
|
||||
var coordinate = locationResult.Coordinate.Value;
|
||||
var settingsState = _settingsFacade.Weather.Get();
|
||||
var languageCode = _settingsFacade.Region.Get().LanguageCode;
|
||||
var locale = NormalizeWeatherLocale(languageCode);
|
||||
|
||||
WeatherLocation? resolvedLocation = null;
|
||||
var weatherService = _settingsFacade.Weather.GetWeatherInfoService();
|
||||
var resolvedResult = await weatherService.ResolveLocationAsync(
|
||||
coordinate.Latitude,
|
||||
coordinate.Longitude,
|
||||
locale,
|
||||
cancellationToken);
|
||||
if (resolvedResult.Success && resolvedResult.Data is not null)
|
||||
{
|
||||
resolvedLocation = resolvedResult.Data;
|
||||
}
|
||||
|
||||
var locationKey = resolvedLocation?.LocationKey?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(locationKey))
|
||||
{
|
||||
locationKey = BuildCoordinateKey(coordinate.Latitude, coordinate.Longitude);
|
||||
}
|
||||
|
||||
var locationName = resolvedLocation?.Name?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(locationName))
|
||||
{
|
||||
locationName = BuildCoordinateDisplayName(languageCode, coordinate.Latitude, coordinate.Longitude);
|
||||
}
|
||||
|
||||
var nextState = settingsState with
|
||||
{
|
||||
LocationMode = "Coordinates",
|
||||
LocationKey = locationKey,
|
||||
LocationName = locationName,
|
||||
Latitude = Math.Round(coordinate.Latitude, 6),
|
||||
Longitude = Math.Round(coordinate.Longitude, 6),
|
||||
LocationQuery = resolvedLocation?.Name?.Trim() ?? settingsState.LocationQuery,
|
||||
IconPackId = NormalizeIconPackId(settingsState.IconPackId)
|
||||
};
|
||||
|
||||
_settingsFacade.Weather.Save(nextState);
|
||||
return WeatherLocationRefreshResult.Ok(nextState, resolvedLocation, locationResult);
|
||||
}
|
||||
|
||||
public async Task<bool> TryRefreshOnStartupAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = _settingsFacade.Weather.Get();
|
||||
var isCoordinatesMode = string.Equals(state.LocationMode, "Coordinates", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isCoordinatesMode || !state.AutoRefreshLocation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = await RefreshCurrentLocationAsync(cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"Weather.Location",
|
||||
$"Automatic weather location refresh failed. Reason='{result.LocationResult?.FailureReason}'. Message='{result.ErrorMessage ?? "<none>"}'.");
|
||||
}
|
||||
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
private static string NormalizeIconPackId(string? iconPackId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(iconPackId)
|
||||
? "HyperOS3"
|
||||
: "HyperOS3";
|
||||
}
|
||||
|
||||
private string BuildCoordinateDisplayName(string? languageCode, double latitude, double longitude)
|
||||
{
|
||||
var normalizedLanguage = _localizationService.NormalizeLanguageCode(languageCode);
|
||||
var format = _localizationService.GetString(
|
||||
normalizedLanguage,
|
||||
"settings.weather.coordinates_default_name_format",
|
||||
"Coordinate {0:F4}, {1:F4}");
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
format,
|
||||
latitude,
|
||||
longitude);
|
||||
}
|
||||
|
||||
private static string BuildCoordinateKey(double latitude, double longitude)
|
||||
{
|
||||
return FormattableString.Invariant($"coord:{latitude:F4},{longitude:F4}");
|
||||
}
|
||||
|
||||
private static string NormalizeWeatherLocale(string? languageCode)
|
||||
{
|
||||
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
||||
? "en_us"
|
||||
: "zh_cn";
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public sealed record XiaomiWeatherApiOptions
|
||||
|
||||
public string CitySearchPath { get; init; } = "/wtr-v3/location/city/search";
|
||||
|
||||
public string CityGeoPath { get; init; } = "/wtr-v3/location/city/geo";
|
||||
|
||||
public string AppKey { get; init; } = "weather20151024";
|
||||
|
||||
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
|
||||
@@ -173,6 +175,63 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
string? locale = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedLocale = string.IsNullOrWhiteSpace(locale) ? _options.Locale : locale.Trim();
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["longitude"] = longitude.ToString("F6", CultureInfo.InvariantCulture),
|
||||
["latitude"] = latitude.ToString("F6", CultureInfo.InvariantCulture),
|
||||
["locale"] = normalizedLocale
|
||||
};
|
||||
|
||||
var requestUri = BuildUri(_options.CityGeoPath, parameters);
|
||||
string responseText;
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return WeatherQueryResult<WeatherLocation>.Fail(
|
||||
"http_error",
|
||||
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return WeatherQueryResult<WeatherLocation>.Fail("network_error", ex.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
if (TryGetProperty(root, out var dataNode, "data"))
|
||||
{
|
||||
root = dataNode;
|
||||
}
|
||||
|
||||
var location = ParseSingleLocation(root, latitude, longitude);
|
||||
return location is null
|
||||
? WeatherQueryResult<WeatherLocation>.Fail("not_found", "No weather location could be resolved from the provided coordinates.")
|
||||
: WeatherQueryResult<WeatherLocation>.Ok(location);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return WeatherQueryResult<WeatherLocation>.Fail("parse_error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
|
||||
WeatherQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -285,6 +344,44 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
||||
return results;
|
||||
}
|
||||
|
||||
private static WeatherLocation? ParseSingleLocation(JsonElement root, double latitude, double longitude)
|
||||
{
|
||||
if (TryResolveLocationArray(root, out var locationArray))
|
||||
{
|
||||
foreach (var item in locationArray.EnumerateArray())
|
||||
{
|
||||
var location = ParseLocationItem(item);
|
||||
if (location is not null)
|
||||
{
|
||||
return location;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParseLocationItem(root, latitude, longitude);
|
||||
}
|
||||
|
||||
private static WeatherLocation? ParseLocationItem(JsonElement item, double? fallbackLatitude = null, double? fallbackLongitude = null)
|
||||
{
|
||||
var locationKey = ReadString(item, "locationKey") ??
|
||||
ReadString(item, "key") ??
|
||||
ReadString(item, "id");
|
||||
if (string.IsNullOrWhiteSpace(locationKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = ReadString(item, "name") ??
|
||||
ReadString(item, "city") ??
|
||||
locationKey;
|
||||
var affiliation = ReadString(item, "affiliation") ?? ReadString(item, "province");
|
||||
var latitude = ReadDouble(item, "latitude") ?? fallbackLatitude ?? 0;
|
||||
var longitude = ReadDouble(item, "longitude") ?? fallbackLongitude ?? 0;
|
||||
return new WeatherLocation(name, locationKey, latitude, longitude, affiliation);
|
||||
}
|
||||
|
||||
private WeatherSnapshot ParseWeatherSnapshot(
|
||||
JsonElement root,
|
||||
string locationKey,
|
||||
|
||||
Reference in New Issue
Block a user