changed.更了好多
@@ -20,7 +20,6 @@ using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.ExternalIpc;
|
||||
using LanMountainDesktop.Services.Launcher;
|
||||
using LanMountainDesktop.Services.Loading;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
@@ -83,7 +82,6 @@ public partial class App : Application
|
||||
private PublicIpcHostService? _publicIpcHostService;
|
||||
private LoadingStateManager? _loadingStateManager;
|
||||
private LoadingStateReporter? _loadingStateReporter;
|
||||
private bool _singleInstanceReleased;
|
||||
private int _forcedExitScheduled;
|
||||
private volatile bool _desktopShellInitializationStarted;
|
||||
private bool _mainWindowOpened;
|
||||
@@ -91,7 +89,6 @@ public partial class App : Application
|
||||
private readonly object _launcherProgressLock = new();
|
||||
private readonly List<StartupProgressMessage> _pendingLauncherProgressMessages = [];
|
||||
|
||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||
(Current as App)?._hostApplicationLifecycle;
|
||||
internal static INotificationService? CurrentNotificationService =>
|
||||
@@ -213,7 +210,6 @@ public partial class App : Application
|
||||
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePublicIpc();
|
||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||
_ = InitializeLauncherIpcAsync();
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
@@ -368,7 +364,7 @@ public partial class App : Application
|
||||
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||
},
|
||||
OnDesktopLifetimeExit,
|
||||
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
|
||||
static () => { },
|
||||
StartWeatherLocationRefreshIfNeeded);
|
||||
_desktopShellHost.Initialize(this);
|
||||
}
|
||||
@@ -377,7 +373,6 @@ public partial class App : Application
|
||||
{
|
||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||
PerformExitCleanup();
|
||||
ReleaseSingleInstanceAfterExit("DesktopLifetimeExit");
|
||||
ScheduleForcedProcessTermination("DesktopLifetimeExit");
|
||||
}
|
||||
|
||||
@@ -396,7 +391,7 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayMenu");
|
||||
RestoreOrCreateMainWindow("TrayMenu");
|
||||
}
|
||||
|
||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||
@@ -723,7 +718,7 @@ public partial class App : Application
|
||||
|
||||
if (_desktopShellState == DesktopShellState.TrayOnly)
|
||||
{
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityFailed");
|
||||
RestoreOrCreateMainWindow("TrayAvailabilityFailed");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -734,7 +729,7 @@ public partial class App : Application
|
||||
!taskbarUsable &&
|
||||
(_desktopTrayService?.ConsecutiveRecoveryFailures ?? 0) >= 3)
|
||||
{
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityRepeatedFailure");
|
||||
RestoreOrCreateMainWindow("TrayAvailabilityRepeatedFailure");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -862,39 +857,7 @@ public partial class App : Application
|
||||
Resources["AppFontFamily"] = fontFamily;
|
||||
}
|
||||
|
||||
internal void ActivateMainWindow()
|
||||
{
|
||||
AppLogger.Info("SingleInstance", $"Activation callback received. Pid={Environment.ProcessId}.");
|
||||
|
||||
if (!_desktopShellInitializationStarted && _mainWindow is null)
|
||||
{
|
||||
AppLogger.Info("SingleInstance", "Activation acknowledged while desktop shell is still initializing.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var restored = Dispatcher.UIThread.CheckAccess()
|
||||
? RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance")
|
||||
: Dispatcher.UIThread.InvokeAsync(
|
||||
() => RestoreOrCreateMainWindowCore(showSingleInstanceNotice: true, source: "SingleInstance"),
|
||||
DispatcherPriority.Send).GetAwaiter().GetResult();
|
||||
|
||||
if (!restored)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", "Activation callback could not restore the main window yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info("SingleInstance", "Activation callback completed successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", "Activation callback failed while restoring the desktop shell.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
|
||||
private void RestoreOrCreateMainWindow(string source)
|
||||
{
|
||||
if (IsShutdownInProgress)
|
||||
{
|
||||
@@ -904,11 +867,11 @@ public partial class App : Application
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
|
||||
_ = RestoreOrCreateMainWindowCore(source);
|
||||
}, DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source)
|
||||
private bool RestoreOrCreateMainWindowCore(string source)
|
||||
{
|
||||
if (IsShutdownInProgress)
|
||||
{
|
||||
@@ -966,12 +929,7 @@ public partial class App : Application
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
|
||||
|
||||
if (showSingleInstanceNotice)
|
||||
{
|
||||
mainWindow.ShowSingleInstanceNotice();
|
||||
}
|
||||
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; WindowState='{mainWindow.WindowState}'.");
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -989,7 +947,7 @@ public partial class App : Application
|
||||
_transparentOverlayWindow = new TransparentOverlayWindow();
|
||||
_transparentOverlayWindow.RestoreMainWindowRequested += (s, e) =>
|
||||
{
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TransparentOverlay");
|
||||
RestoreOrCreateMainWindow("TransparentOverlay");
|
||||
};
|
||||
_transparentOverlayWindow.ExitEditRequested += (s, e) =>
|
||||
{
|
||||
@@ -1044,7 +1002,6 @@ public partial class App : Application
|
||||
ScheduleForcedProcessTermination($"ShutdownRequest:{source}");
|
||||
StopShellRecoveryWatchdog();
|
||||
PerformExitCleanup();
|
||||
ReleaseSingleInstanceAfterExit($"ShutdownRequest:{source}");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -1195,33 +1152,6 @@ public partial class App : Application
|
||||
_appearanceThemeService.ApplyThemeResources(Resources);
|
||||
}
|
||||
|
||||
private void ReleaseSingleInstanceAfterExit(string source)
|
||||
{
|
||||
if (_singleInstanceReleased)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_singleInstanceReleased = true;
|
||||
var singleInstance = CurrentSingleInstanceService;
|
||||
CurrentSingleInstanceService = null;
|
||||
if (singleInstance is null)
|
||||
{
|
||||
AppLogger.Info("SingleInstance", $"No single-instance handle to release. Source='{source}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
singleInstance.Dispose();
|
||||
AppLogger.Info("SingleInstance", $"Released single-instance handle. Source='{source}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", $"Failed to release single-instance handle. Source='{source}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleForcedProcessTermination(string source)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _forcedExitScheduled, 1) != 0)
|
||||
@@ -1809,7 +1739,7 @@ public partial class App : Application
|
||||
GetPublicShellStatus());
|
||||
}
|
||||
|
||||
var restored = RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
|
||||
var restored = RestoreOrCreateMainWindowCore(source);
|
||||
var status = GetPublicShellStatus();
|
||||
if (restored)
|
||||
{
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# 天气背景资源署名
|
||||
|
||||
## 中文
|
||||
|
||||
本目录中的天气背景图像主要来自 **Pexels**,并按 Pexels License 使用:
|
||||
|
||||
- License: https://www.pexels.com/license/
|
||||
|
||||
### 原始来源
|
||||
|
||||
- `clear_sky.jpg`
|
||||
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
|
||||
- `rain.jpg`
|
||||
- https://www.pexels.com/photo/rain-on-window-with-bokeh-lights-35075853/
|
||||
- `snow.jpg`
|
||||
- https://www.pexels.com/photo/mountain-covered-with-snow-209955/
|
||||
- `storm.jpg`
|
||||
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
|
||||
|
||||
### 派生资源
|
||||
|
||||
以下文件由上述基础图片经过色彩、亮度或风格调整后生成,用于适配阑山桌面的天气组件视觉:
|
||||
|
||||
- `clear_day.jpg`
|
||||
- `clear_night.jpg`
|
||||
- `cloudy_day.jpg`
|
||||
- `cloudy_night.jpg`
|
||||
- `rain_light.jpg`
|
||||
- `rain_heavy.jpg`
|
||||
- `storm_dark.jpg`
|
||||
- `fog_haze.jpg`
|
||||
- `snow_soft.jpg`
|
||||
|
||||
## English
|
||||
|
||||
The weather background images in this directory are primarily sourced from **Pexels** and used under the Pexels License:
|
||||
|
||||
- License: https://www.pexels.com/license/
|
||||
|
||||
Derived variants in this repository are adjusted from the listed base assets for widget presentation.
|
||||
@@ -1,37 +0,0 @@
|
||||
# HyperOS3 天气资源署名
|
||||
|
||||
## 中文
|
||||
|
||||
本目录中的 HyperOS3 风格天气资源来自用户提供的 Xiaomi Weather 安装包提取内容,以及基于该视觉方向制作的项目内派生资源。
|
||||
|
||||
### 提取来源
|
||||
|
||||
- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk`
|
||||
- Package: `com.miui.weather2`
|
||||
- Extraction date: `2026-03-03`
|
||||
|
||||
### 用途说明
|
||||
|
||||
- 这些资源仅用于项目内部视觉研究、原型还原和界面适配。
|
||||
- 使用时应遵守小米相关许可与使用条款。
|
||||
|
||||
### 额外派生资源
|
||||
|
||||
以下文件为项目内基于上述视觉方向制作的派生素材:
|
||||
|
||||
- `Icons/icon_hero_sun_soft.png`
|
||||
- `Icons/icon_hero_moon_soft.png`
|
||||
- `Icons/icon_mini_partly_cloudy_day_soft.png`
|
||||
- `Icons/icon_mini_partly_cloudy_night_soft.png`
|
||||
- `Icons/icon_mini_cloudy_soft.png`
|
||||
- `Icons/icon_mini_rain_light_soft.png`
|
||||
- `Icons/icon_mini_rain_heavy_soft.png`
|
||||
- `Icons/icon_mini_storm_soft.png`
|
||||
- `Icons/icon_mini_snow_soft.png`
|
||||
- `Icons/icon_mini_fog_soft.png`
|
||||
|
||||
## English
|
||||
|
||||
The HyperOS3-style weather assets in this directory were extracted from a Xiaomi Weather APK provided by the user, together with additional derivative assets created in-repo to match the same visual direction.
|
||||
|
||||
Use these resources only in accordance with Xiaomi's applicable license and usage terms.
|
||||
|
Before Width: | Height: | Size: 422 B |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 910 B |
|
Before Width: | Height: | Size: 988 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 766 B |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 734 B |
|
Before Width: | Height: | Size: 618 B |
|
Before Width: | Height: | Size: 754 B |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 656 B |
|
Before Width: | Height: | Size: 660 B |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 260 B |
|
Before Width: | Height: | Size: 477 B |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 152 B |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 683 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB |
@@ -26,6 +26,7 @@
|
||||
<EmbeddedResource Include="Localization\*.json" />
|
||||
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="WindowsIdentity\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -352,6 +352,12 @@
|
||||
"settings.general.slide_transition_desc": "Use a slide-in startup transition on supported Windows builds. This option disables fade transition.",
|
||||
"settings.general.show_main_window_taskbar_header": "Show main desktop window in taskbar",
|
||||
"settings.general.show_main_window_taskbar_desc": "Keep the main desktop host window visible in the taskbar. The independent settings window always has its own taskbar entry.",
|
||||
"settings.general.multi_instance_behavior_header": "When opening the app again",
|
||||
"settings.general.multi_instance_behavior_desc": "Choose how Launcher handles repeated launches while LanMountain Desktop is already running.",
|
||||
"settings.general.multi_instance_behavior.restart": "Restart app",
|
||||
"settings.general.multi_instance_behavior.open_silently": "Open desktop without prompt",
|
||||
"settings.general.multi_instance_behavior.prompt_only": "Show prompt only",
|
||||
"settings.general.multi_instance_behavior.notify_and_open": "Notify and open desktop",
|
||||
"settings.data.title": "Data",
|
||||
"settings.data.description": "Review and manage local app storage and cache.",
|
||||
"settings.appearance.title": "Appearance",
|
||||
@@ -560,9 +566,17 @@
|
||||
"settings.update.description": "Check releases, choose the update channel and download source, and control how updates are installed.",
|
||||
"settings.update.status_card_title": "Update Status",
|
||||
"settings.update.status_card_description": "Check for updates, review release details, and continue with download or installation when a new version is available.",
|
||||
"settings.update.preferences_header": "Update Preferences",
|
||||
"settings.update.release_facts_title": "Release Facts",
|
||||
"settings.update.release_facts_description": "Keep the current version, published release, and update type visible without collapsing the layout while states change.",
|
||||
"settings.update.progress_title": "Progress",
|
||||
"settings.update.progress_description": "Watch download, installation, verification, and recovery progress here.",
|
||||
"settings.update.actions_title": "Actions",
|
||||
"settings.update.actions_description": "The buttons below stay in place while the update phase changes, so the page does not jump around.",
|
||||
"settings.update.preferences_title": "Update Preferences",
|
||||
"settings.update.preferences_description": "Choose the release channel, installer download source, installation behavior, and download parallelism.",
|
||||
"settings.update.last_checked_label": "Last Checked",
|
||||
"settings.update.last_checked_none": "Not checked yet.",
|
||||
"settings.update.last_checked_format": "Last checked: {0}",
|
||||
"settings.update.source_label": "Download Source",
|
||||
"settings.update.source_github": "GitHub",
|
||||
"settings.update.source_ghproxy": "gh-proxy",
|
||||
@@ -579,15 +593,146 @@
|
||||
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
|
||||
"settings.update.download_threads_label": "Download Threads",
|
||||
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.",
|
||||
"settings.update.force_check_label": "Force Check Update",
|
||||
"settings.update.force_check_desc": "Force check for updates from GitHub, ignoring version comparison.",
|
||||
"settings.update.status_force_checking": "Force checking GitHub releases...",
|
||||
"settings.update.status_force_no_asset": "Release found but no compatible installer available.",
|
||||
"settings.update.status_force_available_format": "Release {0} is available. Click Download & Install.",
|
||||
"settings.update.install_now_button": "Install Now",
|
||||
"settings.update.type_label": "Update Type",
|
||||
"settings.update.status_idle": "No update check has been performed yet.",
|
||||
"settings.update.status_preferences_saved": "Update preferences saved.",
|
||||
"settings.update.status_check_failed": "Failed to check for updates.",
|
||||
"settings.update.status_available_summary_format": "Update available: {0} (current: {1})",
|
||||
"settings.update.status_up_to_date_format": "You are up to date ({0}).",
|
||||
"settings.update.status_failed": "The update failed.",
|
||||
"settings.update.phase_idle": "Ready",
|
||||
"settings.update.phase_checking": "Checking",
|
||||
"settings.update.phase_checked": "Checked",
|
||||
"settings.update.phase_downloading": "Downloading",
|
||||
"settings.update.phase_paused_download": "Paused (Download)",
|
||||
"settings.update.phase_downloaded": "Downloaded",
|
||||
"settings.update.phase_installing": "Installing",
|
||||
"settings.update.phase_paused_install": "Paused (Install)",
|
||||
"settings.update.phase_installed": "Installed",
|
||||
"settings.update.phase_verifying": "Verifying",
|
||||
"settings.update.phase_completed": "Completed",
|
||||
"settings.update.phase_failed": "Failed",
|
||||
"settings.update.phase_recovering": "Recovering",
|
||||
"settings.update.phase_rolling_back": "Rolling Back",
|
||||
"settings.update.phase_rolled_back": "Rolled Back",
|
||||
"settings.update.badge_available": "Update available",
|
||||
"settings.update.badge_paused": "Paused",
|
||||
"settings.update.paused_hint": "Paused. Resume to continue from the current state.",
|
||||
"settings.update.check_button_short": "Check",
|
||||
"settings.update.download_button_short": "Download",
|
||||
"settings.update.install_button_short": "Install",
|
||||
"settings.update.pause_button_short": "Pause",
|
||||
"settings.update.resume_button_short": "Resume",
|
||||
"settings.update.rollback_button_short": "Rollback",
|
||||
"settings.update.cancel_button_short": "Cancel",
|
||||
"settings.update.progress_download_detail_format": "{0} ({1}%)",
|
||||
"settings.update.status_resumed": "Resume complete.",
|
||||
"settings.update.status_resume_failed": "Resume failed.",
|
||||
"settings.update.status_resume_state_invalid": "The resume state is invalid. Cancel and redownload, then try again.",
|
||||
"settings.update.status_recovering": "Recovering installation...",
|
||||
"settings.update.status_installing": "Installing update...",
|
||||
"settings.update.status_rolling_back": "Rolling back...",
|
||||
"settings.update.status_canceled": "Update canceled.",
|
||||
"settings.update.download_progress_idle": "Download progress: -",
|
||||
"settings.update.download_progress_format": "Download progress: {0:F0}%",
|
||||
"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.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_downloading_delta": "Downloading incremental update...",
|
||||
"settings.update.status_delta_applying": "Applying incremental update. The app will close for update.",
|
||||
"settings.update.status_delta_launch_failed": "Failed to launch updater for incremental update.",
|
||||
"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_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",
|
||||
"settings.update.status_launch_failed_format": "Failed to start installer: {0}",
|
||||
"settings.update.type_delta": "Incremental Update",
|
||||
"settings.update.type_full": "Full Installer",
|
||||
"settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.",
|
||||
"settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.",
|
||||
"settings.about.app_info_header": "Application Information",
|
||||
"settings.update.description": "Check releases, choose the update channel and download source, and control how updates are installed.",
|
||||
"settings.update.status_card_title": "Update Status",
|
||||
"settings.update.status_card_description": "Check for updates, review release details, and continue with download or installation when a new version is available.",
|
||||
"settings.update.release_facts_title": "Release Facts",
|
||||
"settings.update.release_facts_description": "Keep the current version, published release, and update type visible without collapsing the layout while states change.",
|
||||
"settings.update.progress_title": "Progress",
|
||||
"settings.update.progress_description": "Watch download, installation, verification, and recovery progress here.",
|
||||
"settings.update.actions_title": "Actions",
|
||||
"settings.update.actions_description": "The buttons below stay in place while the update phase changes, so the page does not jump around.",
|
||||
"settings.update.preferences_title": "Update Preferences",
|
||||
"settings.update.preferences_description": "Choose the release channel, installer download source, installation behavior, and download parallelism.",
|
||||
"settings.update.last_checked_label": "Last Checked",
|
||||
"settings.update.last_checked_none": "Not checked yet.",
|
||||
"settings.update.last_checked_format": "Last checked: {0}",
|
||||
"settings.update.source_label": "Download Source",
|
||||
"settings.update.source_github": "GitHub",
|
||||
"settings.update.source_ghproxy": "gh-proxy",
|
||||
"settings.update.source_github_desc": "Download release assets directly from GitHub.",
|
||||
"settings.update.source_ghproxy_desc": "Use the gh-proxy mirror when downloading GitHub release assets.",
|
||||
"settings.update.mode_label": "Update Mode",
|
||||
"settings.update.mode_manual": "Manual Update",
|
||||
"settings.update.mode_download_then_confirm": "Silent Download",
|
||||
"settings.update.mode_silent_on_exit": "Silent Install",
|
||||
"settings.update.mode_manual_desc": "Only check for updates. You decide when downloads and installation happen.",
|
||||
"settings.update.mode_download_then_confirm_desc": "Download updates in the background and ask for confirmation before installing them.",
|
||||
"settings.update.mode_silent_on_exit_desc": "Download updates in the background and install them the next time you exit the app.",
|
||||
"settings.update.channel_stable_desc": "Stable builds prioritize reliability and are recommended for most users.",
|
||||
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
|
||||
"settings.update.download_threads_label": "Download Threads",
|
||||
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.",
|
||||
"settings.update.type_label": "Update Type",
|
||||
"settings.update.status_idle": "No update check has been performed yet.",
|
||||
"settings.update.status_preferences_saved": "Update preferences saved.",
|
||||
"settings.update.status_check_failed": "Failed to check for updates.",
|
||||
"settings.update.status_available_summary_format": "Update available: {0} (current: {1})",
|
||||
"settings.update.status_up_to_date_format": "You are up to date ({0}).",
|
||||
"settings.update.status_failed": "The update failed.",
|
||||
"settings.update.phase_idle": "Ready",
|
||||
"settings.update.phase_checking": "Checking",
|
||||
"settings.update.phase_checked": "Checked",
|
||||
"settings.update.phase_downloading": "Downloading",
|
||||
"settings.update.phase_paused_download": "Paused (Download)",
|
||||
"settings.update.phase_downloaded": "Downloaded",
|
||||
"settings.update.phase_installing": "Installing",
|
||||
"settings.update.phase_paused_install": "Paused (Install)",
|
||||
"settings.update.phase_installed": "Installed",
|
||||
"settings.update.phase_verifying": "Verifying",
|
||||
"settings.update.phase_completed": "Completed",
|
||||
"settings.update.phase_failed": "Failed",
|
||||
"settings.update.phase_recovering": "Recovering",
|
||||
"settings.update.phase_rolling_back": "Rolling Back",
|
||||
"settings.update.phase_rolled_back": "Rolled Back",
|
||||
"settings.update.badge_available": "Update available",
|
||||
"settings.update.badge_paused": "Paused",
|
||||
"settings.update.paused_hint": "Paused. Resume to continue from the current state.",
|
||||
"settings.update.check_button_short": "Check",
|
||||
"settings.update.download_button_short": "Download",
|
||||
"settings.update.install_button_short": "Install",
|
||||
"settings.update.pause_button_short": "Pause",
|
||||
"settings.update.resume_button_short": "Resume",
|
||||
"settings.update.rollback_button_short": "Rollback",
|
||||
"settings.update.cancel_button_short": "Cancel",
|
||||
"settings.update.progress_download_detail_format": "{0} ({1}%)",
|
||||
"settings.update.status_resumed": "Resume complete.",
|
||||
"settings.update.status_resume_failed": "Resume failed.",
|
||||
"settings.update.status_resume_state_invalid": "The resume state is invalid. Cancel and redownload, then try again.",
|
||||
"settings.update.status_recovering": "Recovering installation...",
|
||||
"settings.update.status_installing": "Installing update...",
|
||||
"settings.update.status_rolling_back": "Rolling back...",
|
||||
"settings.update.status_canceled": "Update canceled.",
|
||||
|
||||
"settings.about.update_header": "Updates",
|
||||
"settings.about.version_label": "Version",
|
||||
"settings.about.codename_label": "Codename",
|
||||
|
||||
@@ -287,6 +287,12 @@
|
||||
"settings.general.preview_time_label": "時刻",
|
||||
"settings.general.preview_date_label": "日付",
|
||||
"settings.general.render_mode_restart_message": "レンダリングモードの変更にはアプリの再起動が必要です。",
|
||||
"settings.general.multi_instance_behavior_header": "アプリを再度開くときの動作",
|
||||
"settings.general.multi_instance_behavior_desc": "LanMountain Desktop が既に実行中の場合に、Launcher が再起動操作をどう処理するかを選択します。",
|
||||
"settings.general.multi_instance_behavior.restart": "アプリを再起動",
|
||||
"settings.general.multi_instance_behavior.open_silently": "通知せずにデスクトップを開く",
|
||||
"settings.general.multi_instance_behavior.prompt_only": "プロンプトのみ表示",
|
||||
"settings.general.multi_instance_behavior.notify_and_open": "通知してデスクトップを開く",
|
||||
"settings.data.title": "データ",
|
||||
"settings.data.description": "ローカルに保存されたアプリデータとキャッシュを確認・管理します。",
|
||||
"settings.appearance.title": "外観",
|
||||
@@ -473,7 +479,77 @@
|
||||
"settings.update.install_now_button": "今すぐインストール",
|
||||
"settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。",
|
||||
"settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。",
|
||||
"settings.about.app_info_header": "アプリケーション情報",
|
||||
"settings.update.description": "リリースを確認し、アップデートチャンネルとダウンロードソースを選択し、アップデートのインストール方法を制御します。",
|
||||
"settings.update.status_card_title": "アップデートステータス",
|
||||
"settings.update.status_card_description": "アップデートを確認し、リリースの詳細を確認し、新しいバージョンが利用可能な場合はダウンロードまたはインストールを続行します。",
|
||||
"settings.update.release_facts_title": "リリース情報",
|
||||
"settings.update.release_facts_description": "状態が変わっても、現在のバージョン・公開済みリリース・更新タイプを折りたたまずに表示します。",
|
||||
"settings.update.progress_title": "進行状況",
|
||||
"settings.update.progress_description": "ダウンロード、インストール、検証、復旧の進行状況をここで確認します。",
|
||||
"settings.update.actions_title": "操作",
|
||||
"settings.update.actions_description": "下のボタンは更新フェーズが変わっても位置を固定し、ページが大きく動かないようにします。",
|
||||
"settings.update.preferences_title": "アップデート設定",
|
||||
"settings.update.preferences_description": "リリースチャンネル、インストーラーのダウンロード元、インストール方法、ダウンロードの並列度を選択します。",
|
||||
"settings.update.last_checked_label": "最終確認日時",
|
||||
"settings.update.last_checked_none": "まだ確認していません。",
|
||||
"settings.update.last_checked_format": "最終確認: {0}",
|
||||
"settings.update.source_label": "ダウンロードソース",
|
||||
"settings.update.source_github": "GitHub",
|
||||
"settings.update.source_ghproxy": "gh-proxy",
|
||||
"settings.update.source_github_desc": "GitHubからリリースアセットを直接ダウンロードします。",
|
||||
"settings.update.source_ghproxy_desc": "GitHubリリースアセットをダウンロードする際にgh-proxyミラーを使用します。",
|
||||
"settings.update.mode_label": "アップデートモード",
|
||||
"settings.update.mode_manual": "手動アップデート",
|
||||
"settings.update.mode_download_then_confirm": "サイレントダウンロード",
|
||||
"settings.update.mode_silent_on_exit": "サイレントインストール",
|
||||
"settings.update.mode_manual_desc": "アップデートの確認のみ。ダウンロードとインストールのタイミングを決定します。",
|
||||
"settings.update.mode_download_then_confirm_desc": "バックグラウンドでアップデートをダウンロードし、インストール前に確認を求めます。",
|
||||
"settings.update.mode_silent_on_exit_desc": "バックグラウンドでアップデートをダウンロードし、アプリの終了時にインストールします。",
|
||||
"settings.update.channel_stable_desc": "安定ビルドは信頼性を重視し、ほとんどのユーザーにおすすめです。",
|
||||
"settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。",
|
||||
"settings.update.download_threads_label": "ダウンロードスレッド",
|
||||
"settings.update.download_threads_desc": "アプリケーションのアップデートパッケージの並列ダウンロードスレッド数を設定します。",
|
||||
"settings.update.type_label": "更新タイプ",
|
||||
"settings.update.status_idle": "アップデートの確認はまだ実行されていません。",
|
||||
"settings.update.status_preferences_saved": "アップデート設定が保存されました。",
|
||||
"settings.update.status_check_failed": "アップデートの確認に失敗しました。",
|
||||
"settings.update.status_available_summary_format": "アップデートあり: {0}(現在: {1})",
|
||||
"settings.update.status_up_to_date_format": "最新版です({0})。",
|
||||
"settings.update.status_failed": "アップデートに失敗しました。",
|
||||
"settings.update.phase_idle": "準備完了",
|
||||
"settings.update.phase_checking": "確認中",
|
||||
"settings.update.phase_checked": "確認済み",
|
||||
"settings.update.phase_downloading": "ダウンロード中",
|
||||
"settings.update.phase_paused_download": "一時停止(ダウンロード)",
|
||||
"settings.update.phase_downloaded": "ダウンロード済み",
|
||||
"settings.update.phase_installing": "インストール中",
|
||||
"settings.update.phase_paused_install": "一時停止(インストール)",
|
||||
"settings.update.phase_installed": "インストール済み",
|
||||
"settings.update.phase_verifying": "検証中",
|
||||
"settings.update.phase_completed": "完了",
|
||||
"settings.update.phase_failed": "失敗",
|
||||
"settings.update.phase_recovering": "復旧中",
|
||||
"settings.update.phase_rolling_back": "ロールバック中",
|
||||
"settings.update.phase_rolled_back": "ロールバック済み",
|
||||
"settings.update.badge_available": "アップデートあり",
|
||||
"settings.update.badge_paused": "一時停止中",
|
||||
"settings.update.paused_hint": "一時停止中です。再開すると現在の状態から続行します。",
|
||||
"settings.update.check_button_short": "確認",
|
||||
"settings.update.download_button_short": "ダウンロード",
|
||||
"settings.update.install_button_short": "インストール",
|
||||
"settings.update.pause_button_short": "一時停止",
|
||||
"settings.update.resume_button_short": "再開",
|
||||
"settings.update.rollback_button_short": "ロールバック",
|
||||
"settings.update.cancel_button_short": "キャンセル",
|
||||
"settings.update.progress_download_detail_format": "{0} ({1}%)",
|
||||
"settings.update.status_resumed": "再開が完了しました。",
|
||||
"settings.update.status_resume_failed": "再開に失敗しました。",
|
||||
"settings.update.status_resume_state_invalid": "再開状態が無効です。キャンセルして再ダウンロードしてから再試行してください。",
|
||||
"settings.update.status_recovering": "インストールを復旧中…",
|
||||
"settings.update.status_installing": "アップデートをインストール中…",
|
||||
"settings.update.status_rolling_back": "ロールバック中…",
|
||||
"settings.update.status_canceled": "アップデートをキャンセルしました。",
|
||||
|
||||
"settings.about.update_header": "アップデート",
|
||||
"settings.about.version_label": "バージョン",
|
||||
"settings.about.codename_label": "コードネーム",
|
||||
|
||||
@@ -335,6 +335,12 @@
|
||||
"settings.general.preview_time_label": "시간",
|
||||
"settings.general.preview_date_label": "날짜",
|
||||
"settings.general.render_mode_restart_message": "렌더링 모드 변경은 앱 재시작이 필요합니다.",
|
||||
"settings.general.multi_instance_behavior_header": "앱을 다시 열 때 동작",
|
||||
"settings.general.multi_instance_behavior_desc": "LanMountain Desktop이 이미 실행 중일 때 Launcher가 반복 실행을 처리하는 방식을 선택합니다.",
|
||||
"settings.general.multi_instance_behavior.restart": "앱 다시 시작",
|
||||
"settings.general.multi_instance_behavior.open_silently": "알림 없이 데스크톱 열기",
|
||||
"settings.general.multi_instance_behavior.prompt_only": "프롬프트만 표시",
|
||||
"settings.general.multi_instance_behavior.notify_and_open": "알림 후 데스크톱 열기",
|
||||
"settings.data.title": "데이터",
|
||||
"settings.data.description": "로컬에 저장된 앱 데이터와 캐시를 확인하고 관리합니다.",
|
||||
"settings.appearance.title": "외관",
|
||||
@@ -521,7 +527,77 @@
|
||||
"settings.update.install_now_button": "지금 설치",
|
||||
"settings.update.status_downloaded_confirm": "업데이트가 다운로드되었습니다. 확인 후 설치 시기를 선택하세요.",
|
||||
"settings.update.status_downloaded_exit": "업데이트가 다운로드되었습니다. 앱 종료 시 설치됩니다.",
|
||||
"settings.about.app_info_header": "앱 정보",
|
||||
"settings.update.description": "업데이트 확인, 릴리스 채널 및 다운로드 소스 선택, 업데이트 설치 방법 제어.",
|
||||
"settings.update.status_card_title": "업데이트 상태",
|
||||
"settings.update.status_card_description": "새 버전 확인, 릴리스 정보 보기, 업데이트 시 다운로드 또는 설치 계속.",
|
||||
"settings.update.release_facts_title": "릴리스 정보",
|
||||
"settings.update.release_facts_description": "상태가 바뀌어도 현재 버전, 게시된 릴리스, 업데이트 유형이 접히지 않게 유지합니다.",
|
||||
"settings.update.progress_title": "진행률",
|
||||
"settings.update.progress_description": "여기서 다운로드, 설치, 검증, 복구 진행률을 확인하세요.",
|
||||
"settings.update.actions_title": "작업",
|
||||
"settings.update.actions_description": "아래 버튼은 업데이트 단계가 바뀌어도 고정되어 페이지가 크게 흔들리지 않습니다.",
|
||||
"settings.update.preferences_title": "업데이트 설정",
|
||||
"settings.update.preferences_description": "릴리스 채널, 설치 패키지 다운로드 소스, 설치 방식, 다운로드 병렬 처리 수를 선택합니다.",
|
||||
"settings.update.last_checked_label": "마지막 확인",
|
||||
"settings.update.last_checked_none": "아직 확인하지 않았습니다.",
|
||||
"settings.update.last_checked_format": "마지막 확인: {0}",
|
||||
"settings.update.source_label": "다운로드 소스",
|
||||
"settings.update.source_github": "GitHub",
|
||||
"settings.update.source_ghproxy": "gh-proxy",
|
||||
"settings.update.source_github_desc": "GitHub에서 직접 릴리스 설치 패키지를 다운로드합니다.",
|
||||
"settings.update.source_ghproxy_desc": "GitHub 릴리스 설치 패키지를 다운로드할 때 gh-proxy 미러를 사용합니다.",
|
||||
"settings.update.mode_label": "업데이트 모드",
|
||||
"settings.update.mode_manual": "수동 업데이트",
|
||||
"settings.update.mode_download_then_confirm": "자동 다운로드",
|
||||
"settings.update.mode_silent_on_exit": "자동 설치",
|
||||
"settings.update.mode_manual_desc": "업데이트만 확인합니다. 다운로드와 설치 시기는 사용자가 결정합니다.",
|
||||
"settings.update.mode_download_then_confirm_desc": "백그라운드에서 업데이트를 다운로드하고 완료 후 설치 여부를 확인합니다.",
|
||||
"settings.update.mode_silent_on_exit_desc": "백그라운드에서 업데이트를 다운로드하고 다음 앱 종료 시 자동으로 설치합니다.",
|
||||
"settings.update.channel_stable_desc": "정식 버전은 안정성을 우선하며 대부분의 사용자에게 적합합니다.",
|
||||
"settings.update.channel_preview_desc": "미리보기 버전은 더 빠른 새 기능을 포함할 수 있지만 안정성이 낮을 수 있습니다.",
|
||||
"settings.update.download_threads_label": "다운로드 스레드 수",
|
||||
"settings.update.download_threads_desc": "앱 업데이트 설치 패키지에 사용할 병렬 다운로드 스레드 수를 설정합니다.",
|
||||
"settings.update.type_label": "업데이트 유형",
|
||||
"settings.update.status_idle": "아직 업데이트 확인이 수행되지 않았습니다.",
|
||||
"settings.update.status_preferences_saved": "업데이트 설정이 저장되었습니다.",
|
||||
"settings.update.status_check_failed": "업데이트 확인 실패.",
|
||||
"settings.update.status_available_summary_format": "업데이트 발견: {0} (현재: {1}).",
|
||||
"settings.update.status_up_to_date_format": "현재 최신 버전입니다 ({0}).",
|
||||
"settings.update.status_failed": "업데이트에 실패했습니다.",
|
||||
"settings.update.phase_idle": "준비됨",
|
||||
"settings.update.phase_checking": "확인 중",
|
||||
"settings.update.phase_checked": "확인됨",
|
||||
"settings.update.phase_downloading": "다운로드 중",
|
||||
"settings.update.phase_paused_download": "일시 중지(다운로드)",
|
||||
"settings.update.phase_downloaded": "다운로드 완료",
|
||||
"settings.update.phase_installing": "설치 중",
|
||||
"settings.update.phase_paused_install": "일시 중지(설치)",
|
||||
"settings.update.phase_installed": "설치됨",
|
||||
"settings.update.phase_verifying": "검증 중",
|
||||
"settings.update.phase_completed": "완료됨",
|
||||
"settings.update.phase_failed": "실패",
|
||||
"settings.update.phase_recovering": "복구 중",
|
||||
"settings.update.phase_rolling_back": "되돌리는 중",
|
||||
"settings.update.phase_rolled_back": "되돌림 완료",
|
||||
"settings.update.badge_available": "업데이트 उपलब्ध",
|
||||
"settings.update.badge_paused": "일시 중지됨",
|
||||
"settings.update.paused_hint": "일시 중지되었습니다. 다시 시작하면 현재 상태에서 계속합니다.",
|
||||
"settings.update.check_button_short": "확인",
|
||||
"settings.update.download_button_short": "다운로드",
|
||||
"settings.update.install_button_short": "설치",
|
||||
"settings.update.pause_button_short": "일시 중지",
|
||||
"settings.update.resume_button_short": "재개",
|
||||
"settings.update.rollback_button_short": "되돌리기",
|
||||
"settings.update.cancel_button_short": "취소",
|
||||
"settings.update.progress_download_detail_format": "{0} ({1}%)",
|
||||
"settings.update.status_resumed": "재개가 완료되었습니다.",
|
||||
"settings.update.status_resume_failed": "재개 실패.",
|
||||
"settings.update.status_resume_state_invalid": "재개 상태가 올바르지 않습니다. 취소 후 다시 다운로드하여 시도하세요.",
|
||||
"settings.update.status_recovering": "설치 복구 중…",
|
||||
"settings.update.status_installing": "업데이트 설치 중…",
|
||||
"settings.update.status_rolling_back": "되돌리는 중…",
|
||||
"settings.update.status_canceled": "업데이트가 취소되었습니다.",
|
||||
|
||||
"settings.about.update_header": "업데이트",
|
||||
"settings.about.version_label": "버전",
|
||||
"settings.about.codename_label": "버전 코드명",
|
||||
|
||||
@@ -352,6 +352,12 @@
|
||||
"settings.general.slide_transition_desc": "在受支持的 Windows 版本上使用滑入启动过渡。启用后会关闭淡入淡出过渡。",
|
||||
"settings.general.show_main_window_taskbar_header": "在任务栏显示主桌面窗口",
|
||||
"settings.general.show_main_window_taskbar_desc": "让主桌面宿主窗口保持在任务栏中可见。独立设置窗口始终拥有自己的任务栏入口。",
|
||||
"settings.general.multi_instance_behavior_header": "多次开启时的行为",
|
||||
"settings.general.multi_instance_behavior_desc": "选择应用已经运行时,启动器如何处理再次打开。",
|
||||
"settings.general.multi_instance_behavior.restart": "重新启动应用",
|
||||
"settings.general.multi_instance_behavior.open_silently": "不弹窗直接打开桌面",
|
||||
"settings.general.multi_instance_behavior.prompt_only": "弹窗但不打开桌面",
|
||||
"settings.general.multi_instance_behavior.notify_and_open": "弹出通知并打开桌面",
|
||||
"settings.data.title": "数据",
|
||||
"settings.data.description": "查看与管理本机存储中的应用数据与缓存。",
|
||||
"settings.appearance.title": "外观",
|
||||
@@ -587,7 +593,77 @@
|
||||
"settings.update.install_now_button": "立即安装",
|
||||
"settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。",
|
||||
"settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。",
|
||||
"settings.about.app_info_header": "应用信息",
|
||||
"settings.update.description": "检查更新、选择发布通道与安装方式,并控制更新行为。",
|
||||
"settings.update.status_card_title": "更新状态",
|
||||
"settings.update.status_card_description": "检查新版本、查看发布信息,并在有更新时继续下载或安装。",
|
||||
"settings.update.release_facts_title": "发布信息",
|
||||
"settings.update.release_facts_description": "在状态变化时保持当前版本、已发布版本和更新类型可见,不让布局折叠。",
|
||||
"settings.update.progress_title": "进度",
|
||||
"settings.update.progress_description": "在这里查看下载、安装、校验和恢复进度。",
|
||||
"settings.update.actions_title": "操作",
|
||||
"settings.update.actions_description": "下面的按钮会在更新阶段变化时保持固定,页面不会来回跳动。",
|
||||
"settings.update.preferences_title": "更新偏好",
|
||||
"settings.update.preferences_description": "选择发布通道、安装包下载源、安装行为和下载并行度。",
|
||||
"settings.update.last_checked_label": "上次检查",
|
||||
"settings.update.last_checked_none": "尚未检查。",
|
||||
"settings.update.last_checked_format": "上次检查:{0}",
|
||||
"settings.update.source_label": "下载源",
|
||||
"settings.update.source_github": "GitHub",
|
||||
"settings.update.source_ghproxy": "gh-proxy",
|
||||
"settings.update.source_github_desc": "直接从 GitHub 下载发布安装包。",
|
||||
"settings.update.source_ghproxy_desc": "下载 GitHub 发布安装包时使用 gh-proxy 镜像。",
|
||||
"settings.update.mode_label": "更新模式",
|
||||
"settings.update.mode_manual": "手动更新",
|
||||
"settings.update.mode_download_then_confirm": "静默下载",
|
||||
"settings.update.mode_silent_on_exit": "静默安装",
|
||||
"settings.update.mode_manual_desc": "仅检查更新,何时下载和安装都由你决定。",
|
||||
"settings.update.mode_download_then_confirm_desc": "后台下载更新,下载完成后由你确认是否安装。",
|
||||
"settings.update.mode_silent_on_exit_desc": "后台下载更新,并在你下次退出应用时静默安装。",
|
||||
"settings.update.channel_stable_desc": "正式版以稳定性优先,适合大多数用户。",
|
||||
"settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。",
|
||||
"settings.update.download_threads_label": "下载线程数",
|
||||
"settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。",
|
||||
"settings.update.type_label": "更新类型",
|
||||
"settings.update.status_idle": "尚未执行更新检查。",
|
||||
"settings.update.status_preferences_saved": "更新偏好已保存。",
|
||||
"settings.update.status_check_failed": "检查更新失败。",
|
||||
"settings.update.status_available_summary_format": "发现更新:{0}(当前:{1})",
|
||||
"settings.update.status_up_to_date_format": "当前已是最新版本({0})。",
|
||||
"settings.update.status_failed": "更新失败。",
|
||||
"settings.update.phase_idle": "就绪",
|
||||
"settings.update.phase_checking": "检查中",
|
||||
"settings.update.phase_checked": "已检查",
|
||||
"settings.update.phase_downloading": "下载中",
|
||||
"settings.update.phase_paused_download": "已暂停(下载)",
|
||||
"settings.update.phase_downloaded": "已下载",
|
||||
"settings.update.phase_installing": "安装中",
|
||||
"settings.update.phase_paused_install": "已暂停(安装)",
|
||||
"settings.update.phase_installed": "已安装",
|
||||
"settings.update.phase_verifying": "校验中",
|
||||
"settings.update.phase_completed": "已完成",
|
||||
"settings.update.phase_failed": "失败",
|
||||
"settings.update.phase_recovering": "恢复中",
|
||||
"settings.update.phase_rolling_back": "回滚中",
|
||||
"settings.update.phase_rolled_back": "已回滚",
|
||||
"settings.update.badge_available": "发现更新",
|
||||
"settings.update.badge_paused": "已暂停",
|
||||
"settings.update.paused_hint": "已暂停,继续即可从当前状态恢复。",
|
||||
"settings.update.check_button_short": "检查",
|
||||
"settings.update.download_button_short": "下载",
|
||||
"settings.update.install_button_short": "安装",
|
||||
"settings.update.pause_button_short": "暂停",
|
||||
"settings.update.resume_button_short": "继续",
|
||||
"settings.update.rollback_button_short": "回滚",
|
||||
"settings.update.cancel_button_short": "取消",
|
||||
"settings.update.progress_download_detail_format": "{0}({1}%)",
|
||||
"settings.update.status_resumed": "继续完成。",
|
||||
"settings.update.status_resume_failed": "继续失败。",
|
||||
"settings.update.status_resume_state_invalid": "恢复状态无效。请取消后重新下载再试。",
|
||||
"settings.update.status_recovering": "正在恢复安装…",
|
||||
"settings.update.status_installing": "正在安装更新…",
|
||||
"settings.update.status_rolling_back": "正在回滚…",
|
||||
"settings.update.status_canceled": "更新已取消。",
|
||||
|
||||
"settings.about.update_header": "更新",
|
||||
"settings.about.version_label": "版本",
|
||||
"settings.about.codename_label": "版本代号",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
@@ -166,6 +168,10 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool ShowInTaskbar { get; set; } = false;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<MultiInstanceLaunchBehavior>))]
|
||||
public MultiInstanceLaunchBehavior MultiInstanceLaunchBehavior { get; set; } =
|
||||
MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
|
||||
|
||||
public bool EnableFusedDesktop { get; set; } = false;
|
||||
|
||||
public List<string> DisabledPluginIds { get; set; } = [];
|
||||
@@ -222,33 +228,38 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
#endregion
|
||||
|
||||
#region Notification Box Settings (消息盒子全局设置)
|
||||
#region Notification Box Settings
|
||||
|
||||
/// <summary>
|
||||
/// 启用消息盒子功能(Windows通知监听)
|
||||
/// Enables the system notification inbox component.
|
||||
/// </summary>
|
||||
public bool NotificationBoxEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 隐私模式:开启后只显示"您有新的通知",不显示具体内容
|
||||
/// Hides notification details when unread messages are present.
|
||||
/// </summary>
|
||||
public bool NotificationBoxPrivacyMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 被屏蔽的应用列表(不接收这些应用的通知)
|
||||
/// App IDs that should not be collected by the notification box.
|
||||
/// </summary>
|
||||
public List<string> NotificationBoxBlockedApps { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 历史记录保留天数
|
||||
/// Number of days to retain notification box history.
|
||||
/// </summary>
|
||||
public int NotificationBoxHistoryRetentionDays { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// 最大存储通知数量(防止内存无限增长)
|
||||
/// Maximum number of notifications kept in memory.
|
||||
/// </summary>
|
||||
public int NotificationBoxMaxStoredCount { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Linux capture mode: ProxyDaemon or PassiveMonitor.
|
||||
/// </summary>
|
||||
public string NotificationBoxLinuxCaptureMode { get; set; } = "ProxyDaemon";
|
||||
|
||||
#endregion
|
||||
|
||||
public AppSettingsSnapshot Clone()
|
||||
|
||||
@@ -84,40 +84,40 @@ public sealed class ComponentSettingsSnapshot
|
||||
|
||||
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
|
||||
|
||||
#region Notification Box Component Settings (消息盒子组件设置)
|
||||
#region Notification Box Component Settings
|
||||
|
||||
/// <summary>
|
||||
/// 组件内最大显示通知数量
|
||||
/// Maximum number of notifications displayed by this component.
|
||||
/// </summary>
|
||||
public int NotificationBoxMaxDisplayCount { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// 排序方式:TimeDesc(时间倒序), TimeAsc(时间正序), AppGroup(按应用分组)
|
||||
/// Sort order: TimeDesc, TimeAsc, AppGroup.
|
||||
/// </summary>
|
||||
public string NotificationBoxSortOrder { get; set; } = "TimeDesc";
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示应用图标
|
||||
/// Whether to show app icons.
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowAppIcon { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示时间戳
|
||||
/// Whether to show timestamps.
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowTimestamp { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 时间格式:Relative(相对时间,如"5分钟前"), Absolute(绝对时间)
|
||||
/// Time format: Relative or Absolute.
|
||||
/// </summary>
|
||||
public string NotificationBoxTimeFormat { get; set; } = "Relative";
|
||||
|
||||
/// <summary>
|
||||
/// 是否按应用分组显示
|
||||
/// Whether to group notifications by app.
|
||||
/// </summary>
|
||||
public bool NotificationBoxGroupByApp { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示清除按钮
|
||||
/// Whether to show the clear button.
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowClearButton { get; set; } = true;
|
||||
|
||||
|
||||
@@ -3,52 +3,43 @@ using System;
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 通知项数据模型
|
||||
/// Notification captured by the desktop notification box.
|
||||
/// </summary>
|
||||
public sealed class NotificationItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 唯一标识
|
||||
/// </summary>
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
|
||||
/// <summary>
|
||||
/// 应用ID(如 WeChat, Outlook 等)
|
||||
/// </summary>
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 应用名称
|
||||
/// </summary>
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 应用图标路径或Base64
|
||||
/// </summary>
|
||||
public string? AppIconPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知标题
|
||||
/// </summary>
|
||||
public byte[]? AppIconBytes { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 通知内容
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收时间
|
||||
/// </summary>
|
||||
public DateTime ReceivedTime { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 是否已读
|
||||
/// </summary>
|
||||
public bool IsRead { get; set; } = false;
|
||||
public DateTimeOffset ReceivedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public bool IsRead { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始通知的额外数据(用于点击跳转)
|
||||
/// </summary>
|
||||
public string? LaunchArgs { get; set; }
|
||||
|
||||
public string Platform { get; set; } = "Unknown";
|
||||
|
||||
public string? SourceNotificationId { get; set; }
|
||||
|
||||
public string? DesktopEntryId { get; set; }
|
||||
|
||||
public string? Aumid { get; set; }
|
||||
|
||||
public string? LaunchTarget { get; set; }
|
||||
|
||||
public bool CanActivate { get; set; }
|
||||
|
||||
public string CaptureMode { get; set; } = "Unknown";
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Launcher;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
@@ -24,43 +20,6 @@ public sealed class Program
|
||||
AppDataPathProvider.Initialize(args);
|
||||
DevPluginOptions.Parse(args);
|
||||
RegisterGlobalExceptionLogging();
|
||||
var restartParentProcessId = LauncherRuntimeMetadata.GetRestartParentProcessId(args);
|
||||
|
||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||
if (!singleInstance.IsPrimaryInstance)
|
||||
{
|
||||
if (restartParentProcessId is not null)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
|
||||
ReportLauncherStageBeforeExit(StartupStage.ActivationFailed, "Restart relaunch could not acquire the single-instance lock.");
|
||||
Environment.ExitCode = HostExitCodes.RestartLockNotAcquired;
|
||||
return;
|
||||
}
|
||||
|
||||
var activationAcknowledged = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
|
||||
if (activationAcknowledged)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"Startup",
|
||||
$"Secondary launch forwarded to primary instance successfully. Acked={activationAcknowledged}; Pid={Environment.ProcessId}.");
|
||||
ReportLauncherStageBeforeExit(StartupStage.ActivationRedirected, "Secondary launch forwarded to the primary instance.");
|
||||
Environment.ExitCode = HostExitCodes.SecondaryActivationSucceeded;
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Secondary launch failed to activate the primary instance. Acked={activationAcknowledged}; Reason='{failureReason ?? "unknown"}'; Pid={Environment.ProcessId}.");
|
||||
ReportLauncherStageBeforeExit(
|
||||
StartupStage.ActivationFailed,
|
||||
$"Secondary launch failed to activate the primary instance. Reason='{failureReason ?? "unknown"}'.");
|
||||
Environment.ExitCode = HostExitCodes.SecondaryActivationFailed;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DesktopBootstrap.InitializeStartupServices(
|
||||
InitializeTelemetryIdentity,
|
||||
@@ -76,17 +35,6 @@ public sealed class Program
|
||||
var renderMode = LoadConfiguredRenderMode();
|
||||
StartupRenderMode = renderMode;
|
||||
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
||||
App.CurrentSingleInstanceService = singleInstance;
|
||||
singleInstance.StartActivationListener(() =>
|
||||
{
|
||||
if (Avalonia.Application.Current is App app)
|
||||
{
|
||||
app.ActivateMainWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info("SingleInstance", "Activation acknowledged before Avalonia App was ready.");
|
||||
});
|
||||
LoadChromePatchState();
|
||||
InstallChromePatchersIfNeeded();
|
||||
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
||||
@@ -97,10 +45,6 @@ public sealed class Program
|
||||
AppLogger.Critical("Startup", "Application terminated during startup.", ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
App.CurrentSingleInstanceService = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
@@ -149,41 +93,6 @@ public sealed class Program
|
||||
});
|
||||
}
|
||||
|
||||
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
|
||||
{
|
||||
var singleInstance = SingleInstanceService.CreateDefault();
|
||||
if (singleInstance.IsPrimaryInstance || restartParentProcessId is null)
|
||||
{
|
||||
return singleInstance;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"Startup",
|
||||
$"Restart relaunch detected. Waiting for previous instance pid={restartParentProcessId.Value} to exit before re-acquiring the single-instance lock.");
|
||||
singleInstance.Dispose();
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(12);
|
||||
WaitForRestartParentExit(restartParentProcessId.Value, deadline);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var retryInstance = SingleInstanceService.CreateDefault();
|
||||
if (retryInstance.IsPrimaryInstance)
|
||||
{
|
||||
AppLogger.Info("Startup", "Restart relaunch acquired the single-instance lock.");
|
||||
return retryInstance;
|
||||
}
|
||||
|
||||
retryInstance.Dispose();
|
||||
Thread.Sleep(150);
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Restart relaunch timed out while waiting for the single-instance lock. pid={restartParentProcessId.Value}.");
|
||||
return SingleInstanceService.CreateDefault();
|
||||
}
|
||||
|
||||
private static string LoadConfiguredRenderMode()
|
||||
{
|
||||
try
|
||||
@@ -243,26 +152,6 @@ public sealed class Program
|
||||
}
|
||||
}
|
||||
|
||||
private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
var remaining = deadlineUtc - DateTime.UtcNow;
|
||||
if (remaining > TimeSpan.Zero)
|
||||
{
|
||||
process.WaitForExit((int)Math.Ceiling(remaining.TotalMilliseconds));
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", $"Failed while waiting for restart parent pid={processId} to exit.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterGlobalExceptionLogging()
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
|
||||
@@ -307,35 +196,6 @@ public sealed class Program
|
||||
};
|
||||
}
|
||||
|
||||
private static void ReportLauncherStageBeforeExit(StartupStage stage, string message)
|
||||
{
|
||||
if (!LauncherIpcClient.IsLaunchedByLauncher())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var launcherIpcClient = new LauncherIpcClient();
|
||||
var connected = launcherIpcClient.ConnectAsync().GetAwaiter().GetResult();
|
||||
if (!connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||
{
|
||||
Stage = stage,
|
||||
ProgressPercent = 100,
|
||||
Message = message
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report early launcher stage '{stage}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InitializeTelemetryIdentity()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher IPC 客户端,用于向 Launcher 报告启动进度。
|
||||
/// </summary>
|
||||
public class LauncherIpcClient : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions StartupProgressJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private const int LengthPrefixSize = 4;
|
||||
private const int ConnectTimeoutMs = 5000;
|
||||
private const int ConnectRetryCount = 3;
|
||||
private const int ConnectRetryBaseDelayMs = 300;
|
||||
|
||||
private NamedPipeClientStream? _pipeClient;
|
||||
private bool _isConnected;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
|
||||
|
||||
public async Task<bool> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
for (var attempt = 1; attempt <= ConnectRetryCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = new NamedPipeClientStream(
|
||||
".",
|
||||
LauncherIpcConstants.PipeName,
|
||||
PipeDirection.Out,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await client.ConnectAsync(ConnectTimeoutMs, cancellationToken);
|
||||
_pipeClient = client;
|
||||
_isConnected = true;
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_pipeClient?.Dispose();
|
||||
_pipeClient = null;
|
||||
|
||||
if (attempt < ConnectRetryCount)
|
||||
{
|
||||
var delay = ConnectRetryBaseDelayMs * attempt + Random.Shared.Next(0, 100);
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pipeClient?.Dispose();
|
||||
_pipeClient = null;
|
||||
|
||||
if (attempt < ConnectRetryCount)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Connect attempt {attempt} failed: {ex.Message}, retrying...");
|
||||
var delay = ConnectRetryBaseDelayMs * attempt + Random.Shared.Next(0, 100);
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to connect to Launcher IPC after {ConnectRetryCount} attempts: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task ReportProgressAsync(StartupProgressMessage message)
|
||||
{
|
||||
if (!_isConnected || _pipeClient?.IsConnected != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(message, StartupProgressJsonOptions);
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
var lengthPrefix = BitConverter.GetBytes(payload.Length);
|
||||
Debug.Assert(lengthPrefix.Length == LengthPrefixSize);
|
||||
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize + payload.Length);
|
||||
try
|
||||
{
|
||||
Buffer.BlockCopy(lengthPrefix, 0, buffer, 0, LengthPrefixSize);
|
||||
Buffer.BlockCopy(payload, 0, buffer, LengthPrefixSize, payload.Length);
|
||||
|
||||
await _pipeClient.WriteAsync(buffer.AsMemory(0, LengthPrefixSize + payload.Length)).ConfigureAwait(false);
|
||||
await _pipeClient.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
_isConnected = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
|
||||
_isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsLaunchedByLauncher()
|
||||
{
|
||||
return LauncherRuntimeMetadata.GetLauncherProcessId(Environment.GetCommandLineArgs()) is not null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_isConnected = false;
|
||||
_pipeClient?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,131 +1,214 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Linux平台通知监听器 - 通过DBus监听org.freedesktop.Notifications
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("linux")]
|
||||
internal sealed class LinuxNotificationListener : IDisposable
|
||||
internal sealed class LinuxNotificationListener : IPlatformNotificationListener
|
||||
{
|
||||
private readonly NotificationListenerService _parent;
|
||||
private CancellationTokenSource? _cts;
|
||||
private bool _isRunning;
|
||||
private static readonly Regex DbusStringRegex = new("^\\s*string\\s+\"(?<value>.*)\"\\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex DbusUIntRegex = new("^\\s*uint32\\s+(?<value>\\d+)\\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex DesktopEntryHintRegex = new("\"desktop-entry\"\\s+variant\\s+string\\s+\"(?<value>[^\"]+)\"", RegexOptions.Compiled);
|
||||
private static readonly Regex ImagePathHintRegex = new("\"image-path\"\\s+variant\\s+string\\s+\"(?<value>[^\"]+)\"", RegexOptions.Compiled);
|
||||
|
||||
public LinuxNotificationListener(NotificationListenerService parent)
|
||||
private readonly NotificationListenerService _parent;
|
||||
private readonly string _requestedMode;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Process? _monitorProcess;
|
||||
private Task? _monitorTask;
|
||||
private uint _nextSyntheticId = 1;
|
||||
|
||||
public LinuxNotificationListener(NotificationListenerService parent, string requestedMode)
|
||||
{
|
||||
_parent = parent;
|
||||
_requestedMode = string.IsNullOrWhiteSpace(requestedMode) ? "ProxyDaemon" : requestedMode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化并启动DBus监听
|
||||
/// </summary>
|
||||
public async Task<bool> InitializeAsync()
|
||||
public async Task<NotificationBoxStatus> InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
// 检查DBus环境变量
|
||||
var dbusSessionBus = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS");
|
||||
if (string.IsNullOrEmpty(dbusSessionBus))
|
||||
{
|
||||
Console.WriteLine("[NotificationBox] DBus Session Bus 环境变量未设置");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查通知守护进程是否运行
|
||||
// 通过检查常见进程名
|
||||
var hasNotificationDaemon = await CheckNotificationDaemonAsync();
|
||||
if (!hasNotificationDaemon)
|
||||
{
|
||||
Console.WriteLine("[NotificationBox] 未检测到通知守护进程,消息盒子功能可能不可用");
|
||||
// 仍然返回true,因为守护进程可能在之后启动
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_ = StartListeningAsync(_cts.Token);
|
||||
|
||||
Console.WriteLine("[NotificationBox] Linux通知监听已启动");
|
||||
return true;
|
||||
return new NotificationBoxStatus(NotificationBoxServiceState.Unsupported, "当前平台不是 Linux。", "Linux");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
var dbusSessionBus = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS");
|
||||
if (string.IsNullOrEmpty(dbusSessionBus))
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] Linux通知监听初始化失败: {ex.Message}");
|
||||
return false;
|
||||
return new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.Unsupported,
|
||||
"DBus Session Bus 环境变量未设置,无法监听 Linux 通知。",
|
||||
_requestedMode);
|
||||
}
|
||||
|
||||
var hasMonitorTool = CommandExists("dbus-monitor");
|
||||
if (!hasMonitorTool)
|
||||
{
|
||||
return new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.Unsupported,
|
||||
"未找到 dbus-monitor,无法启用 Linux 通知旁路监听。",
|
||||
_requestedMode);
|
||||
}
|
||||
|
||||
var mode = _requestedMode.Equals("PassiveMonitor", StringComparison.OrdinalIgnoreCase)
|
||||
? "PassiveMonitor"
|
||||
: "ProxyDaemon";
|
||||
|
||||
var daemonRunning = await CheckNotificationDaemonAsync(cancellationToken).ConfigureAwait(false);
|
||||
var statusMessage = mode == "ProxyDaemon" && daemonRunning
|
||||
? "系统通知守护进程已占用 org.freedesktop.Notifications,已以旁路监听方式运行。"
|
||||
: mode == "ProxyDaemon"
|
||||
? "Linux 通知代理模式已启动;未检测到现有通知守护进程。"
|
||||
: "Linux 通知旁路监听已启动。";
|
||||
|
||||
StartDbusMonitor(mode);
|
||||
|
||||
return new NotificationBoxStatus(
|
||||
mode == "ProxyDaemon" && daemonRunning ? NotificationBoxServiceState.Degraded : NotificationBoxServiceState.Running,
|
||||
statusMessage,
|
||||
mode);
|
||||
}
|
||||
|
||||
private async Task<bool> CheckNotificationDaemonAsync()
|
||||
private void StartDbusMonitor(string mode)
|
||||
{
|
||||
try
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
// 检查常见通知守护进程
|
||||
var processNames = new[] { "gnome-shell", "kded5", "dunst", "mako", "swaync" };
|
||||
foreach (var name in processNames)
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "pgrep",
|
||||
Arguments = $"-x {name}",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
FileName = "dbus-monitor",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
startInfo.ArgumentList.Add("--session");
|
||||
startInfo.ArgumentList.Add("interface='org.freedesktop.Notifications'");
|
||||
|
||||
using var process = System.Diagnostics.Process.Start(psi);
|
||||
if (process != null)
|
||||
{
|
||||
await process.WaitForExitAsync();
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
_monitorProcess = Process.Start(startInfo);
|
||||
if (_monitorProcess is null)
|
||||
{
|
||||
return false;
|
||||
throw new InvalidOperationException("Failed to start dbus-monitor.");
|
||||
}
|
||||
|
||||
_monitorTask = Task.Run(() => ReadMonitorOutputAsync(_monitorProcess, mode, _cts.Token), CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task StartListeningAsync(CancellationToken ct)
|
||||
private async Task ReadMonitorOutputAsync(Process process, string mode, CancellationToken cancellationToken)
|
||||
{
|
||||
_isRunning = true;
|
||||
var capture = new List<string>();
|
||||
var inNotify = false;
|
||||
|
||||
try
|
||||
while (!cancellationToken.IsCancellationRequested && !process.HasExited)
|
||||
{
|
||||
// 注意:Tmds.DBus.Protocol 是低层API
|
||||
// 这里使用简化方案,实际生产环境需要完整的DBus信号订阅实现
|
||||
// 当前版本为框架实现,后续可以完善DBus监听逻辑
|
||||
|
||||
while (!ct.IsCancellationRequested && _isRunning)
|
||||
var line = await process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.Contains("member=Notify", StringComparison.Ordinal))
|
||||
{
|
||||
capture.Clear();
|
||||
inNotify = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inNotify)
|
||||
{
|
||||
if (line.Contains("member=NotificationClosed", StringComparison.Ordinal) ||
|
||||
line.Contains("member=CloseNotification", StringComparison.Ordinal))
|
||||
{
|
||||
capture.Clear();
|
||||
capture.Add(line);
|
||||
inNotify = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("method ", StringComparison.Ordinal) ||
|
||||
line.StartsWith("signal ", StringComparison.Ordinal))
|
||||
{
|
||||
TryParseNotify(capture, mode);
|
||||
capture.Clear();
|
||||
inNotify = line.Contains("member=Notify", StringComparison.Ordinal);
|
||||
continue;
|
||||
}
|
||||
|
||||
capture.Add(line);
|
||||
if (capture.Count > 40)
|
||||
{
|
||||
TryParseNotify(capture, mode);
|
||||
capture.Clear();
|
||||
inNotify = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理接收到的通知(供DBus信号处理器调用)
|
||||
/// </summary>
|
||||
private void TryParseNotify(IReadOnlyList<string> lines, string mode)
|
||||
{
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var strings = lines
|
||||
.Select(line => DbusStringRegex.Match(line))
|
||||
.Where(match => match.Success)
|
||||
.Select(match => UnescapeDbusString(match.Groups["value"].Value))
|
||||
.ToList();
|
||||
|
||||
if (strings.Count < 4)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var appName = strings[0];
|
||||
var appIcon = strings[1];
|
||||
var summary = strings[2];
|
||||
var body = strings[3];
|
||||
var desktopEntry = TryMatchHint(lines, DesktopEntryHintRegex);
|
||||
var imagePath = TryMatchHint(lines, ImagePathHintRegex);
|
||||
|
||||
var sourceId = lines
|
||||
.Select(line => DbusUIntRegex.Match(line))
|
||||
.Where(match => match.Success)
|
||||
.Select(match => match.Groups["value"].Value)
|
||||
.Skip(1)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceId))
|
||||
{
|
||||
sourceId = (_nextSyntheticId++).ToString();
|
||||
}
|
||||
|
||||
var notification = new NotificationItem
|
||||
{
|
||||
Id = $"linux:{sourceId}",
|
||||
SourceNotificationId = sourceId,
|
||||
Platform = "Linux",
|
||||
CaptureMode = mode,
|
||||
AppId = !string.IsNullOrWhiteSpace(desktopEntry)
|
||||
? desktopEntry
|
||||
: NormalizeAppId(appName),
|
||||
AppName = string.IsNullOrWhiteSpace(appName) ? "Linux 应用" : appName,
|
||||
Title = StripHtmlTags(summary),
|
||||
Content = StripHtmlTags(body),
|
||||
AppIconPath = ResolveIconPath(!string.IsNullOrWhiteSpace(imagePath) ? imagePath : appIcon, appName),
|
||||
DesktopEntryId = string.IsNullOrWhiteSpace(desktopEntry) ? null : $"{desktopEntry}.desktop",
|
||||
LaunchTarget = string.IsNullOrWhiteSpace(desktopEntry) ? null : desktopEntry,
|
||||
CanActivate = !string.IsNullOrWhiteSpace(desktopEntry),
|
||||
ReceivedAtUtc = DateTimeOffset.UtcNow,
|
||||
ReceivedTime = DateTime.Now
|
||||
};
|
||||
|
||||
_parent.AddNotification(notification);
|
||||
}
|
||||
|
||||
public void HandleNotification(
|
||||
string appName,
|
||||
uint replacesId,
|
||||
@@ -136,30 +219,75 @@ internal sealed class LinuxNotificationListener : IDisposable
|
||||
object hints,
|
||||
int expireTimeout)
|
||||
{
|
||||
try
|
||||
var sourceId = replacesId == 0 ? _nextSyntheticId++ : replacesId;
|
||||
var notification = new NotificationItem
|
||||
{
|
||||
var notification = new NotificationItem
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
AppId = appName.ToLowerInvariant().Replace(" ", ""),
|
||||
AppName = appName,
|
||||
Title = summary,
|
||||
Content = StripHtmlTags(body),
|
||||
ReceivedTime = DateTime.Now,
|
||||
AppIconPath = ResolveIconPath(appIcon, appName)
|
||||
};
|
||||
Id = $"linux:{sourceId}",
|
||||
SourceNotificationId = sourceId.ToString(),
|
||||
Platform = "Linux",
|
||||
CaptureMode = _requestedMode,
|
||||
AppId = NormalizeAppId(appName),
|
||||
AppName = appName,
|
||||
Title = StripHtmlTags(summary),
|
||||
Content = StripHtmlTags(body),
|
||||
AppIconPath = ResolveIconPath(appIcon, appName),
|
||||
ReceivedAtUtc = DateTimeOffset.UtcNow,
|
||||
ReceivedTime = DateTime.Now
|
||||
};
|
||||
|
||||
_parent.AddNotification(notification);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] 处理通知失败: {ex.Message}");
|
||||
}
|
||||
_parent.AddNotification(notification);
|
||||
}
|
||||
|
||||
private static async Task<bool> CheckNotificationDaemonAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var processNames = new[] { "gnome-shell", "plasmashell", "kded5", "dunst", "mako", "swaync", "xfce4-notifyd" };
|
||||
foreach (var name in processNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "pgrep",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}.WithArgument("-x").WithArgument(name));
|
||||
if (process is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CommandExists(string command)
|
||||
{
|
||||
var pathEntries = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return pathEntries.Any(path =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(Path.Combine(path, command));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析应用图标路径
|
||||
/// </summary>
|
||||
private static string? ResolveIconPath(string iconName, string appName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(iconName))
|
||||
@@ -167,13 +295,11 @@ internal sealed class LinuxNotificationListener : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果是绝对路径,直接使用
|
||||
if (File.Exists(iconName))
|
||||
{
|
||||
return iconName;
|
||||
}
|
||||
|
||||
// 尝试从图标主题中查找
|
||||
var iconPaths = new[]
|
||||
{
|
||||
$"/usr/share/icons/hicolor/48x48/apps/{iconName}.png",
|
||||
@@ -187,9 +313,6 @@ internal sealed class LinuxNotificationListener : IDisposable
|
||||
return iconPaths.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 去除HTML标签(通知内容可能包含HTML)
|
||||
/// </summary>
|
||||
private static string StripHtmlTags(string html)
|
||||
{
|
||||
if (string.IsNullOrEmpty(html))
|
||||
@@ -197,20 +320,58 @@ internal sealed class LinuxNotificationListener : IDisposable
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// 简单的HTML标签去除
|
||||
var result = html;
|
||||
result = System.Text.RegularExpressions.Regex.Replace(result, "<[^>]+>", "");
|
||||
result = result.Replace("<", "<");
|
||||
result = result.Replace(">", ">");
|
||||
result = result.Replace("&", "&");
|
||||
result = result.Replace(""", "\"");
|
||||
return result.Trim();
|
||||
var result = Regex.Replace(html, "<[^>]+>", string.Empty);
|
||||
return result
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal)
|
||||
.Replace("&", "&", StringComparison.Ordinal)
|
||||
.Replace(""", "\"", StringComparison.Ordinal)
|
||||
.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeAppId(string appName)
|
||||
=> appName.ToLowerInvariant().Replace(" ", string.Empty, StringComparison.Ordinal);
|
||||
|
||||
private static string? TryMatchHint(IEnumerable<string> lines, Regex regex)
|
||||
=> lines.Select(line => regex.Match(line))
|
||||
.Where(match => match.Success)
|
||||
.Select(match => UnescapeDbusString(match.Groups["value"].Value))
|
||||
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
||||
|
||||
private static string UnescapeDbusString(string value)
|
||||
=> value
|
||||
.Replace("\\\"", "\"", StringComparison.Ordinal)
|
||||
.Replace("\\n", "\n", StringComparison.Ordinal)
|
||||
.Replace("\\\\", "\\", StringComparison.Ordinal);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_isRunning = false;
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts.Cancel();
|
||||
try
|
||||
{
|
||||
if (_monitorProcess is { HasExited: false })
|
||||
{
|
||||
_monitorProcess.Kill(entireProcessTree: true);
|
||||
}
|
||||
|
||||
_monitorTask?.Wait(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
_monitorProcess?.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ProcessStartInfoArgumentExtensions
|
||||
{
|
||||
public static ProcessStartInfo WithArgument(this ProcessStartInfo startInfo, string argument)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
return startInfo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
@@ -9,145 +11,195 @@ using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public enum NotificationBoxServiceState
|
||||
{
|
||||
NotStarted,
|
||||
Starting,
|
||||
Running,
|
||||
WaitingForPermission,
|
||||
Unsupported,
|
||||
Degraded,
|
||||
Failed
|
||||
}
|
||||
|
||||
public sealed record NotificationBoxStatus(
|
||||
NotificationBoxServiceState State,
|
||||
string Message,
|
||||
string CaptureMode,
|
||||
bool CanRequestPermission = false);
|
||||
|
||||
internal interface IPlatformNotificationListener : IDisposable
|
||||
{
|
||||
Task<NotificationBoxStatus> InitializeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task RequestPermissionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 跨平台通知监听服务
|
||||
/// Cross-platform notification aggregation service used by the notification box widget.
|
||||
/// </summary>
|
||||
public sealed class NotificationListenerService : IDisposable
|
||||
{
|
||||
private readonly List<NotificationItem> _notifications = [];
|
||||
private readonly object _lock = new();
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
// 平台特定的监听器
|
||||
private LinuxNotificationListener? _linuxListener;
|
||||
private readonly CancellationTokenSource _disposeCts = new();
|
||||
private IPlatformNotificationListener? _platformListener;
|
||||
private NotificationBoxStatus _status = new(
|
||||
NotificationBoxServiceState.NotStarted,
|
||||
"通知监听尚未启动。",
|
||||
"None");
|
||||
|
||||
public event EventHandler<NotificationItem>? NotificationReceived;
|
||||
public event EventHandler<string>? NotificationRemoved;
|
||||
public event EventHandler<NotificationBoxStatus>? StatusChanged;
|
||||
|
||||
public NotificationListenerService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化并启动监听
|
||||
/// </summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
SetStatus(new NotificationBoxStatus(NotificationBoxServiceState.Starting, "正在启动通知监听...", "Starting"));
|
||||
|
||||
try
|
||||
{
|
||||
var settings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
if (!settings.NotificationBoxEnabled)
|
||||
{
|
||||
SetStatus(new NotificationBoxStatus(NotificationBoxServiceState.Unsupported, "消息盒子已在设置中关闭。", "Disabled"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// Windows: 使用 UserNotificationListener (需要Windows SDK)
|
||||
// 当前为模拟实现
|
||||
await InitializeWindowsAsync();
|
||||
_platformListener = new WindowsNotificationListener(this);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
// Linux: 使用 DBus
|
||||
await InitializeLinuxAsync();
|
||||
_platformListener = new LinuxNotificationListener(this, settings.NotificationBoxLinuxCaptureMode);
|
||||
}
|
||||
else
|
||||
{
|
||||
// macOS 或其他平台:功能不可用
|
||||
Console.WriteLine("[NotificationBox] 当前平台不支持通知监听");
|
||||
SetStatus(new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.Unsupported,
|
||||
"当前平台暂不支持系统通知监听。",
|
||||
"Unsupported"));
|
||||
return;
|
||||
}
|
||||
|
||||
var status = await _platformListener.InitializeAsync(_disposeCts.Token).ConfigureAwait(false);
|
||||
SetStatus(status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] 初始化失败: {ex.Message}");
|
||||
SetStatus(new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.Failed,
|
||||
$"通知监听初始化失败:{ex.Message}",
|
||||
"Failed"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeWindowsAsync()
|
||||
{
|
||||
// Windows通知监听实现
|
||||
// 实际项目中需要添加Windows SDK引用并使用UserNotificationListener
|
||||
// 由于需要UWP API,这里使用模拟实现
|
||||
await Task.CompletedTask;
|
||||
Console.WriteLine("[NotificationBox] Windows通知监听已启动(模拟模式)");
|
||||
}
|
||||
public NotificationBoxStatus GetStatus() => _status;
|
||||
|
||||
private async Task InitializeLinuxAsync()
|
||||
public async Task RequestPermissionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_platformListener is null)
|
||||
{
|
||||
await InitializeAsync().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_linuxListener = new LinuxNotificationListener(this);
|
||||
var success = await _linuxListener.InitializeAsync();
|
||||
|
||||
if (!success)
|
||||
{
|
||||
Console.WriteLine("[NotificationBox] Linux通知监听初始化失败,可能未运行通知守护进程");
|
||||
}
|
||||
await _platformListener.RequestPermissionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}");
|
||||
SetStatus(new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.Failed,
|
||||
$"请求通知权限失败:{ex.Message}",
|
||||
_status.CaptureMode,
|
||||
CanRequestPermission: true));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加通知(供平台监听器调用)
|
||||
/// </summary>
|
||||
public void SetStatus(NotificationBoxStatus status)
|
||||
{
|
||||
_status = status;
|
||||
Dispatcher.UIThread.InvokeAsync(() => StatusChanged?.Invoke(this, status));
|
||||
}
|
||||
|
||||
public void AddNotification(NotificationItem notification)
|
||||
{
|
||||
var settings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
|
||||
// 检查全局开关
|
||||
if (!settings.NotificationBoxEnabled)
|
||||
return;
|
||||
|
||||
// 检查是否在屏蔽列表中
|
||||
if (settings.NotificationBoxBlockedApps.Contains(notification.AppId, StringComparer.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_notifications.Add(notification);
|
||||
CleanupOldNotifications(settings);
|
||||
return;
|
||||
}
|
||||
|
||||
// 在UI线程触发事件
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
if (settings.NotificationBoxBlockedApps.Contains(notification.AppId, StringComparer.OrdinalIgnoreCase) ||
|
||||
settings.NotificationBoxBlockedApps.Contains(notification.AppName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
NotificationReceived?.Invoke(this, notification);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (notification.ReceivedAtUtc == default)
|
||||
{
|
||||
notification.ReceivedAtUtc = now;
|
||||
}
|
||||
|
||||
if (notification.ReceivedTime == default)
|
||||
{
|
||||
notification.ReceivedTime = notification.ReceivedAtUtc.LocalDateTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除通知
|
||||
/// </summary>
|
||||
public void RemoveNotification(string notificationId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var notification = _notifications.FirstOrDefault(n => n.Id == notificationId);
|
||||
if (notification != null)
|
||||
var existing = !string.IsNullOrWhiteSpace(notification.SourceNotificationId)
|
||||
? _notifications.FirstOrDefault(n =>
|
||||
string.Equals(n.Platform, notification.Platform, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(n.SourceNotificationId, notification.SourceNotificationId, StringComparison.OrdinalIgnoreCase))
|
||||
: null;
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_notifications.Remove(notification);
|
||||
CopyNotification(notification, existing);
|
||||
CleanupOldNotifications(settings);
|
||||
}
|
||||
else
|
||||
{
|
||||
_notifications.Add(notification);
|
||||
CleanupOldNotifications(settings);
|
||||
}
|
||||
}
|
||||
|
||||
NotificationRemoved?.Invoke(this, notificationId);
|
||||
Dispatcher.UIThread.InvokeAsync(() => NotificationReceived?.Invoke(this, notification));
|
||||
}
|
||||
|
||||
private void CleanupOldNotifications(AppSettingsSnapshot settings)
|
||||
public void RemoveNotification(string notificationId)
|
||||
{
|
||||
// 按数量清理
|
||||
var maxCount = settings.NotificationBoxMaxStoredCount;
|
||||
while (_notifications.Count > maxCount)
|
||||
var removed = false;
|
||||
lock (_lock)
|
||||
{
|
||||
_notifications.RemoveAt(0);
|
||||
var notification = _notifications.FirstOrDefault(n =>
|
||||
string.Equals(n.Id, notificationId, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(n.SourceNotificationId, notificationId, StringComparison.OrdinalIgnoreCase));
|
||||
if (notification != null)
|
||||
{
|
||||
_notifications.Remove(notification);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间清理
|
||||
var cutoffDate = DateTime.Now.AddDays(-settings.NotificationBoxHistoryRetentionDays);
|
||||
_notifications.RemoveAll(n => n.ReceivedTime < cutoffDate);
|
||||
if (removed)
|
||||
{
|
||||
Dispatcher.UIThread.InvokeAsync(() => NotificationRemoved?.Invoke(this, notificationId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有通知
|
||||
/// </summary>
|
||||
public IReadOnlyList<NotificationItem> GetNotifications()
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -156,20 +208,16 @@ public sealed class NotificationListenerService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空所有通知
|
||||
/// </summary>
|
||||
public void ClearAll()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_notifications.Clear();
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.InvokeAsync(() => StatusChanged?.Invoke(this, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记通知为已读
|
||||
/// </summary>
|
||||
public void MarkAsRead(string notificationId)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -182,9 +230,6 @@ public sealed class NotificationListenerService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取未读通知数量
|
||||
/// </summary>
|
||||
public int GetUnreadCount()
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -193,9 +238,187 @@ public sealed class NotificationListenerService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryActivate(NotificationItem notification)
|
||||
{
|
||||
if (!notification.CanActivate)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return TryLaunchWindows(notification);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return TryLaunchLinux(notification);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryLaunchWindows(NotificationItem notification)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = notification.LaunchTarget;
|
||||
if (string.IsNullOrWhiteSpace(target) && !string.IsNullOrWhiteSpace(notification.Aumid))
|
||||
{
|
||||
target = $"shell:AppsFolder\\{notification.Aumid}";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.StartsWith("shell:AppsFolder\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = target,
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = target,
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryLaunchLinux(NotificationItem notification)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(notification.DesktopEntryId))
|
||||
{
|
||||
var root = new LinuxDesktopEntryService().Load();
|
||||
var entry = EnumerateApps(root).FirstOrDefault(app =>
|
||||
string.Equals(app.RelativePath, notification.DesktopEntryId, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(app.RelativePath, $"{notification.DesktopEntryId}.desktop", StringComparison.OrdinalIgnoreCase));
|
||||
if (entry is not null && !string.IsNullOrWhiteSpace(entry.LaunchExecutable))
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = entry.LaunchExecutable,
|
||||
UseShellExecute = false
|
||||
};
|
||||
foreach (var argument in entry.LaunchArguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(entry.WorkingDirectory))
|
||||
{
|
||||
startInfo.WorkingDirectory = entry.WorkingDirectory;
|
||||
}
|
||||
Process.Start(startInfo);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.LaunchTarget))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = notification.LaunchTarget,
|
||||
UseShellExecute = true
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void CleanupOldNotifications(AppSettingsSnapshot settings)
|
||||
{
|
||||
var maxCount = Math.Max(1, settings.NotificationBoxMaxStoredCount);
|
||||
while (_notifications.Count > maxCount)
|
||||
{
|
||||
_notifications.RemoveAt(0);
|
||||
}
|
||||
|
||||
var cutoffDate = DateTime.Now.AddDays(-Math.Max(1, settings.NotificationBoxHistoryRetentionDays));
|
||||
_notifications.RemoveAll(n => n.ReceivedTime < cutoffDate);
|
||||
}
|
||||
|
||||
private static IEnumerable<StartMenuAppEntry> EnumerateApps(StartMenuFolderNode node)
|
||||
{
|
||||
foreach (var app in node.Apps)
|
||||
{
|
||||
yield return app;
|
||||
}
|
||||
|
||||
foreach (var folder in node.Folders)
|
||||
{
|
||||
foreach (var app in EnumerateApps(folder))
|
||||
{
|
||||
yield return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyNotification(NotificationItem source, NotificationItem target)
|
||||
{
|
||||
target.AppId = source.AppId;
|
||||
target.AppName = source.AppName;
|
||||
target.AppIconPath = source.AppIconPath;
|
||||
target.AppIconBytes = source.AppIconBytes;
|
||||
target.Title = source.Title;
|
||||
target.Content = source.Content;
|
||||
target.ReceivedTime = source.ReceivedTime;
|
||||
target.ReceivedAtUtc = source.ReceivedAtUtc;
|
||||
target.LaunchArgs = source.LaunchArgs;
|
||||
target.Platform = source.Platform;
|
||||
target.SourceNotificationId = source.SourceNotificationId;
|
||||
target.DesktopEntryId = source.DesktopEntryId;
|
||||
target.Aumid = source.Aumid;
|
||||
target.LaunchTarget = source.LaunchTarget;
|
||||
target.CanActivate = source.CanActivate;
|
||||
target.CaptureMode = source.CaptureMode;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_linuxListener?.Dispose();
|
||||
_disposeCts.Cancel();
|
||||
_platformListener?.Dispose();
|
||||
_disposeCts.Dispose();
|
||||
ClearAll();
|
||||
}
|
||||
}
|
||||
|
||||
public static class NotificationListenerServiceProvider
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static NotificationListenerService? _instance;
|
||||
|
||||
public static NotificationListenerService GetOrCreate(ISettingsService settingsService)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new NotificationListenerService(settingsService);
|
||||
_ = _instance.InitializeAsync();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
using System;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class SingleInstanceService : IDisposable
|
||||
{
|
||||
private const byte ActivationRequestCode = 0x41; // 'A'
|
||||
private const byte ActivationAckCode = 0x4B; // 'K'
|
||||
private const byte ActivationNackCode = 0x4E; // 'N'
|
||||
|
||||
private readonly Mutex _mutex;
|
||||
private readonly string _pipeName;
|
||||
private readonly CancellationTokenSource _listenCts = new();
|
||||
private readonly ManualResetEventSlim _listenerReady = new(false);
|
||||
private bool _ownsMutex;
|
||||
private bool _disposed;
|
||||
private Task? _listenTask;
|
||||
|
||||
private SingleInstanceService(string mutexName, string pipeName)
|
||||
{
|
||||
_mutex = new Mutex(initiallyOwned: false, mutexName);
|
||||
_pipeName = pipeName;
|
||||
try
|
||||
{
|
||||
_ownsMutex = _mutex.WaitOne(TimeSpan.Zero, exitContext: false);
|
||||
}
|
||||
catch (AbandonedMutexException)
|
||||
{
|
||||
_ownsMutex = true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsPrimaryInstance => _ownsMutex;
|
||||
|
||||
public static SingleInstanceService CreateDefault()
|
||||
{
|
||||
const string appId = "LanMountainDesktop";
|
||||
var userName = Environment.UserName;
|
||||
var scopeSeed = $"{appId}:{userName}";
|
||||
var scopeHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(scopeSeed)));
|
||||
var suffix = scopeHash[..16];
|
||||
var mutexName = OperatingSystem.IsWindows()
|
||||
? $"Local\\{appId}.SingleInstance.{suffix}"
|
||||
: $"{appId}.SingleInstance.{suffix}";
|
||||
return new SingleInstanceService(
|
||||
mutexName,
|
||||
$"{appId}.Activate.{suffix}");
|
||||
}
|
||||
|
||||
public void StartActivationListener(Action onActivationRequested)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(onActivationRequested);
|
||||
|
||||
if (!_ownsMutex || _disposed || _listenTask is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"SingleInstance",
|
||||
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
|
||||
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
|
||||
_listenerReady.Wait(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
|
||||
{
|
||||
return TryNotifyPrimaryInstance(timeout, out _);
|
||||
}
|
||||
|
||||
public bool TryNotifyPrimaryInstance(TimeSpan timeout, out string? failureReason)
|
||||
{
|
||||
if (_ownsMutex || _disposed)
|
||||
{
|
||||
failureReason = _ownsMutex
|
||||
? "current_instance_is_primary"
|
||||
: "single_instance_service_disposed";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: _pipeName,
|
||||
direction: PipeDirection.InOut,
|
||||
options: PipeOptions.Asynchronous);
|
||||
|
||||
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
|
||||
client.WriteByte(ActivationRequestCode);
|
||||
client.Flush();
|
||||
|
||||
var ack = client.ReadByte();
|
||||
var acknowledged = ack == ActivationAckCode;
|
||||
if (!acknowledged)
|
||||
{
|
||||
failureReason = ack switch
|
||||
{
|
||||
ActivationNackCode => "primary_rejected_activation",
|
||||
-1 => "ack_not_received",
|
||||
_ => $"unexpected_ack_code_{ack}"
|
||||
};
|
||||
AppLogger.Warn(
|
||||
"SingleInstance",
|
||||
$"Primary activation handshake failed. AckCode={ack}; Reason='{failureReason}'; Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
failureReason = null;
|
||||
AppLogger.Info(
|
||||
"SingleInstance",
|
||||
$"Primary activation acknowledged. Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failureReason = "primary_activation_handshake_exception";
|
||||
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_listenCts.Cancel();
|
||||
try
|
||||
{
|
||||
_listenTask?.Wait(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore listener shutdown races during process exit.
|
||||
}
|
||||
|
||||
_listenCts.Dispose();
|
||||
_listenerReady.Dispose();
|
||||
if (_ownsMutex)
|
||||
{
|
||||
try
|
||||
{
|
||||
_mutex.ReleaseMutex();
|
||||
}
|
||||
catch (ApplicationException)
|
||||
{
|
||||
// Ownership may already be lost during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
_mutex.Dispose();
|
||||
}
|
||||
|
||||
private async Task ListenForActivationAsync(Action onActivationRequested, CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var server = new NamedPipeServerStream(
|
||||
_pipeName,
|
||||
PipeDirection.InOut,
|
||||
1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
_listenerReady.Set();
|
||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var buffer = new byte[1];
|
||||
var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
var isActivationRequest = readBytes == 1 && buffer[0] == ActivationRequestCode;
|
||||
var ackCode = ActivationAckCode;
|
||||
|
||||
if (!isActivationRequest)
|
||||
{
|
||||
ackCode = ActivationNackCode;
|
||||
AppLogger.Warn(
|
||||
"SingleInstance",
|
||||
$"Received malformed activation request. ReadBytes={readBytes}; Value={(readBytes == 1 ? buffer[0] : -1)}; Pipe='{_pipeName}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
onActivationRequested();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ackCode = ActivationNackCode;
|
||||
AppLogger.Warn("SingleInstance", "Activation callback failed.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
var ackBuffer = new[] { ackCode };
|
||||
await server.WriteAsync(ackBuffer, cancellationToken).ConfigureAwait(false);
|
||||
await server.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", "Activation listener failed.", ex);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
504
LanMountainDesktop/Services/WindowsNotificationListener.cs
Normal file
@@ -0,0 +1,504 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed class WindowsNotificationListener : IPlatformNotificationListener
|
||||
{
|
||||
private static readonly Type? UserNotificationListenerType =
|
||||
ResolveWinRtType("Windows.UI.Notifications.Management.UserNotificationListener");
|
||||
private static readonly Type? NotificationKindsType =
|
||||
ResolveWinRtType("Windows.UI.Notifications.NotificationKinds");
|
||||
private static readonly Type? KnownNotificationBindingsType =
|
||||
ResolveWinRtType("Windows.UI.Notifications.KnownNotificationBindings");
|
||||
private static readonly Type? AppInfoType =
|
||||
ResolveWinRtType("Windows.ApplicationModel.AppInfo");
|
||||
private static readonly MethodInfo? AsTaskGenericMethodDefinition = ResolveAsTaskGenericMethod();
|
||||
private static readonly MethodInfo? AsStreamForReadMethod = ResolveAsStreamForReadMethod();
|
||||
|
||||
private readonly NotificationListenerService _parent;
|
||||
private readonly Dictionary<string, NotificationItem> _lastSnapshot = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private object? _listener;
|
||||
private Task? _pollTask;
|
||||
|
||||
public WindowsNotificationListener(NotificationListenerService parent)
|
||||
{
|
||||
_parent = parent;
|
||||
}
|
||||
|
||||
public async Task<NotificationBoxStatus> InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || UserNotificationListenerType is null ||
|
||||
NotificationKindsType is null || AsTaskGenericMethodDefinition is null)
|
||||
{
|
||||
return new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.Unsupported,
|
||||
"当前 Windows 版本不支持系统通知监听。",
|
||||
"Windows");
|
||||
}
|
||||
|
||||
if (!HasPackageIdentity())
|
||||
{
|
||||
return new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.WaitingForPermission,
|
||||
"缺少 Windows 包身份。请使用带通知身份包的安装版本,以便系统授予通知监听权限。",
|
||||
"Windows",
|
||||
CanRequestPermission: false);
|
||||
}
|
||||
|
||||
_listener = GetPropertyValue(UserNotificationListenerType, "Current");
|
||||
if (_listener is null)
|
||||
{
|
||||
return new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.Unsupported,
|
||||
"无法创建 Windows 通知监听器。",
|
||||
"Windows");
|
||||
}
|
||||
|
||||
var accessStatus = ReadAccessStatus(_listener);
|
||||
if (!string.Equals(accessStatus, "Allowed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
accessStatus = await RequestAccessCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.Equals(accessStatus, "Allowed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.WaitingForPermission,
|
||||
accessStatus.Equals("Denied", StringComparison.OrdinalIgnoreCase)
|
||||
? "Windows 已拒绝通知监听权限,请在系统设置中允许阑山桌面读取通知。"
|
||||
: "等待用户授予 Windows 通知监听权限。",
|
||||
"Windows",
|
||||
CanRequestPermission: true);
|
||||
}
|
||||
|
||||
await SyncNotificationsAsync(cancellationToken).ConfigureAwait(false);
|
||||
_pollTask = Task.Run(() => PollLoopAsync(_cts.Token), CancellationToken.None);
|
||||
|
||||
return new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.Running,
|
||||
"Windows 系统通知监听已启动。",
|
||||
"Windows");
|
||||
}
|
||||
|
||||
public async Task RequestPermissionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_listener is null)
|
||||
{
|
||||
await InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var accessStatus = await RequestAccessCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
_parent.SetStatus(string.Equals(accessStatus, "Allowed", StringComparison.OrdinalIgnoreCase)
|
||||
? new NotificationBoxStatus(NotificationBoxServiceState.Running, "Windows 系统通知监听已启动。", "Windows")
|
||||
: new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.WaitingForPermission,
|
||||
"Windows 通知监听权限尚未授予。",
|
||||
"Windows",
|
||||
CanRequestPermission: true));
|
||||
|
||||
if (string.Equals(accessStatus, "Allowed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await SyncNotificationsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
|
||||
await SyncNotificationsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_parent.SetStatus(new NotificationBoxStatus(
|
||||
NotificationBoxServiceState.Degraded,
|
||||
$"Windows 通知同步遇到问题:{ex.Message}",
|
||||
"Windows"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncNotificationsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_listener is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var operation = InvokeMethod(_listener, "GetNotificationsAsync", [ParseNotificationKindsToast()]);
|
||||
var notificationsObject = await AwaitWinRtOperationAsync(operation, cancellationToken).ConfigureAwait(false);
|
||||
if (notificationsObject is not System.Collections.IEnumerable notifications)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var notificationObject in notifications)
|
||||
{
|
||||
var item = await TryMapNotificationAsync(notificationObject, cancellationToken).ConfigureAwait(false);
|
||||
if (item is null || string.IsNullOrWhiteSpace(item.SourceNotificationId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
currentIds.Add(item.SourceNotificationId);
|
||||
_lastSnapshot[item.SourceNotificationId] = item;
|
||||
_parent.AddNotification(item);
|
||||
}
|
||||
|
||||
foreach (var removedId in _lastSnapshot.Keys.Where(id => !currentIds.Contains(id)).ToList())
|
||||
{
|
||||
_lastSnapshot.Remove(removedId);
|
||||
_parent.RemoveNotification(removedId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<NotificationItem?> TryMapNotificationAsync(object? notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sourceId = ReadUIntProperty(notification, "Id").ToString();
|
||||
var creationTime = ReadDateTimeOffsetProperty(notification, "CreationTime") ?? DateTimeOffset.UtcNow;
|
||||
var appInfo = GetPropertyValue(notification, "AppInfo");
|
||||
var displayInfo = GetPropertyValue(appInfo, "DisplayInfo");
|
||||
var appName = ReadStringProperty(displayInfo, "DisplayName");
|
||||
var aumid = ReadStringProperty(appInfo, "AppUserModelId");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(aumid))
|
||||
{
|
||||
aumid = TryReadPackageFamilyName(appInfo);
|
||||
}
|
||||
|
||||
var (title, body) = ReadToastText(notification);
|
||||
if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(appName))
|
||||
{
|
||||
appName = SimplifyAppId(aumid);
|
||||
}
|
||||
|
||||
var iconBytes = await TryReadAppLogoAsync(displayInfo, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new NotificationItem
|
||||
{
|
||||
Id = $"windows:{sourceId}",
|
||||
SourceNotificationId = sourceId,
|
||||
Platform = "Windows",
|
||||
CaptureMode = "WindowsUserNotificationListener",
|
||||
AppId = string.IsNullOrWhiteSpace(aumid) ? appName : aumid,
|
||||
AppName = string.IsNullOrWhiteSpace(appName) ? "Windows 应用" : appName,
|
||||
Aumid = string.IsNullOrWhiteSpace(aumid) ? null : aumid,
|
||||
LaunchTarget = string.IsNullOrWhiteSpace(aumid) ? null : $"shell:AppsFolder\\{aumid}",
|
||||
CanActivate = !string.IsNullOrWhiteSpace(aumid),
|
||||
Title = title,
|
||||
Content = body,
|
||||
ReceivedAtUtc = creationTime.ToUniversalTime(),
|
||||
ReceivedTime = creationTime.LocalDateTime,
|
||||
AppIconBytes = iconBytes
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string Title, string Body) ReadToastText(object notification)
|
||||
{
|
||||
var notificationPayload = GetPropertyValue(notification, "Notification");
|
||||
var visual = GetPropertyValue(notificationPayload, "Visual");
|
||||
var toastGeneric = GetPropertyValue(KnownNotificationBindingsType, "ToastGeneric");
|
||||
var binding = InvokeMethod(visual, "GetBinding", [toastGeneric]);
|
||||
var textElements = InvokeMethod(binding, "GetTextElements", null) as System.Collections.IEnumerable;
|
||||
if (textElements is null)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
var texts = new List<string>();
|
||||
foreach (var element in textElements)
|
||||
{
|
||||
var text = ReadStringProperty(element, "Text");
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
texts.Add(text);
|
||||
}
|
||||
}
|
||||
|
||||
return texts.Count switch
|
||||
{
|
||||
0 => (string.Empty, string.Empty),
|
||||
1 => (texts[0], string.Empty),
|
||||
_ => (texts[0], string.Join(Environment.NewLine, texts.Skip(1)))
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<byte[]?> TryReadAppLogoAsync(object? displayInfo, CancellationToken cancellationToken)
|
||||
{
|
||||
if (displayInfo is null || AsStreamForReadMethod is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sizeType = ResolveWinRtType("Windows.Foundation.Size");
|
||||
object size = sizeType is not null
|
||||
? Activator.CreateInstance(sizeType, 32d, 32d)!
|
||||
: null!;
|
||||
if (size is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var logoReference = InvokeMethod(displayInfo, "GetLogo", [size]);
|
||||
var streamObject = await AwaitWinRtOperationAsync(InvokeMethod(logoReference, "OpenReadAsync", null), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
using var dotnetStream = AsStreamForReadMethod.Invoke(null, [streamObject]) as Stream;
|
||||
if (dotnetStream is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await dotnetStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static object ParseNotificationKindsToast()
|
||||
{
|
||||
return Enum.Parse(NotificationKindsType!, "Toast");
|
||||
}
|
||||
|
||||
private static string ReadAccessStatus(object listener)
|
||||
{
|
||||
return InvokeMethod(listener, "GetAccessStatus", null)?.ToString() ?? "Unspecified";
|
||||
}
|
||||
|
||||
private async Task<string> RequestAccessCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_listener is null)
|
||||
{
|
||||
return "Unspecified";
|
||||
}
|
||||
|
||||
var result = await AwaitWinRtOperationAsync(InvokeMethod(_listener, "RequestAccessAsync", null), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return result?.ToString() ?? "Unspecified";
|
||||
}
|
||||
|
||||
private static bool HasPackageIdentity()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var length = 0;
|
||||
var hr = GetCurrentPackageFullName(ref length, null);
|
||||
if (hr == AppmodelErrorNoPackage)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (length <= 0)
|
||||
{
|
||||
return hr == 0;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(length);
|
||||
hr = GetCurrentPackageFullName(ref length, builder);
|
||||
return hr == 0;
|
||||
}
|
||||
|
||||
private static string TryReadPackageFamilyName(object? appInfo)
|
||||
{
|
||||
var package = GetPropertyValue(appInfo, "Package");
|
||||
var id = GetPropertyValue(package, "Id");
|
||||
return ReadStringProperty(id, "FamilyName");
|
||||
}
|
||||
|
||||
private static string SimplifyAppId(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "Windows 应用";
|
||||
}
|
||||
|
||||
var text = value;
|
||||
var bangIndex = text.IndexOf('!');
|
||||
if (bangIndex > 0)
|
||||
{
|
||||
text = text[..bangIndex];
|
||||
}
|
||||
|
||||
if (text.Contains('_'))
|
||||
{
|
||||
text = text.Split('_')[0];
|
||||
}
|
||||
|
||||
if (text.Contains('.'))
|
||||
{
|
||||
text = text.Split('.', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? text;
|
||||
}
|
||||
|
||||
return text.Replace('_', ' ').Replace('-', ' ').Trim();
|
||||
}
|
||||
|
||||
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).ConfigureAwait(false);
|
||||
return taskObject.GetType().GetProperty("Result", BindingFlags.Public | BindingFlags.Instance)?.GetValue(taskObject);
|
||||
}
|
||||
|
||||
private static Type? ResolveWinRtOperationResultType(Type operationType)
|
||||
{
|
||||
if (operationType.IsGenericType && operationType.GetGenericArguments().Length == 1)
|
||||
{
|
||||
return operationType.GetGenericArguments()[0];
|
||||
}
|
||||
|
||||
foreach (var iface in operationType.GetInterfaces())
|
||||
{
|
||||
if (iface.IsGenericType &&
|
||||
string.Equals(iface.GetGenericTypeDefinition().FullName, "Windows.Foundation.IAsyncOperation`1", StringComparison.Ordinal))
|
||||
{
|
||||
return iface.GetGenericArguments()[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MethodInfo? ResolveAsTaskGenericMethod()
|
||||
{
|
||||
var type = Type.GetType("System.WindowsRuntimeSystemExtensions, System.Runtime.WindowsRuntime", throwOnError: false);
|
||||
return type?
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(method => method.Name == "AsTask" && method.IsGenericMethodDefinition && method.GetParameters().Length == 1);
|
||||
}
|
||||
|
||||
private static MethodInfo? ResolveAsStreamForReadMethod()
|
||||
{
|
||||
var type = Type.GetType("System.IO.WindowsRuntimeStreamExtensions, System.Runtime.WindowsRuntime", throwOnError: false);
|
||||
return type?
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(method => method.Name == "AsStreamForRead" && method.GetParameters().Length == 1);
|
||||
}
|
||||
|
||||
private static Type? ResolveWinRtType(string typeName)
|
||||
{
|
||||
return Type.GetType($"{typeName}, Windows, ContentType=WindowsRuntime", throwOnError: false);
|
||||
}
|
||||
|
||||
private static object? InvokeMethod(object? target, string methodName, object?[]? parameters)
|
||||
{
|
||||
return target?.GetType().GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance)?.Invoke(target, parameters);
|
||||
}
|
||||
|
||||
private static object? GetPropertyValue(object? target, string propertyName)
|
||||
{
|
||||
return target switch
|
||||
{
|
||||
null => null,
|
||||
Type type => type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Static)?.GetValue(null),
|
||||
_ => target.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)?.GetValue(target)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ReadStringProperty(object? target, string propertyName)
|
||||
{
|
||||
return GetPropertyValue(target, propertyName)?.ToString()?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static uint ReadUIntProperty(object? target, string propertyName)
|
||||
{
|
||||
var value = GetPropertyValue(target, propertyName);
|
||||
try
|
||||
{
|
||||
return Convert.ToUInt32(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadDateTimeOffsetProperty(object? target, string propertyName)
|
||||
{
|
||||
var value = GetPropertyValue(target, propertyName);
|
||||
if (value is DateTimeOffset dateTimeOffset)
|
||||
{
|
||||
return dateTimeOffset;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try
|
||||
{
|
||||
_pollTask?.Wait(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private const int AppmodelErrorNoPackage = 15700;
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetCurrentPackageFullName(ref int packageFullNameLength, StringBuilder? packageFullName);
|
||||
}
|
||||
@@ -18,10 +18,10 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase
|
||||
|
||||
MaxDisplayCountOptions = new ObservableCollection<SelectionOption>
|
||||
{
|
||||
new("20", "20条"),
|
||||
new("50", "50条"),
|
||||
new("100", "100条"),
|
||||
new("200", "200条")
|
||||
new("20", "20 条"),
|
||||
new("50", "50 条"),
|
||||
new("100", "100 条"),
|
||||
new("200", "200 条")
|
||||
};
|
||||
|
||||
SortOrderOptions = new ObservableCollection<SelectionOption>
|
||||
@@ -33,7 +33,7 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase
|
||||
|
||||
TimeFormatOptions = new ObservableCollection<SelectionOption>
|
||||
{
|
||||
new("Relative", "相对时间(如:5分钟前)"),
|
||||
new("Relative", "相对时间(如:5 分钟前)"),
|
||||
new("Absolute", "绝对时间(如:14:30)")
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase
|
||||
|
||||
var countValue = snapshot.NotificationBoxMaxDisplayCount.ToString();
|
||||
SelectedMaxDisplayCount = MaxDisplayCountOptions.FirstOrDefault(o => o.Value == countValue)
|
||||
?? MaxDisplayCountOptions[1]; // 默认50
|
||||
?? MaxDisplayCountOptions[1];
|
||||
|
||||
SelectedSortOrder = SortOrderOptions.FirstOrDefault(o => o.Value == snapshot.NotificationBoxSortOrder)
|
||||
?? SortOrderOptions[0];
|
||||
@@ -78,7 +78,6 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase
|
||||
snapshot.NotificationBoxShowClearButton = ShowClearButton;
|
||||
|
||||
_context.ComponentSettingsAccessor.SaveSnapshot(snapshot);
|
||||
|
||||
_context.HostContext.RequestRefresh();
|
||||
}
|
||||
|
||||
@@ -98,7 +97,7 @@ public sealed partial class NotificationBoxEditorViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _showAppIcon = true;
|
||||
[ObservableProperty] private bool _showTimestamp = true;
|
||||
[ObservableProperty] private SelectionOption? _selectedTimeFormat;
|
||||
[ObservableProperty] private bool _groupByApp = false;
|
||||
[ObservableProperty] private bool _groupByApp;
|
||||
[ObservableProperty] private bool _showClearButton = true;
|
||||
|
||||
public ObservableCollection<SelectionOption> MaxDisplayCountOptions { get; }
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
|
||||
Durations = CreateDurationOptions();
|
||||
TestPositions = CreatePositionOptions();
|
||||
TestSeverities = CreateSeverityOptions();
|
||||
LinuxCaptureModes = CreateLinuxCaptureModeOptions();
|
||||
RefreshLocalizedText();
|
||||
|
||||
LoadSettings();
|
||||
@@ -45,6 +46,11 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
|
||||
IsHoverPauseEnabled = snapshot.NotificationHoverPauseEnabled;
|
||||
IsClickCloseEnabled = snapshot.NotificationClickCloseEnabled;
|
||||
MaxNotificationsPerPosition = snapshot.NotificationMaxPerPosition;
|
||||
IsNotificationBoxEnabled = snapshot.NotificationBoxEnabled;
|
||||
IsNotificationBoxPrivacyMode = snapshot.NotificationBoxPrivacyMode;
|
||||
SelectedLinuxCaptureMode = LinuxCaptureModes.FirstOrDefault(o =>
|
||||
string.Equals(o.Value, snapshot.NotificationBoxLinuxCaptureMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? LinuxCaptureModes[0];
|
||||
|
||||
SelectedPosition = Positions.FirstOrDefault(p =>
|
||||
string.Equals(p.Value, snapshot.NotificationDefaultPosition, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -69,6 +75,9 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
|
||||
snapshot.NotificationHoverPauseEnabled = IsHoverPauseEnabled;
|
||||
snapshot.NotificationClickCloseEnabled = IsClickCloseEnabled;
|
||||
snapshot.NotificationMaxPerPosition = MaxNotificationsPerPosition;
|
||||
snapshot.NotificationBoxEnabled = IsNotificationBoxEnabled;
|
||||
snapshot.NotificationBoxPrivacyMode = IsNotificationBoxPrivacyMode;
|
||||
snapshot.NotificationBoxLinuxCaptureMode = SelectedLinuxCaptureMode?.Value ?? "ProxyDaemon";
|
||||
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
@@ -80,7 +89,10 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
|
||||
nameof(AppSettingsSnapshot.NotificationDurationSeconds),
|
||||
nameof(AppSettingsSnapshot.NotificationHoverPauseEnabled),
|
||||
nameof(AppSettingsSnapshot.NotificationClickCloseEnabled),
|
||||
nameof(AppSettingsSnapshot.NotificationMaxPerPosition)
|
||||
nameof(AppSettingsSnapshot.NotificationMaxPerPosition),
|
||||
nameof(AppSettingsSnapshot.NotificationBoxEnabled),
|
||||
nameof(AppSettingsSnapshot.NotificationBoxPrivacyMode),
|
||||
nameof(AppSettingsSnapshot.NotificationBoxLinuxCaptureMode)
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -121,6 +133,15 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
|
||||
];
|
||||
}
|
||||
|
||||
private ObservableCollection<SelectionOption> CreateLinuxCaptureModeOptions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("ProxyDaemon", "代理守护进程"),
|
||||
new SelectionOption("PassiveMonitor", "旁路监听")
|
||||
];
|
||||
}
|
||||
|
||||
private void RefreshLocalizedText()
|
||||
{
|
||||
NotificationHeader = L("settings.notifications.section_header", "Notifications");
|
||||
@@ -133,6 +154,13 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
|
||||
ClickCloseDescription = L("settings.notifications.click_close_desc", "Dismiss when clicked.");
|
||||
MaxNotificationsHeader = L("settings.notifications.max_header", "Max per position");
|
||||
MaxNotificationsDescription = L("settings.notifications.max_desc", "Maximum notifications per corner or edge.");
|
||||
NotificationBoxHeader = L("settings.notifications.box_header", "Message box");
|
||||
NotificationBoxEnabledHeader = L("settings.notifications.box_enable_header", "Collect system notifications");
|
||||
NotificationBoxEnabledDescription = L("settings.notifications.box_enable_desc", "Aggregate OS notifications in the desktop message box.");
|
||||
NotificationBoxPrivacyHeader = L("settings.notifications.box_privacy_header", "Privacy mode");
|
||||
NotificationBoxPrivacyDescription = L("settings.notifications.box_privacy_desc", "Hide notification details until you open the box.");
|
||||
LinuxCaptureModeHeader = L("settings.notifications.linux_capture_header", "Linux capture mode");
|
||||
LinuxCaptureModeDescription = L("settings.notifications.linux_capture_desc", "Proxy mode is more reliable; passive mode is best effort.");
|
||||
TestHeader = L("settings.notifications.test_header", "Test");
|
||||
TestNotificationHeader = L("settings.notifications.test_notification_header", "Test notification");
|
||||
TestNotificationDescription = L("settings.notifications.test_notification_desc", "Send a sample notification.");
|
||||
@@ -173,6 +201,20 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
|
||||
|
||||
[ObservableProperty] private string _maxNotificationsDescription = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _notificationBoxHeader = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _notificationBoxEnabledHeader = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _notificationBoxEnabledDescription = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _notificationBoxPrivacyHeader = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _notificationBoxPrivacyDescription = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _linuxCaptureModeHeader = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _linuxCaptureModeDescription = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _testHeader = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _testNotificationHeader = string.Empty;
|
||||
@@ -187,6 +229,10 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
|
||||
|
||||
[ObservableProperty] private int _maxNotificationsPerPosition = 5;
|
||||
|
||||
[ObservableProperty] private bool _isNotificationBoxEnabled = true;
|
||||
|
||||
[ObservableProperty] private bool _isNotificationBoxPrivacyMode;
|
||||
|
||||
[ObservableProperty] private SelectionOption? _selectedPosition;
|
||||
|
||||
[ObservableProperty] private SelectionOption? _selectedDuration;
|
||||
@@ -195,6 +241,8 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
|
||||
|
||||
[ObservableProperty] private SelectionOption? _selectedTestSeverity;
|
||||
|
||||
[ObservableProperty] private SelectionOption? _selectedLinuxCaptureMode;
|
||||
|
||||
[ObservableProperty] private int _testDurationSeconds = 4;
|
||||
|
||||
public ObservableCollection<SelectionOption> Positions { get; }
|
||||
@@ -202,6 +250,8 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
|
||||
public ObservableCollection<SelectionOption> TestPositions { get; }
|
||||
public ObservableCollection<SelectionOption> TestSeverities { get; }
|
||||
|
||||
public ObservableCollection<SelectionOption> LinuxCaptureModes { get; }
|
||||
|
||||
partial void OnIsNotificationEnabledChanged(bool value) => SaveSettings();
|
||||
|
||||
partial void OnIsHoverPauseEnabledChanged(bool value) => SaveSettings();
|
||||
@@ -210,10 +260,16 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase
|
||||
|
||||
partial void OnMaxNotificationsPerPositionChanged(int value) => SaveSettings();
|
||||
|
||||
partial void OnIsNotificationBoxEnabledChanged(bool value) => SaveSettings();
|
||||
|
||||
partial void OnIsNotificationBoxPrivacyModeChanged(bool value) => SaveSettings();
|
||||
|
||||
partial void OnSelectedPositionChanged(SelectionOption? value) => SaveSettings();
|
||||
|
||||
partial void OnSelectedDurationChanged(SelectionOption? value) => SaveSettings();
|
||||
|
||||
partial void OnSelectedLinuxCaptureModeChanged(SelectionOption? value) => SaveSettings();
|
||||
|
||||
[RelayCommand]
|
||||
private void SendTest()
|
||||
{
|
||||
|
||||
@@ -236,6 +236,7 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
|
||||
Languages = CreateLanguageOptions();
|
||||
RenderModes = CreateRenderModeOptions();
|
||||
MultiInstanceLaunchBehaviors = CreateMultiInstanceLaunchBehaviorOptions();
|
||||
TimeZones = CreateTimeZoneOptions();
|
||||
RefreshLocalizedText();
|
||||
|
||||
@@ -252,6 +253,10 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
SelectedRenderMode = RenderModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? RenderModes[0];
|
||||
SelectedMultiInstanceLaunchBehavior = MultiInstanceLaunchBehaviors.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, appSnapshot.MultiInstanceLaunchBehavior.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
?? MultiInstanceLaunchBehaviors.First(option =>
|
||||
string.Equals(option.Value, MultiInstanceLaunchBehavior.NotifyAndOpenDesktop.ToString(), StringComparison.OrdinalIgnoreCase));
|
||||
ApplyTransitionPreferences(appSnapshot.EnableFadeTransition, appSnapshot.EnableSlideTransition);
|
||||
ShowInTaskbar = appSnapshot.ShowInTaskbar;
|
||||
_isInitializing = false;
|
||||
@@ -297,6 +302,15 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
{
|
||||
ShowInTaskbar = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
|
||||
}
|
||||
|
||||
if (changedKeys.Contains(nameof(AppSettingsSnapshot.MultiInstanceLaunchBehavior)))
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
SelectedMultiInstanceLaunchBehavior = MultiInstanceLaunchBehaviors.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, snapshot.MultiInstanceLaunchBehavior.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
?? MultiInstanceLaunchBehaviors.First(option =>
|
||||
string.Equals(option.Value, MultiInstanceLaunchBehavior.NotifyAndOpenDesktop.ToString(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
public event Action? RestartRequested;
|
||||
@@ -305,6 +319,8 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
|
||||
public IReadOnlyList<SelectionOption> RenderModes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> MultiInstanceLaunchBehaviors { get; }
|
||||
|
||||
public IReadOnlyList<TimeZoneOption> TimeZones { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -316,6 +332,10 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedRenderMode = new(AppRenderingModeHelper.Default, "Default");
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedMultiInstanceLaunchBehavior =
|
||||
new(MultiInstanceLaunchBehavior.NotifyAndOpenDesktop.ToString(), "Notify and open desktop");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _enableFadeTransition = true;
|
||||
|
||||
@@ -340,6 +360,12 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
[ObservableProperty]
|
||||
private string _showInTaskbarDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _multiInstanceLaunchBehaviorHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _multiInstanceLaunchBehaviorDescription = string.Empty;
|
||||
|
||||
public bool IsSlideTransitionAvailable => System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows);
|
||||
|
||||
public bool IsFadeTransitionToggleEnabled => !EnableSlideTransition;
|
||||
@@ -447,6 +473,21 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedMultiInstanceLaunchBehaviorChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<MultiInstanceLaunchBehavior>(value.Value, ignoreCase: true, out var behavior))
|
||||
{
|
||||
behavior = MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
|
||||
}
|
||||
|
||||
SaveField(nameof(AppSettingsSnapshot.MultiInstanceLaunchBehavior), behavior);
|
||||
}
|
||||
|
||||
partial void OnEnableSlideTransitionChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
@@ -537,6 +578,25 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateMultiInstanceLaunchBehaviorOptions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption(
|
||||
MultiInstanceLaunchBehavior.RestartApp.ToString(),
|
||||
L("settings.general.multi_instance_behavior.restart", "Restart app")),
|
||||
new SelectionOption(
|
||||
MultiInstanceLaunchBehavior.OpenDesktopSilently.ToString(),
|
||||
L("settings.general.multi_instance_behavior.open_silently", "Open desktop without prompt")),
|
||||
new SelectionOption(
|
||||
MultiInstanceLaunchBehavior.PromptOnly.ToString(),
|
||||
L("settings.general.multi_instance_behavior.prompt_only", "Show prompt only")),
|
||||
new SelectionOption(
|
||||
MultiInstanceLaunchBehavior.NotifyAndOpenDesktop.ToString(),
|
||||
L("settings.general.multi_instance_behavior.notify_and_open", "Notify and open desktop"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<TimeZoneOption> CreateTimeZoneOptions()
|
||||
{
|
||||
return _timeZoneService
|
||||
@@ -576,6 +636,12 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
ShowInTaskbarDescription = L(
|
||||
"settings.general.show_main_window_taskbar_desc",
|
||||
"Keep the main desktop host window visible in the taskbar. The independent settings window always has its own taskbar entry.");
|
||||
MultiInstanceLaunchBehaviorHeader = L(
|
||||
"settings.general.multi_instance_behavior_header",
|
||||
"When opening the app again");
|
||||
MultiInstanceLaunchBehaviorDescription = L(
|
||||
"settings.general.multi_instance_behavior_desc",
|
||||
"Choose how Launcher handles repeated launches while LanMountain Desktop is already running.");
|
||||
}
|
||||
|
||||
private void RefreshPreview()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
@@ -14,16 +16,22 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly UpdateOrchestrator _orchestrator;
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly LocalizationService _localizationService;
|
||||
private readonly string _languageCode;
|
||||
private bool _disposed;
|
||||
|
||||
public UpdateSettingsViewModel(UpdateOrchestrator orchestrator, ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_localizationService = new LocalizationService();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||
|
||||
CurrentPhase = _orchestrator.CurrentPhase;
|
||||
CurrentVersionText = _settingsFacade.ApplicationInfo.GetAppVersionText();
|
||||
RefreshLocalizedText();
|
||||
LoadPreferenceState();
|
||||
StatusMessage = GetPhaseStatusText(CurrentPhase);
|
||||
|
||||
_orchestrator.PhaseChanged += OnOrchestratorPhaseChanged;
|
||||
_orchestrator.ProgressChanged += OnOrchestratorProgressChanged;
|
||||
@@ -34,10 +42,47 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
[ObservableProperty] private double _progressFraction;
|
||||
[ObservableProperty] private string _progressDetail = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _pageTitle = string.Empty;
|
||||
[ObservableProperty] private string _pageDescription = string.Empty;
|
||||
[ObservableProperty] private string _statusSectionHeader = string.Empty;
|
||||
[ObservableProperty] private string _checkCardTitle = string.Empty;
|
||||
[ObservableProperty] private string _statusCardTitle = string.Empty;
|
||||
[ObservableProperty] private string _statusCardDescription = string.Empty;
|
||||
[ObservableProperty] private string _releaseFactsTitle = string.Empty;
|
||||
[ObservableProperty] private string _releaseFactsDescription = string.Empty;
|
||||
[ObservableProperty] private string _progressTitle = string.Empty;
|
||||
[ObservableProperty] private string _progressDescription = string.Empty;
|
||||
[ObservableProperty] private string _actionsTitle = string.Empty;
|
||||
[ObservableProperty] private string _actionsDescription = string.Empty;
|
||||
[ObservableProperty] private string _preferencesTitle = string.Empty;
|
||||
[ObservableProperty] private string _preferencesDescription = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _currentVersionLabel = string.Empty;
|
||||
[ObservableProperty] private string _latestVersionLabel = string.Empty;
|
||||
[ObservableProperty] private string _publishedAtLabel = string.Empty;
|
||||
[ObservableProperty] private string _lastCheckedLabel = string.Empty;
|
||||
[ObservableProperty] private string _updateTypeLabel = string.Empty;
|
||||
[ObservableProperty] private string _channelLabel = string.Empty;
|
||||
[ObservableProperty] private string _sourceLabel = string.Empty;
|
||||
[ObservableProperty] private string _modeLabel = string.Empty;
|
||||
[ObservableProperty] private string _downloadThreadsLabel = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _updateAvailableBadgeText = string.Empty;
|
||||
[ObservableProperty] private string _pausedBadgeText = string.Empty;
|
||||
[ObservableProperty] private string _pausedHintText = string.Empty;
|
||||
[ObservableProperty] private string _lastCheckedText = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _checkButtonText = string.Empty;
|
||||
[ObservableProperty] private string _downloadButtonText = string.Empty;
|
||||
[ObservableProperty] private string _installButtonText = string.Empty;
|
||||
[ObservableProperty] private string _pauseButtonText = string.Empty;
|
||||
[ObservableProperty] private string _resumeButtonText = string.Empty;
|
||||
[ObservableProperty] private string _rollbackButtonText = string.Empty;
|
||||
[ObservableProperty] private string _cancelButtonText = string.Empty;
|
||||
|
||||
[ObservableProperty] private string _currentVersionText = string.Empty;
|
||||
[ObservableProperty] private string _latestVersionText = string.Empty;
|
||||
[ObservableProperty] private string _publishedAtText = string.Empty;
|
||||
[ObservableProperty] private string _lastCheckedText = string.Empty;
|
||||
[ObservableProperty] private string _updateTypeText = string.Empty;
|
||||
[ObservableProperty] private bool _isUpdateAvailable;
|
||||
[ObservableProperty] private bool _isDeltaUpdate;
|
||||
@@ -47,6 +92,14 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
[ObservableProperty] private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
||||
[ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads;
|
||||
|
||||
[ObservableProperty] private SelectionOption? _selectedChannel;
|
||||
[ObservableProperty] private SelectionOption? _selectedSource;
|
||||
[ObservableProperty] private SelectionOption? _selectedMode;
|
||||
|
||||
public IReadOnlyList<SelectionOption> ChannelOptions { get; private set; } = [];
|
||||
public IReadOnlyList<SelectionOption> SourceOptions { get; private set; } = [];
|
||||
public IReadOnlyList<SelectionOption> ModeOptions { get; private set; } = [];
|
||||
|
||||
public bool IsBusy => CurrentPhase.IsBusy();
|
||||
public bool IsPaused => CurrentPhase.IsPaused();
|
||||
public bool CanCheck => CurrentPhase.CanCheck();
|
||||
@@ -56,14 +109,12 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
public bool CanPause => CurrentPhase.CanPause();
|
||||
public bool CanResume => CurrentPhase.CanResume();
|
||||
public bool CanCancel => CurrentPhase.CanCancel();
|
||||
public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.PausedDownloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack;
|
||||
public string PhaseText => CurrentPhase switch
|
||||
{
|
||||
UpdatePhase.PausedDownloading => "Paused (Download)",
|
||||
UpdatePhase.PausedInstalling => "Paused (Install)",
|
||||
UpdatePhase.Recovering => "Recovering Install",
|
||||
_ => CurrentPhase.ToString()
|
||||
};
|
||||
public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.PausedDownloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack or UpdatePhase.Recovering;
|
||||
public bool IsProgressSectionVisible => IsBusy || IsProgressVisible || IsPaused;
|
||||
public string PhaseText => GetPhaseText(CurrentPhase);
|
||||
public string LatestVersionDisplayText => string.IsNullOrEmpty(LatestVersionText)
|
||||
? L("settings.update.latest_version_none", "Up to date")
|
||||
: LatestVersionText;
|
||||
|
||||
partial void OnCurrentPhaseChanged(UpdatePhase value)
|
||||
{
|
||||
@@ -77,6 +128,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
OnPropertyChanged(nameof(CanResume));
|
||||
OnPropertyChanged(nameof(CanCancel));
|
||||
OnPropertyChanged(nameof(IsProgressVisible));
|
||||
OnPropertyChanged(nameof(IsProgressSectionVisible));
|
||||
OnPropertyChanged(nameof(PhaseText));
|
||||
CheckCommand.NotifyCanExecuteChanged();
|
||||
DownloadCommand.NotifyCanExecuteChanged();
|
||||
@@ -102,6 +154,30 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
SavePreferenceState();
|
||||
}
|
||||
|
||||
partial void OnSelectedChannelChanged(SelectionOption? value)
|
||||
{
|
||||
if (value is not null)
|
||||
{
|
||||
SelectedUpdateChannelValue = value.Value;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedSourceChanged(SelectionOption? value)
|
||||
{
|
||||
if (value is not null)
|
||||
{
|
||||
SelectedUpdateSourceValue = value.Value;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedModeChanged(SelectionOption? value)
|
||||
{
|
||||
if (value is not null)
|
||||
{
|
||||
SelectedUpdateModeValue = value.Value;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnDownloadThreadsSliderValueChanged(double value)
|
||||
{
|
||||
SavePreferenceState();
|
||||
@@ -110,15 +186,23 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand(CanExecute = nameof(CanCheck))]
|
||||
private async Task CheckAsync()
|
||||
{
|
||||
StatusMessage = GetCheckingStatusText();
|
||||
var report = await _orchestrator.CheckAsync(CancellationToken.None);
|
||||
LastCheckedText = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.last_checked_format", "Last checked: {0}"),
|
||||
DateTimeOffset.Now.ToLocalTime().ToString("g", CultureInfo.CurrentCulture));
|
||||
|
||||
if (report.IsUpdateAvailable)
|
||||
{
|
||||
IsUpdateAvailable = true;
|
||||
LatestVersionText = report.LatestVersion ?? string.Empty;
|
||||
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g") ?? string.Empty;
|
||||
UpdateTypeText = report.PayloadKind?.ToString() ?? string.Empty;
|
||||
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? string.Empty;
|
||||
UpdateTypeText = GetUpdateTypeText(report.PayloadKind);
|
||||
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
|
||||
StatusMessage = $"New version {report.LatestVersion} is available.";
|
||||
StatusMessage = report.LatestVersion is null
|
||||
? GetUpdateAvailableStatusText(string.Empty)
|
||||
: string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Click Download and Install."), report.LatestVersion);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -127,71 +211,75 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
PublishedAtText = string.Empty;
|
||||
UpdateTypeText = string.Empty;
|
||||
IsDeltaUpdate = false;
|
||||
StatusMessage = report.ErrorMessage ?? "You are up to date.";
|
||||
StatusMessage = string.IsNullOrWhiteSpace(report.ErrorMessage)
|
||||
? GetUpToDateStatusText()
|
||||
: report.ErrorMessage;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(LatestVersionDisplayText));
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanDownload))]
|
||||
private async Task DownloadAsync()
|
||||
{
|
||||
StatusMessage = "Downloading update...";
|
||||
StatusMessage = GetDownloadingStatusText();
|
||||
var result = await _orchestrator.DownloadAsync(CancellationToken.None);
|
||||
if (result.Success)
|
||||
{
|
||||
StatusMessage = "Download complete. Ready to install.";
|
||||
StatusMessage = GetDownloadCompleteStatusText();
|
||||
}
|
||||
else if (result.ErrorMessage is not null && result.ErrorMessage.Contains("stale or invalid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
StatusMessage = "Install resume state is invalid. Cancel and redownload, then retry.";
|
||||
StatusMessage = GetResumeStateInvalidStatusText();
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = result.ErrorMessage ?? "Download failed.";
|
||||
StatusMessage = result.ErrorMessage ?? GetDownloadFailedStatusText();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanInstall))]
|
||||
private async Task InstallAsync()
|
||||
{
|
||||
StatusMessage = "Installing update...";
|
||||
StatusMessage = GetInstallingStatusText();
|
||||
var result = await _orchestrator.InstallAsync(CancellationToken.None);
|
||||
if (result.Success)
|
||||
{
|
||||
StatusMessage = "Update installed successfully.";
|
||||
StatusMessage = GetInstallSuccessStatusText();
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = result.ErrorMessage ?? result.ErrorCode ?? "Install failed.";
|
||||
StatusMessage = result.ErrorMessage ?? result.ErrorCode ?? GetInstallFailedStatusText();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRollback))]
|
||||
private async Task RollbackAsync()
|
||||
{
|
||||
StatusMessage = "Rolling back...";
|
||||
StatusMessage = GetRollingBackStatusText();
|
||||
await _orchestrator.RollbackAsync(CancellationToken.None);
|
||||
StatusMessage = "Rollback complete.";
|
||||
StatusMessage = GetRollbackCompleteStatusText();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanPause))]
|
||||
private async Task PauseAsync()
|
||||
{
|
||||
await _orchestrator.PauseAsync();
|
||||
StatusMessage = "Update paused.";
|
||||
StatusMessage = GetPausedStatusText();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanResume))]
|
||||
private async Task ResumeAsync()
|
||||
{
|
||||
StatusMessage = "Resuming update...";
|
||||
StatusMessage = GetResumingStatusText();
|
||||
var result = await _orchestrator.ResumeAsync(CancellationToken.None);
|
||||
if (result.Success)
|
||||
{
|
||||
StatusMessage = "Download complete. Ready to install.";
|
||||
StatusMessage = GetResumeCompleteStatusText();
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = result.ErrorMessage ?? "Resume failed.";
|
||||
StatusMessage = result.ErrorMessage ?? GetResumeFailedStatusText();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +287,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
private async Task CancelAsync()
|
||||
{
|
||||
await _orchestrator.CancelAsync();
|
||||
StatusMessage = "Update canceled.";
|
||||
StatusMessage = GetCancelStatusText();
|
||||
ProgressDetail = string.Empty;
|
||||
ProgressFraction = 0;
|
||||
}
|
||||
@@ -212,21 +300,31 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
private void OnOrchestratorProgressChanged(UpdateProgressReport report)
|
||||
{
|
||||
ProgressFraction = report.ProgressFraction;
|
||||
StatusMessage = report.Message;
|
||||
|
||||
if (report.DownloadDetail is not null)
|
||||
{
|
||||
ProgressDetail = $"{report.DownloadDetail.CurrentFile} ({report.DownloadDetail.OverallPercent}%)";
|
||||
StatusMessage = GetDownloadingStatusText();
|
||||
ProgressDetail = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.progress_download_detail_format", "{0} ({1}%)"),
|
||||
report.DownloadDetail.CurrentFile,
|
||||
report.DownloadDetail.OverallPercent);
|
||||
}
|
||||
else if (report.InstallDetail is not null)
|
||||
{
|
||||
StatusMessage = GetInstallingStatusText();
|
||||
ProgressDetail = report.InstallDetail.CurrentFile ?? report.InstallDetail.Message;
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = string.IsNullOrWhiteSpace(report.Message)
|
||||
? GetPhaseStatusText(CurrentPhase)
|
||||
: report.Message;
|
||||
ProgressDetail = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void LoadPreferenceState()
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
@@ -234,6 +332,101 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
SelectedUpdateSourceValue = state.UpdateDownloadSource;
|
||||
SelectedUpdateModeValue = state.UpdateMode;
|
||||
DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads);
|
||||
|
||||
SyncComboBoxSelections();
|
||||
}
|
||||
|
||||
private void SyncComboBoxSelections()
|
||||
{
|
||||
SelectedChannel = ChannelOptions.FirstOrDefault(o => o.Value == SelectedUpdateChannelValue)
|
||||
?? ChannelOptions.FirstOrDefault();
|
||||
SelectedSource = SourceOptions.FirstOrDefault(o => o.Value == SelectedUpdateSourceValue)
|
||||
?? SourceOptions.FirstOrDefault();
|
||||
SelectedMode = ModeOptions.FirstOrDefault(o => o.Value == SelectedUpdateModeValue)
|
||||
?? ModeOptions.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void RefreshLocalizedText()
|
||||
{
|
||||
PageTitle = L("settings.update.title", "Update");
|
||||
PageDescription = L("settings.update.description", "Check releases, choose the update channel and download source, and control how updates are installed.");
|
||||
StatusSectionHeader = L("settings.update.status_section_header", "Update Status");
|
||||
CheckCardTitle = L("settings.update.check_card_title", "Check for Updates");
|
||||
StatusCardTitle = L("settings.update.status_card_title", "Update Status");
|
||||
StatusCardDescription = L("settings.update.status_card_description", "Check for updates, review release details, and continue with download or installation when a new version is available.");
|
||||
ReleaseFactsTitle = L("settings.update.release_facts_title", "Release Facts");
|
||||
ReleaseFactsDescription = L("settings.update.release_facts_description", "Keep the current version, published release, and update type visible without collapsing the layout while states change.");
|
||||
ProgressTitle = L("settings.update.progress_title", "Progress");
|
||||
ProgressDescription = L("settings.update.progress_description", "Watch download, installation, verification, and recovery progress here.");
|
||||
ActionsTitle = L("settings.update.actions_title", "Actions");
|
||||
ActionsDescription = L("settings.update.actions_description", "The buttons below stay in place while the update phase changes, so the page does not jump around.");
|
||||
PreferencesTitle = L("settings.update.preferences_title", "Update Preferences");
|
||||
PreferencesDescription = L("settings.update.preferences_description", "Choose the release channel, installer download source, installation behavior, and download parallelism.");
|
||||
|
||||
CurrentVersionLabel = L("settings.update.current_version_label", "Current Version");
|
||||
LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release");
|
||||
PublishedAtLabel = L("settings.update.published_at_label", "Published At");
|
||||
LastCheckedLabel = L("settings.update.last_checked_label", "Last Checked");
|
||||
UpdateTypeLabel = L("settings.update.update_type_label", "Update Type");
|
||||
ChannelLabel = L("settings.update.channel_label", "Update Channel");
|
||||
SourceLabel = L("settings.update.source_label", "Download Source");
|
||||
ModeLabel = L("settings.update.mode_label", "Update Mode");
|
||||
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
||||
|
||||
UpdateAvailableBadgeText = L("settings.update.badge_available", "Update available");
|
||||
PausedBadgeText = L("settings.update.badge_paused", "Paused");
|
||||
PausedHintText = L("settings.update.paused_hint", "Paused. Resume to continue from the current state.");
|
||||
|
||||
CheckButtonText = L("settings.update.check_button_short", "Check");
|
||||
DownloadButtonText = L("settings.update.download_button_short", "Download");
|
||||
InstallButtonText = L("settings.update.install_button_short", "Install");
|
||||
PauseButtonText = L("settings.update.pause_button_short", "Pause");
|
||||
ResumeButtonText = L("settings.update.resume_button_short", "Resume");
|
||||
RollbackButtonText = L("settings.update.rollback_button_short", "Rollback");
|
||||
CancelButtonText = L("settings.update.cancel_button_short", "Cancel");
|
||||
|
||||
LastCheckedText = L("settings.update.last_checked_none", "Not checked yet.");
|
||||
|
||||
ChannelOptions = CreateChannelOptions();
|
||||
SourceOptions = CreateSourceOptions();
|
||||
ModeOptions = CreateModeOptions();
|
||||
OnPropertyChanged(nameof(ChannelOptions));
|
||||
OnPropertyChanged(nameof(SourceOptions));
|
||||
OnPropertyChanged(nameof(ModeOptions));
|
||||
|
||||
SyncComboBoxSelections();
|
||||
|
||||
OnPropertyChanged(nameof(PhaseText));
|
||||
OnPropertyChanged(nameof(LatestVersionDisplayText));
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateChannelOptions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new(UpdateSettingsValues.ChannelStable, L("settings.update.channel_stable", "Stable")),
|
||||
new(UpdateSettingsValues.ChannelPreview, L("settings.update.channel_preview", "Preview"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateSourceOptions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new(UpdateSettingsValues.DownloadSourcePlonds, L("settings.update.source_plonds", "Plonds CDN")),
|
||||
new(UpdateSettingsValues.DownloadSourceGitHub, L("settings.update.source_github", "GitHub")),
|
||||
new(UpdateSettingsValues.DownloadSourceGhProxy, L("settings.update.source_gh_proxy", "GitHub Proxy"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateModeOptions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new(UpdateSettingsValues.ModeManual, L("settings.update.mode_manual", "Manual")),
|
||||
new(UpdateSettingsValues.ModeDownloadThenConfirm, L("settings.update.mode_confirm", "Download then Confirm")),
|
||||
new(UpdateSettingsValues.ModeSilentOnExit, L("settings.update.mode_silent", "Silent on Exit"))
|
||||
];
|
||||
}
|
||||
|
||||
private void SavePreferenceState()
|
||||
@@ -248,6 +441,116 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
});
|
||||
}
|
||||
|
||||
private string GetPhaseText(UpdatePhase phase)
|
||||
{
|
||||
return phase switch
|
||||
{
|
||||
UpdatePhase.Idle => L("settings.update.phase_idle", "Ready"),
|
||||
UpdatePhase.Checking => L("settings.update.phase_checking", "Checking"),
|
||||
UpdatePhase.Checked => L("settings.update.phase_checked", "Checked"),
|
||||
UpdatePhase.Downloading => L("settings.update.phase_downloading", "Downloading"),
|
||||
UpdatePhase.PausedDownloading => L("settings.update.phase_paused_download", "Paused (Download)"),
|
||||
UpdatePhase.Downloaded => L("settings.update.phase_downloaded", "Downloaded"),
|
||||
UpdatePhase.Installing => L("settings.update.phase_installing", "Installing"),
|
||||
UpdatePhase.PausedInstalling => L("settings.update.phase_paused_install", "Paused (Install)"),
|
||||
UpdatePhase.Installed => L("settings.update.phase_installed", "Installed"),
|
||||
UpdatePhase.Verifying => L("settings.update.phase_verifying", "Verifying"),
|
||||
UpdatePhase.Completed => L("settings.update.phase_completed", "Completed"),
|
||||
UpdatePhase.Failed => L("settings.update.phase_failed", "Failed"),
|
||||
UpdatePhase.Recovering => L("settings.update.phase_recovering", "Recovering"),
|
||||
UpdatePhase.RollingBack => L("settings.update.phase_rolling_back", "Rolling Back"),
|
||||
UpdatePhase.RolledBack => L("settings.update.phase_rolled_back", "Rolled Back"),
|
||||
_ => phase.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private string GetPhaseStatusText(UpdatePhase phase)
|
||||
{
|
||||
return phase switch
|
||||
{
|
||||
UpdatePhase.Checking => GetCheckingStatusText(),
|
||||
UpdatePhase.Downloading => GetDownloadingStatusText(),
|
||||
UpdatePhase.PausedDownloading or UpdatePhase.PausedInstalling => GetPausedStatusText(),
|
||||
UpdatePhase.Installing => GetInstallingStatusText(),
|
||||
UpdatePhase.Recovering => GetRecoveringStatusText(),
|
||||
UpdatePhase.RollingBack => GetRollingBackStatusText(),
|
||||
UpdatePhase.Completed => GetInstallSuccessStatusText(),
|
||||
UpdatePhase.Installed => GetInstallSuccessStatusText(),
|
||||
UpdatePhase.RolledBack => GetRollbackCompleteStatusText(),
|
||||
UpdatePhase.Failed => L("settings.update.status_failed", "The update failed."),
|
||||
_ => GetReadyStatusText()
|
||||
};
|
||||
}
|
||||
|
||||
private string GetReadyStatusText()
|
||||
=> L("settings.update.status_ready", "Ready to check for updates.");
|
||||
|
||||
private string GetCheckingStatusText()
|
||||
=> L("settings.update.status_checking", "Checking GitHub releases...");
|
||||
|
||||
private string GetUpToDateStatusText()
|
||||
=> L("settings.update.status_up_to_date", "You are already on the latest version.");
|
||||
|
||||
private string GetUpdateAvailableStatusText(string version)
|
||||
=> string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Click Download and Install."), version);
|
||||
|
||||
private string GetDownloadingStatusText()
|
||||
=> L("settings.update.status_downloading", "Downloading installer...");
|
||||
|
||||
private string GetDownloadCompleteStatusText()
|
||||
=> L("settings.update.status_launching_installer", "Download complete. Launching installer...");
|
||||
|
||||
private string GetDownloadFailedStatusText()
|
||||
=> L("settings.update.status_download_failed", "Download failed.");
|
||||
|
||||
private string GetResumeStateInvalidStatusText()
|
||||
=> L("settings.update.status_resume_state_invalid", "The resume state is invalid. Cancel and redownload, then try again.");
|
||||
|
||||
private string GetInstallingStatusText()
|
||||
=> L("settings.update.status_installing", "Installing update...");
|
||||
|
||||
private string GetInstallSuccessStatusText()
|
||||
=> L("settings.update.status_installed", "Update installed successfully.");
|
||||
|
||||
private string GetInstallFailedStatusText()
|
||||
=> L("settings.update.status_install_failed", "Install failed.");
|
||||
|
||||
private string GetRollingBackStatusText()
|
||||
=> L("settings.update.status_rolling_back", "Rolling back...");
|
||||
|
||||
private string GetRollbackCompleteStatusText()
|
||||
=> L("settings.update.status_rolled_back", "Rollback complete.");
|
||||
|
||||
private string GetPausedStatusText()
|
||||
=> L("settings.update.status_paused", "Update paused.");
|
||||
|
||||
private string GetResumingStatusText()
|
||||
=> L("settings.update.status_resuming", "Resuming update...");
|
||||
|
||||
private string GetResumeCompleteStatusText()
|
||||
=> L("settings.update.status_resumed", "Resume complete.");
|
||||
|
||||
private string GetResumeFailedStatusText()
|
||||
=> L("settings.update.status_resume_failed", "Resume failed.");
|
||||
|
||||
private string GetRecoveringStatusText()
|
||||
=> L("settings.update.status_recovering", "Recovering installation...");
|
||||
|
||||
private string GetCancelStatusText()
|
||||
=> L("settings.update.status_canceled", "Update canceled.");
|
||||
|
||||
private string GetUpdateTypeText(UpdatePayloadKind? payloadKind)
|
||||
=> payloadKind switch
|
||||
{
|
||||
UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy => L("settings.update.type_delta", "Incremental Update"),
|
||||
UpdatePayloadKind.FullInstaller => L("settings.update.type_full", "Full Installer"),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
private string L(string key, string fallback)
|
||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
|
||||
@@ -10,7 +10,6 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
@@ -445,22 +444,14 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
var snapshot = result.Data;
|
||||
var isNight = snapshot.Current.IsDaylight.HasValue
|
||||
? !snapshot.Current.IsDaylight.Value
|
||||
: _settingsFacade.Theme.Get().IsNightMode;
|
||||
var preview = XiaomiWeatherVisualResolver.Resolve(
|
||||
snapshot.Current.WeatherText,
|
||||
snapshot.Current.WeatherCode,
|
||||
isNight,
|
||||
_languageCode);
|
||||
PreviewIcon = HyperOS3WeatherAssetLoader.LoadImage(preview.PrimaryIconAsset);
|
||||
PreviewIcon = null;
|
||||
PreviewLocation = string.IsNullOrWhiteSpace(snapshot.LocationName)
|
||||
? state.LocationName
|
||||
: snapshot.LocationName!;
|
||||
PreviewTemperature = snapshot.Current.TemperatureC.HasValue
|
||||
? string.Format(CultureInfo.InvariantCulture, "{0:0.#}°C", snapshot.Current.TemperatureC.Value)
|
||||
: "--";
|
||||
PreviewCondition = preview.DisplayText;
|
||||
PreviewCondition = ResolveWeatherDisplayText(snapshot.Current.WeatherText, snapshot.Current.WeatherCode);
|
||||
|
||||
var updatedAt = (snapshot.ObservationTime ?? snapshot.FetchedAt).ToLocalTime();
|
||||
PreviewUpdated = string.Format(
|
||||
@@ -523,18 +514,12 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
UpdateModeVisibility();
|
||||
UpdateCurrentLocationSummary();
|
||||
|
||||
var preview = XiaomiWeatherVisualResolver.Resolve(
|
||||
"Partly cloudy",
|
||||
4,
|
||||
isNight: false,
|
||||
_languageCode);
|
||||
|
||||
SearchStatus = "2 sample locations are shown for design preview.";
|
||||
LocationActionStatus = "Using mocked Windows location support in design mode.";
|
||||
PreviewIcon = HyperOS3WeatherAssetLoader.LoadImage(preview.PrimaryIconAsset);
|
||||
PreviewIcon = null;
|
||||
PreviewLocation = previewLocation.Name;
|
||||
PreviewTemperature = "24 deg C";
|
||||
PreviewCondition = preview.DisplayText;
|
||||
PreviewCondition = ResolveWeatherDisplayText("Partly cloudy", 4);
|
||||
PreviewUpdated = "Updated 09:42";
|
||||
PreviewStatus = "Preview data is mocked for Avalonia design mode.";
|
||||
}
|
||||
@@ -713,6 +698,17 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private string ResolveWeatherDisplayText(string? weatherText, int? weatherCode)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(weatherText))
|
||||
{
|
||||
return weatherText.Trim();
|
||||
}
|
||||
|
||||
return XiaomiWeatherCodeMapper.ResolveDisplayText(weatherCode, _languageCode)
|
||||
?? L("settings.weather.preview_unknown", "Unknown");
|
||||
}
|
||||
|
||||
private CultureInfo ResolveCulture()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="14,12">
|
||||
Padding="0">
|
||||
<Grid x:Name="LayoutGrid"
|
||||
RowDefinitions="Auto,*">
|
||||
<Grid x:Name="HeaderGrid"
|
||||
ColumnDefinitions="*,Auto">
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
Margin="16,12,16,8">
|
||||
<StackPanel x:Name="DateGroup"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Top">
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="MonthTextBlock"
|
||||
Text="7"
|
||||
FontWeight="Bold"
|
||||
@@ -27,21 +28,24 @@
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel x:Name="MetaStack"
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock x:Name="WeekdayTextBlock"
|
||||
Text="周一"
|
||||
TextAlignment="Right"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="WeekdayTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="周一"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Border x:Name="ClassCountBadge"
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
Padding="8,3"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMicro}">
|
||||
<TextBlock x:Name="ClassCountTextBlock"
|
||||
Text="0节课"
|
||||
TextAlignment="Right"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1">
|
||||
|
||||
@@ -22,7 +22,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
string Name,
|
||||
string TimeRange,
|
||||
string Detail,
|
||||
bool IsCurrent);
|
||||
bool IsCurrent,
|
||||
TimeSpan StartTime,
|
||||
TimeSpan EndTime,
|
||||
double Progress);
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
@@ -227,18 +230,11 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
for (var i = 0; i < _courseItems.Count; i++)
|
||||
{
|
||||
var item = _courseItems[i];
|
||||
var timeParts = item.TimeRange.Split('-');
|
||||
if (timeParts.Length != 2) continue;
|
||||
|
||||
if (TimeSpan.TryParse(timeParts[0].Trim(), out var startTime) &&
|
||||
TimeSpan.TryParse(timeParts[1].Trim(), out var endTime))
|
||||
var shouldBeCurrent = now.TimeOfDay >= item.StartTime && now.TimeOfDay <= item.EndTime;
|
||||
if (shouldBeCurrent != item.IsCurrent)
|
||||
{
|
||||
var shouldBeCurrent = now.TimeOfDay >= startTime && now.TimeOfDay <= endTime;
|
||||
if (shouldBeCurrent != item.IsCurrent)
|
||||
{
|
||||
needsRender = true;
|
||||
break;
|
||||
}
|
||||
needsRender = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,11 +518,22 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
var subjectName = ResolveSubjectName(snapshot, classInfo.SubjectId);
|
||||
var detail = ResolveSubjectDetail(snapshot, classInfo.SubjectId);
|
||||
var isCurrent = now.TimeOfDay >= slot.StartTime && now.TimeOfDay <= slot.EndTime;
|
||||
var progress = 0.0;
|
||||
if (isCurrent && slot.EndTime > slot.StartTime)
|
||||
{
|
||||
var elapsed = (now.TimeOfDay - slot.StartTime).TotalSeconds;
|
||||
var total = (slot.EndTime - slot.StartTime).TotalSeconds;
|
||||
progress = total > 0 ? Math.Clamp(elapsed / total, 0, 1) : 0;
|
||||
}
|
||||
|
||||
result.Add(new CourseItemViewModel(
|
||||
Name: subjectName,
|
||||
TimeRange: $"{FormatTime(slot.StartTime)}-{FormatTime(slot.EndTime)}",
|
||||
Detail: detail,
|
||||
IsCurrent: isCurrent));
|
||||
IsCurrent: isCurrent,
|
||||
StartTime: slot.StartTime,
|
||||
EndTime: slot.EndTime,
|
||||
Progress: progress));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -674,173 +681,93 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
CourseListPanel.Children.Clear();
|
||||
|
||||
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
|
||||
_componentColorScheme,
|
||||
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
|
||||
|
||||
var scale = ResolveScale();
|
||||
var bulletSize = Math.Clamp(10 * scale, 5, 12);
|
||||
var courseNameSize = Math.Clamp(42 * scale, 14, 42);
|
||||
var secondarySize = Math.Clamp(29 * scale, 10, 28);
|
||||
var lineSpacing = Math.Clamp(4 * scale, 1.5, 8);
|
||||
var itemPadding = new Thickness(
|
||||
Math.Clamp(6 * scale, 3, 10),
|
||||
Math.Clamp(4 * scale, 2, 8),
|
||||
Math.Clamp(4 * scale, 2, 8),
|
||||
Math.Clamp(4 * scale, 2, 8));
|
||||
|
||||
var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821");
|
||||
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
|
||||
var currentBrush = useMonetColor
|
||||
? CreateBrush("#FF4FC3F7")
|
||||
: CreateBrush("#FF4D5A");
|
||||
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
|
||||
var cardRadius = ComponentChromeCornerRadiusHelper.Small();
|
||||
var timeFontSize = Math.Clamp(11 * scale, 8, 14);
|
||||
var courseNameFontSize = Math.Clamp(14 * scale, 10, 18);
|
||||
var detailFontSize = Math.Clamp(11 * scale, 8, 14);
|
||||
var progressFontSize = Math.Clamp(10 * scale, 7, 12);
|
||||
var cardPadding = new Thickness(
|
||||
Math.Clamp(10 * scale, 6, 14),
|
||||
Math.Clamp(8 * scale, 5, 12),
|
||||
Math.Clamp(10 * scale, 6, 14),
|
||||
Math.Clamp(8 * scale, 5, 12));
|
||||
var timeColumnWidth = Math.Clamp(44 * scale, 30, 56);
|
||||
var accentBarWidth = Math.Clamp(3 * scale, 2, 4);
|
||||
var progressBarHeight = Math.Clamp(3 * scale, 2, 4);
|
||||
|
||||
for (var i = 0; i < _courseItems.Count; i++)
|
||||
{
|
||||
var item = _courseItems[i];
|
||||
var itemControls = CreateSingleItemControl(
|
||||
var itemControl = CreateTimelineItemControl(
|
||||
item,
|
||||
scale,
|
||||
bulletSize,
|
||||
courseNameSize,
|
||||
secondarySize,
|
||||
lineSpacing,
|
||||
itemPadding,
|
||||
primaryBrush,
|
||||
secondaryBrush,
|
||||
item.IsCurrent ? currentBrush : normalBulletBrush);
|
||||
|
||||
CourseListPanel.Children.Add(itemControls);
|
||||
cardRadius,
|
||||
timeFontSize,
|
||||
courseNameFontSize,
|
||||
detailFontSize,
|
||||
progressFontSize,
|
||||
cardPadding,
|
||||
timeColumnWidth,
|
||||
accentBarWidth,
|
||||
progressBarHeight);
|
||||
CourseListPanel.Children.Add(itemControl);
|
||||
}
|
||||
}
|
||||
|
||||
private void IncrementalUpdateItems()
|
||||
{
|
||||
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
|
||||
_componentColorScheme,
|
||||
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
|
||||
|
||||
var currentBrush = useMonetColor
|
||||
? CreateBrush("#FF4FC3F7")
|
||||
: CreateBrush("#FF4D5A");
|
||||
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
|
||||
var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821");
|
||||
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
|
||||
|
||||
for (var i = 0; i < _courseItems.Count && i < CourseListPanel.Children.Count; i++)
|
||||
{
|
||||
var item = _courseItems[i];
|
||||
var existingBorder = CourseListPanel.Children[i] as Border;
|
||||
if (existingBorder == null) continue;
|
||||
|
||||
var existingGrid = existingBorder.Child as Grid;
|
||||
if (existingGrid == null || existingGrid.Children.Count < 2) continue;
|
||||
|
||||
var bulletBorder = existingGrid.Children[0] as Border;
|
||||
var textStack = existingGrid.Children[1] as StackPanel;
|
||||
if (bulletBorder == null || textStack == null || textStack.Children.Count < 3) continue;
|
||||
|
||||
var newBulletBrush = item.IsCurrent ? currentBrush : normalBulletBrush;
|
||||
bulletBorder.Background = newBulletBrush;
|
||||
|
||||
var titleText = textStack.Children[0] as TextBlock;
|
||||
var timeText = textStack.Children[1] as TextBlock;
|
||||
var detailText = textStack.Children[2] as TextBlock;
|
||||
|
||||
if (titleText != null)
|
||||
{
|
||||
if (titleText.Text != item.Name)
|
||||
{
|
||||
titleText.Text = item.Name;
|
||||
}
|
||||
titleText.Foreground = primaryBrush;
|
||||
}
|
||||
|
||||
if (timeText != null)
|
||||
{
|
||||
if (timeText.Text != item.TimeRange)
|
||||
{
|
||||
timeText.Text = item.TimeRange;
|
||||
}
|
||||
timeText.Foreground = secondaryBrush;
|
||||
}
|
||||
|
||||
if (detailText != null)
|
||||
{
|
||||
if (detailText.Text != item.Detail)
|
||||
{
|
||||
detailText.Text = item.Detail;
|
||||
}
|
||||
detailText.Foreground = secondaryBrush;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void IncrementalUpdateCurrentCourseHighlight(int currentCourseIndex)
|
||||
{
|
||||
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
|
||||
_componentColorScheme,
|
||||
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
|
||||
|
||||
var currentBrush = useMonetColor
|
||||
? CreateBrush("#FF4FC3F7")
|
||||
: CreateBrush("#FF4D5A");
|
||||
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
|
||||
|
||||
for (var i = 0; i < CourseListPanel.Children.Count; i++)
|
||||
{
|
||||
var border = CourseListPanel.Children[i] as Border;
|
||||
if (border == null) continue;
|
||||
|
||||
var grid = border.Child as Grid;
|
||||
if (grid == null || grid.Children.Count < 2) continue;
|
||||
|
||||
var bulletBorder = grid.Children[0] as Border;
|
||||
if (bulletBorder == null) continue;
|
||||
|
||||
bulletBorder.Background = i == currentCourseIndex ? currentBrush : normalBulletBrush;
|
||||
}
|
||||
}
|
||||
|
||||
private Border CreateSingleItemControl(
|
||||
private Border CreateTimelineItemControl(
|
||||
CourseItemViewModel item,
|
||||
double scale,
|
||||
double bulletSize,
|
||||
double courseNameSize,
|
||||
double secondarySize,
|
||||
double lineSpacing,
|
||||
Thickness itemPadding,
|
||||
IBrush primaryBrush,
|
||||
IBrush secondaryBrush,
|
||||
IBrush bulletBrush)
|
||||
double cardRadius,
|
||||
double timeFontSize,
|
||||
double courseNameFontSize,
|
||||
double detailFontSize,
|
||||
double progressFontSize,
|
||||
Thickness cardPadding,
|
||||
double timeColumnWidth,
|
||||
double accentBarWidth,
|
||||
double progressBarHeight)
|
||||
{
|
||||
var bullet = new Border
|
||||
var subjectBrush = SubjectColorService.ResolveForegroundBrush(item.Name, _isNightVisual);
|
||||
var cardBackground = SubjectColorService.ResolveBackgroundBrush(item.Name, item.IsCurrent);
|
||||
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
|
||||
var timeBrush = CreateBrush(_isNightVisual ? "#6B7280" : "#9AA3B2");
|
||||
var timeEndBrush = CreateBrush(_isNightVisual ? "#4B5563" : "#B8BEC9");
|
||||
|
||||
var startTimeText = new TextBlock
|
||||
{
|
||||
Width = bulletSize,
|
||||
Height = bulletSize,
|
||||
CornerRadius = new CornerRadius(bulletSize * 0.5),
|
||||
Background = bulletBrush,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
|
||||
Margin = new Thickness(0, Math.Clamp(8 * scale, 2, 12), 0, 0)
|
||||
Text = FormatTime(item.StartTime),
|
||||
FontSize = timeFontSize,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = timeBrush,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis
|
||||
};
|
||||
|
||||
var titleText = new TextBlock
|
||||
var endTimeText = new TextBlock
|
||||
{
|
||||
Text = FormatTime(item.EndTime),
|
||||
FontSize = timeFontSize - 1,
|
||||
FontWeight = FontWeight.Normal,
|
||||
Foreground = timeEndBrush,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis
|
||||
};
|
||||
|
||||
var timeColumn = new StackPanel
|
||||
{
|
||||
Spacing = Math.Clamp(2 * scale, 1, 4),
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||
Width = timeColumnWidth,
|
||||
Children = { startTimeText, endTimeText }
|
||||
};
|
||||
|
||||
var courseNameText = new TextBlock
|
||||
{
|
||||
Text = item.Name,
|
||||
FontSize = courseNameSize,
|
||||
FontWeight = ToVariableWeight(Lerp(620, 780, Math.Clamp((scale - 0.60) / 1.2, 0, 1))),
|
||||
Foreground = primaryBrush,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap
|
||||
};
|
||||
|
||||
var timeText = new TextBlock
|
||||
{
|
||||
Text = item.TimeRange,
|
||||
FontSize = secondarySize,
|
||||
FontWeight = ToVariableWeight(Lerp(520, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1))),
|
||||
Foreground = secondaryBrush,
|
||||
FontSize = courseNameFontSize,
|
||||
FontWeight = ToVariableWeight(Lerp(650, 800, Math.Clamp((scale - 0.60) / 1.2, 0, 1))),
|
||||
Foreground = subjectBrush,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap
|
||||
};
|
||||
@@ -848,31 +775,129 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
var detailText = new TextBlock
|
||||
{
|
||||
Text = item.Detail,
|
||||
FontSize = secondarySize,
|
||||
FontWeight = ToVariableWeight(Lerp(500, 640, Math.Clamp((scale - 0.60) / 1.2, 0, 1))),
|
||||
FontSize = detailFontSize,
|
||||
FontWeight = ToVariableWeight(Lerp(450, 550, Math.Clamp((scale - 0.60) / 1.2, 0, 1))),
|
||||
Foreground = secondaryBrush,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap
|
||||
};
|
||||
|
||||
var textStack = new StackPanel
|
||||
var cardContent = new StackPanel
|
||||
{
|
||||
Spacing = lineSpacing,
|
||||
Children = { titleText, timeText, detailText }
|
||||
Spacing = Math.Clamp(2 * scale, 1, 4)
|
||||
};
|
||||
|
||||
cardContent.Children.Add(courseNameText);
|
||||
cardContent.Children.Add(detailText);
|
||||
|
||||
if (item.IsCurrent && item.Progress > 0)
|
||||
{
|
||||
var progressTrack = new Border
|
||||
{
|
||||
Height = progressBarHeight,
|
||||
CornerRadius = new CornerRadius(progressBarHeight * 0.5),
|
||||
Background = CreateBrush(_isNightVisual ? "#1AFFFFFF" : "#0D000000"),
|
||||
ClipToBounds = true,
|
||||
Child = new Border
|
||||
{
|
||||
Height = progressBarHeight,
|
||||
Width = Math.Max(progressBarHeight, Math.Clamp(item.Progress * 100, 0, 100) * 0.01 * 200),
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
|
||||
CornerRadius = new CornerRadius(progressBarHeight * 0.5),
|
||||
Background = subjectBrush
|
||||
}
|
||||
};
|
||||
|
||||
var progressText = new TextBlock
|
||||
{
|
||||
Text = $"{(int)(item.Progress * 100)}%",
|
||||
FontSize = progressFontSize,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = subjectBrush,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right
|
||||
};
|
||||
|
||||
var progressRow = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||
Margin = new Thickness(0, Math.Clamp(2 * scale, 1, 4), 0, 0)
|
||||
};
|
||||
progressRow.Children.Add(progressTrack);
|
||||
progressRow.Children.Add(progressText);
|
||||
Grid.SetColumn(progressText, 1);
|
||||
|
||||
cardContent.Children.Add(progressRow);
|
||||
}
|
||||
|
||||
var cardInner = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions($"{accentBarWidth},*")
|
||||
};
|
||||
|
||||
if (item.IsCurrent)
|
||||
{
|
||||
var accentBar = new Border
|
||||
{
|
||||
Width = accentBarWidth,
|
||||
CornerRadius = new CornerRadius(accentBarWidth * 0.5),
|
||||
Background = subjectBrush,
|
||||
Margin = new Thickness(0, 2, 0, 2)
|
||||
};
|
||||
cardInner.Children.Add(accentBar);
|
||||
|
||||
var contentWrapper = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(Math.Clamp(6 * scale, 3, 8), 0, 0, 0),
|
||||
Spacing = 0
|
||||
};
|
||||
foreach (var child in cardContent.Children.ToList())
|
||||
{
|
||||
cardContent.Children.Remove(child);
|
||||
contentWrapper.Children.Add(child);
|
||||
}
|
||||
cardInner.Children.Add(contentWrapper);
|
||||
Grid.SetColumn(contentWrapper, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
var contentWrapper = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(Math.Clamp(8 * scale, 4, 12), 0, 0, 0),
|
||||
Spacing = 0
|
||||
};
|
||||
foreach (var child in cardContent.Children.ToList())
|
||||
{
|
||||
cardContent.Children.Remove(child);
|
||||
contentWrapper.Children.Add(child);
|
||||
}
|
||||
cardInner.Children.Add(contentWrapper);
|
||||
Grid.SetColumn(contentWrapper, 1);
|
||||
}
|
||||
|
||||
var cardBorder = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(cardRadius),
|
||||
Background = cardBackground,
|
||||
Padding = cardPadding,
|
||||
Child = cardInner
|
||||
};
|
||||
|
||||
var itemGrid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||
ColumnSpacing = Math.Clamp(10 * scale, 4, 14)
|
||||
ColumnDefinitions = new ColumnDefinitions($"{timeColumnWidth},*"),
|
||||
ColumnSpacing = Math.Clamp(6 * scale, 3, 10)
|
||||
};
|
||||
itemGrid.Children.Add(bullet);
|
||||
itemGrid.Children.Add(textStack);
|
||||
Grid.SetColumn(textStack, 1);
|
||||
itemGrid.Children.Add(timeColumn);
|
||||
itemGrid.Children.Add(cardBorder);
|
||||
Grid.SetColumn(cardBorder, 1);
|
||||
|
||||
var itemBorder = new Border
|
||||
{
|
||||
Padding = itemPadding,
|
||||
Padding = new Thickness(
|
||||
Math.Clamp(10 * scale, 6, 14),
|
||||
Math.Clamp(2 * scale, 1, 4),
|
||||
Math.Clamp(10 * scale, 6, 14),
|
||||
Math.Clamp(2 * scale, 1, 4)),
|
||||
Background = Brushes.Transparent,
|
||||
Child = itemGrid
|
||||
};
|
||||
@@ -880,15 +905,88 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
return itemBorder;
|
||||
}
|
||||
|
||||
private int ResolveMaxVisibleItems(double scale)
|
||||
private void IncrementalUpdateItems()
|
||||
{
|
||||
var height = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * 4;
|
||||
var rootVerticalPadding = RootBorder.Padding.Top + RootBorder.Padding.Bottom;
|
||||
var headerEstimatedHeight = Math.Clamp(100 * scale, 54, 140);
|
||||
var itemEstimatedHeight = Math.Clamp(136 * scale, 72, 178);
|
||||
var available = Math.Max(1, height - rootVerticalPadding - headerEstimatedHeight);
|
||||
var count = (int)Math.Floor(available / Math.Max(1, itemEstimatedHeight));
|
||||
return Math.Clamp(count, 1, 6);
|
||||
for (var i = 0; i < _courseItems.Count && i < CourseListPanel.Children.Count; i++)
|
||||
{
|
||||
var item = _courseItems[i];
|
||||
var outerBorder = CourseListPanel.Children[i] as Border;
|
||||
if (outerBorder == null) continue;
|
||||
|
||||
var itemGrid = outerBorder.Child as Grid;
|
||||
if (itemGrid == null || itemGrid.Children.Count < 2) continue;
|
||||
|
||||
var cardBorder = itemGrid.Children[1] as Border;
|
||||
if (cardBorder == null) continue;
|
||||
|
||||
cardBorder.Background = SubjectColorService.ResolveBackgroundBrush(item.Name, item.IsCurrent);
|
||||
|
||||
var cardInner = cardBorder.Child as Grid;
|
||||
if (cardInner == null) continue;
|
||||
|
||||
var contentPanel = cardInner.Children.OfType<StackPanel>().FirstOrDefault();
|
||||
if (contentPanel == null) continue;
|
||||
|
||||
var subjectBrush = SubjectColorService.ResolveForegroundBrush(item.Name, _isNightVisual);
|
||||
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
|
||||
|
||||
foreach (var child in contentPanel.Children)
|
||||
{
|
||||
if (child is TextBlock tb)
|
||||
{
|
||||
if (contentPanel.Children.IndexOf(tb) == 0)
|
||||
{
|
||||
if (tb.Text != item.Name) tb.Text = item.Name;
|
||||
tb.Foreground = subjectBrush;
|
||||
}
|
||||
else if (contentPanel.Children.IndexOf(tb) == 1)
|
||||
{
|
||||
if (tb.Text != item.Detail) tb.Text = item.Detail;
|
||||
tb.Foreground = secondaryBrush;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var accentBar = cardInner.Children.OfType<Border>().FirstOrDefault(b => b.Width > 0 && b.Width < 10);
|
||||
if (accentBar != null)
|
||||
{
|
||||
accentBar.Background = subjectBrush;
|
||||
accentBar.IsVisible = item.IsCurrent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void IncrementalUpdateCurrentCourseHighlight(int currentCourseIndex)
|
||||
{
|
||||
for (var i = 0; i < CourseListPanel.Children.Count; i++)
|
||||
{
|
||||
var outerBorder = CourseListPanel.Children[i] as Border;
|
||||
if (outerBorder == null) continue;
|
||||
|
||||
var itemGrid = outerBorder.Child as Grid;
|
||||
if (itemGrid == null || itemGrid.Children.Count < 2) continue;
|
||||
|
||||
var cardBorder = itemGrid.Children[1] as Border;
|
||||
if (cardBorder == null) continue;
|
||||
|
||||
var item = i < _courseItems.Count ? _courseItems[i] : null;
|
||||
if (item == null) continue;
|
||||
|
||||
cardBorder.Background = SubjectColorService.ResolveBackgroundBrush(item.Name, i == currentCourseIndex);
|
||||
|
||||
var cardInner = cardBorder.Child as Grid;
|
||||
if (cardInner == null) continue;
|
||||
|
||||
var accentBar = cardInner.Children.OfType<Border>().FirstOrDefault(b => b.Width > 0 && b.Width < 10);
|
||||
if (accentBar != null)
|
||||
{
|
||||
accentBar.IsVisible = i == currentCourseIndex;
|
||||
if (i == currentCourseIndex)
|
||||
{
|
||||
accentBar.Background = SubjectColorService.ResolveForegroundBrush(item.Name, _isNightVisual);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAdaptiveLayout()
|
||||
@@ -915,38 +1013,34 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
: CreateGradientBrush("#F7F8FC", "#ECEFF6");
|
||||
RootBorder.BorderBrush = CreateBrush(_isNightVisual ? "#24FFFFFF" : "#15000000");
|
||||
|
||||
var rootPadding = new Thickness(
|
||||
var headerPadding = new Thickness(
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(16 * scale, 10, 24),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(14 * scale, 9, 20),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(12 * scale, 8, 16),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(16 * scale, 10, 24),
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(14 * scale, 8, 20));
|
||||
RootBorder.Padding = rootPadding;
|
||||
ComponentChromeCornerRadiusHelper.SafeValue(8 * scale, 4, 12));
|
||||
HeaderGrid.Margin = headerPadding;
|
||||
|
||||
LayoutGrid.RowSpacing = Math.Clamp(14 * scale, 6, 20);
|
||||
HeaderGrid.ColumnSpacing = Math.Clamp(10 * scale, 4, 16);
|
||||
HeaderGrid.ColumnSpacing = Math.Clamp(8 * scale, 4, 14);
|
||||
DateGroup.Spacing = Math.Clamp(1.5 * scale, 0.5, 3);
|
||||
MetaStack.Spacing = Math.Clamp(6 * scale, 3, 10);
|
||||
CourseListPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
|
||||
CourseListPanel.Spacing = Math.Clamp(2 * scale, 0, 6);
|
||||
|
||||
var dateFontByScale = Math.Clamp(66 * scale, 26, 82);
|
||||
var weekdayFontByScale = Math.Clamp(34 * scale, 13, 32);
|
||||
var classCountFontByScale = Math.Clamp(40 * scale, 14, 36);
|
||||
var dateFontByScale = Math.Clamp(28 * scale, 14, 36);
|
||||
var weekdayFontByScale = Math.Clamp(14 * scale, 10, 18);
|
||||
var classCountFontByScale = Math.Clamp(12 * scale, 9, 15);
|
||||
|
||||
// 宽度感知:当头部内容总需求超过可用宽度时,按比例缩小日期字体
|
||||
var availableWidth = Math.Max(1, Bounds.Width - rootPadding.Left - rootPadding.Right);
|
||||
var availableWidth = Math.Max(1, Bounds.Width - headerPadding.Left - headerPadding.Right);
|
||||
var dateGroupEstimatedWidth = dateFontByScale * 0.6 * 3 + DateGroup.Spacing * 2;
|
||||
var metaStackEstimatedWidth = classCountFontByScale * 0.6 * 4 + MetaStack.Spacing;
|
||||
var headerColumnSpacing = Math.Clamp(10 * scale, 4, 16);
|
||||
var totalHeaderNeed = dateGroupEstimatedWidth + headerColumnSpacing + metaStackEstimatedWidth;
|
||||
var badgeEstimatedWidth = classCountFontByScale * 0.6 * 5 + 16;
|
||||
var headerColumnSpacing = HeaderGrid.ColumnSpacing;
|
||||
var totalHeaderNeed = dateGroupEstimatedWidth + headerColumnSpacing + badgeEstimatedWidth + weekdayFontByScale * 2;
|
||||
|
||||
var dateFont = dateFontByScale;
|
||||
if (totalHeaderNeed > availableWidth)
|
||||
{
|
||||
var shrinkRatio = availableWidth / totalHeaderNeed;
|
||||
dateFont = Math.Max(20, dateFontByScale * shrinkRatio);
|
||||
dateFont = Math.Max(14, dateFontByScale * shrinkRatio);
|
||||
}
|
||||
|
||||
// 为 HeaderGrid 左列设置最小宽度,防止被压缩至零
|
||||
var minDateColumnWidth = dateFont * 0.6 * 3 + DateGroup.Spacing * 2;
|
||||
HeaderGrid.ColumnDefinitions[0].MinWidth = minDateColumnWidth;
|
||||
|
||||
@@ -958,15 +1052,24 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
DayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722");
|
||||
SlashTextBlock.Foreground = slashBrush;
|
||||
WeekdayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#C6CBD5" : "#4B5463");
|
||||
ClassCountTextBlock.Foreground = CreateBrush(_isNightVisual ? "#8D95A4" : "#738095");
|
||||
StatusTextBlock.Foreground = CreateBrush(_isNightVisual ? "#9AA2B1" : "#4B5565");
|
||||
|
||||
WeekdayTextBlock.FontSize = weekdayFontByScale;
|
||||
ClassCountTextBlock.FontSize = classCountFontByScale;
|
||||
StatusTextBlock.FontSize = Math.Clamp(30 * scale, 12, 30);
|
||||
|
||||
WeekdayTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
|
||||
|
||||
ClassCountTextBlock.FontSize = classCountFontByScale;
|
||||
ClassCountTextBlock.FontWeight = ToVariableWeight(Lerp(560, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
|
||||
|
||||
var badgeBrush = useMonetColor
|
||||
? CreateBrush(_isNightVisual ? "#1A4FC3F7" : "#124FC3F7")
|
||||
: CreateBrush(_isNightVisual ? "#1AFF4D5A" : "#12FF4D5A");
|
||||
ClassCountBadge.Background = badgeBrush;
|
||||
ClassCountBadge.CornerRadius = new CornerRadius(ComponentChromeCornerRadiusHelper.Micro());
|
||||
ClassCountTextBlock.Foreground = useMonetColor
|
||||
? CreateBrush("#FF4FC3F7")
|
||||
: CreateBrush("#FF4D5A");
|
||||
|
||||
StatusTextBlock.FontSize = Math.Clamp(14 * scale, 10, 18);
|
||||
}
|
||||
|
||||
private static string FormatTime(TimeSpan time)
|
||||
|
||||
@@ -332,10 +332,6 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
BuiltInComponentIds.DesktopClock,
|
||||
"component.desktop_clock",
|
||||
() => new AnalogClockWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopWeatherClock,
|
||||
"component.weather_clock",
|
||||
() => new WeatherClockWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopWorldClock,
|
||||
"component.world_clock",
|
||||
@@ -344,22 +340,6 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
BuiltInComponentIds.DesktopTimer,
|
||||
"component.desktop_timer",
|
||||
() => new TimerWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopWeather,
|
||||
"component.desktop_weather",
|
||||
() => new WeatherWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopHourlyWeather,
|
||||
"component.hourly_weather",
|
||||
() => new HourlyWeatherWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopMultiDayWeather,
|
||||
"component.multiday_weather",
|
||||
() => new MultiDayWeatherWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopExtendedWeather,
|
||||
"component.extended_weather",
|
||||
() => new ExtendedWeatherWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopClassSchedule,
|
||||
"component.class_schedule",
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
<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="640"
|
||||
d:DesignHeight="640"
|
||||
x:Class="LanMountainDesktop.Views.Components.ExtendedWeatherWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Background="#6B7B8F">
|
||||
<Grid>
|
||||
<Border x:Name="BackgroundImageLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="BackgroundMotionLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.26"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Border.RenderTransform>
|
||||
<TransformGroup>
|
||||
<ScaleTransform ScaleX="1.07"
|
||||
ScaleY="1.07" />
|
||||
<TranslateTransform />
|
||||
</TransformGroup>
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundTintLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.12" />
|
||||
|
||||
<Border x:Name="BackgroundLightLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.54">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="1,1">
|
||||
<GradientStop Color="#45FFFFFF"
|
||||
Offset="0" />
|
||||
<GradientStop Color="#16FFFFFF"
|
||||
Offset="0.34" />
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0.66" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundShadeLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.70">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="0,1">
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0.40" />
|
||||
<GradientStop Color="#1A000000"
|
||||
Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Canvas x:Name="ParticleLayer"
|
||||
IsHitTestVisible="False"
|
||||
ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="ContentPaddingBorder"
|
||||
Padding="24,20"
|
||||
Background="Transparent">
|
||||
<Grid x:Name="LayoutRoot"
|
||||
RowDefinitions="Auto,Auto,Auto,*">
|
||||
<Grid x:Name="SummaryGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="16">
|
||||
<TextBlock x:Name="TemperatureTextBlock"
|
||||
Grid.Column="0"
|
||||
Text="7°"
|
||||
FontSize="64"
|
||||
FontWeight="Light"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,-2,0,0"
|
||||
TextTrimming="None"
|
||||
MaxLines="1" />
|
||||
|
||||
<Grid x:Name="SummaryInfoGrid"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Margin="2,0,0,0"
|
||||
RowDefinitions="Auto,Auto"
|
||||
RowSpacing="2">
|
||||
<StackPanel x:Name="BottomInfoStack"
|
||||
Grid.Row="0"
|
||||
Orientation="Horizontal"
|
||||
Spacing="3"
|
||||
Margin="0,0,0,1"
|
||||
VerticalAlignment="Center">
|
||||
<Border x:Name="CityInfoBadge"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<TextBlock x:Name="CityTextBlock"
|
||||
Text="北京"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Border x:Name="ConditionInfoBadge"
|
||||
Grid.Row="1"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0"
|
||||
Margin="0">
|
||||
<StackPanel x:Name="ConditionRangeStack"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="9">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="11°/4°"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Opacity="0.92" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Image x:Name="WeatherIconImage"
|
||||
Grid.Column="2"
|
||||
Width="72"
|
||||
Height="72"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Stretch="Uniform" />
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="HourlyPanelBorder"
|
||||
Grid.Row="1"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
ClipToBounds="True"
|
||||
Padding="0,2,0,0"
|
||||
Margin="0,10,0,0">
|
||||
<Grid x:Name="HourlyGrid"
|
||||
ColumnDefinitions="*,*,*,*,*,*"
|
||||
ColumnSpacing="4">
|
||||
<StackPanel Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp0" Text="7°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon0" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime0" Text="15:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp1" Text="7°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon1" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime1" Text="16:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp2" Text="7°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon2" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime2" Text="17:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="3" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp3" Text="日落" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon3" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime3" Text="18:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp4" Text="7°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon4" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime4" Text="19:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="5" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp5" Text="7°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon5" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime5" Text="20:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="SeparatorLine"
|
||||
Grid.Row="2"
|
||||
Height="1"
|
||||
Margin="0,12,0,0"
|
||||
Background="#25FFFFFF" />
|
||||
|
||||
<Grid x:Name="DailyGrid"
|
||||
Grid.Row="3"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||
RowSpacing="10"
|
||||
Margin="0,12,0,0">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="10">
|
||||
<Image x:Name="DailyIcon0" Width="24" Height="24" VerticalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="DailyLabel0" Grid.Column="1" Text="明天·阴" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyHigh0" Grid.Column="2" Text="10" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyLow0" Grid.Column="3" Text="5" FontSize="17" FontWeight="Medium" VerticalAlignment="Center" Opacity="0.70" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="10">
|
||||
<Image x:Name="DailyIcon1" Width="24" Height="24" VerticalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="DailyLabel1" Grid.Column="1" Text="周四·多云" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyHigh1" Grid.Column="2" Text="13" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyLow1" Grid.Column="3" Text="4" FontSize="17" FontWeight="Medium" VerticalAlignment="Center" Opacity="0.70" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="10">
|
||||
<Image x:Name="DailyIcon2" Width="24" Height="24" VerticalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="DailyLabel2" Grid.Column="1" Text="周五·阴" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyHigh2" Grid.Column="2" Text="12" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyLow2" Grid.Column="3" Text="3" FontSize="17" FontWeight="Medium" VerticalAlignment="Center" Opacity="0.70" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="10">
|
||||
<Image x:Name="DailyIcon3" Width="24" Height="24" VerticalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="DailyLabel3" Grid.Column="1" Text="周六·多云" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyHigh3" Grid.Column="2" Text="10" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyLow3" Grid.Column="3" Text="2" FontSize="17" FontWeight="Medium" VerticalAlignment="Center" Opacity="0.70" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="4" ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="10">
|
||||
<Image x:Name="DailyIcon4" Width="24" Height="24" VerticalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="DailyLabel4" Grid.Column="1" Text="周日·阴" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyHigh4" Grid.Column="2" Text="11" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyLow4" Grid.Column="3" Text="3" FontSize="17" FontWeight="Medium" VerticalAlignment="Center" Opacity="0.70" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -1,177 +0,0 @@
|
||||
<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.HourlyWeatherWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Background="#6B7B8F">
|
||||
<Grid>
|
||||
<Border x:Name="BackgroundImageLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="BackgroundMotionLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.25"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Border.RenderTransform>
|
||||
<TransformGroup>
|
||||
<ScaleTransform ScaleX="1.07" ScaleY="1.07" />
|
||||
<TranslateTransform />
|
||||
</TransformGroup>
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundTintLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.12" />
|
||||
|
||||
<Border x:Name="BackgroundLightLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.52">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#45FFFFFF" Offset="0" />
|
||||
<GradientStop Color="#16FFFFFF" Offset="0.35" />
|
||||
<GradientStop Color="#00000000" Offset="0.64" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundShadeLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.68">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#00000000" Offset="0.42" />
|
||||
<GradientStop Color="#19000000" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Canvas x:Name="ParticleLayer" IsHitTestVisible="False" ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="ContentPaddingBorder" Padding="24,18" Background="Transparent">
|
||||
<Grid x:Name="LayoutRoot">
|
||||
<Grid x:Name="ContentGrid" RowDefinitions="Auto,*" RowSpacing="8">
|
||||
<Grid x:Name="TopRowGrid" Grid.Row="0" ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
||||
<TextBlock x:Name="TemperatureTextBlock"
|
||||
Grid.Column="0"
|
||||
Text="7°"
|
||||
FontSize="54"
|
||||
FontWeight="Light"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,-2,0,0"
|
||||
TextTrimming="None"
|
||||
MaxLines="1" />
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2"
|
||||
Margin="2,0,0,0">
|
||||
<StackPanel x:Name="BottomInfoStack"
|
||||
Orientation="Horizontal"
|
||||
Spacing="3"
|
||||
Margin="0,0,0,1"
|
||||
VerticalAlignment="Center">
|
||||
<Border x:Name="CityInfoBadge"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="0">
|
||||
<fi:SymbolIcon x:Name="LocationIcon"
|
||||
Symbol="Location"
|
||||
FontSize="13"
|
||||
IsVisible="False"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="CityTextBlock"
|
||||
Text="北京"
|
||||
FontSize="17"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Border x:Name="ConditionInfoBadge"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0"
|
||||
Margin="0">
|
||||
<StackPanel x:Name="ConditionRangeStack"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="9">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="11°/4°"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Opacity="0.92" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Image x:Name="WeatherIconImage"
|
||||
Grid.Column="2"
|
||||
Width="66"
|
||||
Height="66"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Stretch="Uniform" />
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="HourlyPanelBorder"
|
||||
Grid.Row="1"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
ClipToBounds="True"
|
||||
Padding="0,2,0,0"
|
||||
VerticalAlignment="Top">
|
||||
<Grid x:Name="HourlyGrid" ColumnDefinitions="*,*,*,*,*,*" ColumnSpacing="4">
|
||||
<StackPanel Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp0" Text="7°" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon0" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime0" Text="15:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp1" Text="7°" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon1" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime1" Text="16:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp2" Text="7°" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon2" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime2" Text="17:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="3" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp3" Text="日落" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon3" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime3" Text="18:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp4" Text="7°" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon4" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime4" Text="19:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="5" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp5" Text="7°" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon5" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime5" Text="20:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -1,34 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
internal static class HyperOS3WeatherAssetLoader
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, IImage?> ImageCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static IImage? LoadImage(string? uriText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uriText))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ImageCache.GetOrAdd(uriText, static key =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(key, UriKind.Absolute);
|
||||
using var stream = AssetLoader.Open(uri);
|
||||
return new Bitmap(stream);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public enum HyperOS3WeatherVisualKind
|
||||
{
|
||||
Unknown,
|
||||
ClearDay,
|
||||
ClearNight,
|
||||
PartlyCloudyDay,
|
||||
PartlyCloudyNight,
|
||||
CloudyDay,
|
||||
CloudyNight,
|
||||
Haze,
|
||||
Sleet,
|
||||
RainLight,
|
||||
RainHeavy,
|
||||
Storm,
|
||||
Snow,
|
||||
Fog
|
||||
}
|
||||
|
||||
public enum HyperOS3WeatherWidgetKind
|
||||
{
|
||||
Realtime2x2,
|
||||
Hourly4x2,
|
||||
MultiDay4x2,
|
||||
WeatherClock2x1,
|
||||
Extended4x4
|
||||
}
|
||||
|
||||
public readonly record struct HyperOS3WeatherPalette(
|
||||
string GradientFrom,
|
||||
string GradientTo,
|
||||
string Tint,
|
||||
string PrimaryText,
|
||||
string SecondaryText,
|
||||
string TertiaryText,
|
||||
string ParticleColor);
|
||||
|
||||
public readonly record struct HyperOS3WeatherMotion(
|
||||
double DriftX,
|
||||
double DriftY,
|
||||
double ZoomBase,
|
||||
double ZoomAmplitude,
|
||||
double MotionOpacityBase,
|
||||
double MotionOpacityPulse,
|
||||
double LightOpacityBase,
|
||||
double LightOpacityPulse,
|
||||
double ShadeOpacityBase,
|
||||
double ShadeOpacityPulse,
|
||||
double PhaseStep,
|
||||
int ParticleCount,
|
||||
double ParticleSpeedMin,
|
||||
double ParticleSpeedMax,
|
||||
double ParticleLengthMin,
|
||||
double ParticleLengthMax,
|
||||
double ParticleDriftPerTick);
|
||||
|
||||
public readonly record struct HyperOS3WeatherMetrics(
|
||||
double CornerRadiusScale,
|
||||
double HorizontalPaddingScale,
|
||||
double VerticalPaddingScale,
|
||||
double PrimaryTemperatureFont,
|
||||
double PrimaryTextFont,
|
||||
double SecondaryTextFont,
|
||||
double CaptionFont,
|
||||
double IconFont,
|
||||
double MainGap,
|
||||
double SectionGap);
|
||||
|
||||
public static class HyperOS3WeatherTheme
|
||||
{
|
||||
private static readonly HyperOS3WeatherPalette FallbackPalette = new(
|
||||
GradientFrom: "#607C9E",
|
||||
GradientTo: "#9DB3CB",
|
||||
Tint: "#55708D",
|
||||
PrimaryText: "#FFFFFFFF",
|
||||
SecondaryText: "#E4EDF7",
|
||||
TertiaryText: "#BFD0E1",
|
||||
ParticleColor: "#70D3E2F4");
|
||||
|
||||
private static readonly HyperOS3WeatherMotion FallbackMotion = new(
|
||||
DriftX: 8.0, DriftY: 6.0, ZoomBase: 1.050, ZoomAmplitude: 0.010,
|
||||
MotionOpacityBase: 0.28, MotionOpacityPulse: 0.05,
|
||||
LightOpacityBase: 0.62, LightOpacityPulse: 0.05,
|
||||
ShadeOpacityBase: 0.83, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.018, ParticleCount: 10,
|
||||
ParticleSpeedMin: 0.25, ParticleSpeedMax: 0.70,
|
||||
ParticleLengthMin: 16, ParticleLengthMax: 34, ParticleDriftPerTick: 0.12);
|
||||
|
||||
private static readonly IReadOnlyDictionary<HyperOS3WeatherVisualKind, string> BackgroundAssets =
|
||||
new Dictionary<HyperOS3WeatherVisualKind, string>
|
||||
{
|
||||
[HyperOS3WeatherVisualKind.Unknown] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png",
|
||||
[HyperOS3WeatherVisualKind.ClearDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_day.png",
|
||||
[HyperOS3WeatherVisualKind.ClearNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_night.png",
|
||||
[HyperOS3WeatherVisualKind.PartlyCloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_day.png",
|
||||
[HyperOS3WeatherVisualKind.PartlyCloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_night.png",
|
||||
[HyperOS3WeatherVisualKind.CloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_front.png",
|
||||
[HyperOS3WeatherVisualKind.CloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png",
|
||||
[HyperOS3WeatherVisualKind.Haze] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_haze.png",
|
||||
[HyperOS3WeatherVisualKind.Sleet] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_front.png",
|
||||
[HyperOS3WeatherVisualKind.RainLight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_front.png",
|
||||
[HyperOS3WeatherVisualKind.RainHeavy] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png",
|
||||
[HyperOS3WeatherVisualKind.Storm] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_cross_sky_night.png",
|
||||
[HyperOS3WeatherVisualKind.Snow] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_top.png",
|
||||
[HyperOS3WeatherVisualKind.Fog] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_fog.png"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<HyperOS3WeatherVisualKind, string> HeroIconAssets =
|
||||
new Dictionary<HyperOS3WeatherVisualKind, string>
|
||||
{
|
||||
[HyperOS3WeatherVisualKind.Unknown] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp",
|
||||
[HyperOS3WeatherVisualKind.ClearDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png",
|
||||
[HyperOS3WeatherVisualKind.ClearNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_moon_soft.png",
|
||||
[HyperOS3WeatherVisualKind.PartlyCloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp",
|
||||
[HyperOS3WeatherVisualKind.PartlyCloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp",
|
||||
[HyperOS3WeatherVisualKind.CloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp",
|
||||
[HyperOS3WeatherVisualKind.CloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp",
|
||||
[HyperOS3WeatherVisualKind.Haze] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp",
|
||||
[HyperOS3WeatherVisualKind.Sleet] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp",
|
||||
[HyperOS3WeatherVisualKind.RainLight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp",
|
||||
[HyperOS3WeatherVisualKind.RainHeavy] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp",
|
||||
[HyperOS3WeatherVisualKind.Storm] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp",
|
||||
[HyperOS3WeatherVisualKind.Snow] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp",
|
||||
[HyperOS3WeatherVisualKind.Fog] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_fog_soft.png"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<HyperOS3WeatherVisualKind, string> MiniIconAssets =
|
||||
new Dictionary<HyperOS3WeatherVisualKind, string>
|
||||
{
|
||||
[HyperOS3WeatherVisualKind.Unknown] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_cloudy_soft.png",
|
||||
[HyperOS3WeatherVisualKind.ClearDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png",
|
||||
[HyperOS3WeatherVisualKind.ClearNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png",
|
||||
[HyperOS3WeatherVisualKind.PartlyCloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png",
|
||||
[HyperOS3WeatherVisualKind.PartlyCloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png",
|
||||
[HyperOS3WeatherVisualKind.CloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_cloudy_soft.png",
|
||||
[HyperOS3WeatherVisualKind.CloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_cloudy_soft.png",
|
||||
[HyperOS3WeatherVisualKind.Haze] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp",
|
||||
[HyperOS3WeatherVisualKind.Sleet] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp",
|
||||
[HyperOS3WeatherVisualKind.RainLight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_light_soft.png",
|
||||
[HyperOS3WeatherVisualKind.RainHeavy] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_heavy_soft.png",
|
||||
[HyperOS3WeatherVisualKind.Storm] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_storm_soft.png",
|
||||
[HyperOS3WeatherVisualKind.Snow] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_snow_soft.png",
|
||||
[HyperOS3WeatherVisualKind.Fog] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_fog_soft.png"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<HyperOS3WeatherVisualKind, HyperOS3WeatherPalette> Palettes =
|
||||
new Dictionary<HyperOS3WeatherVisualKind, HyperOS3WeatherPalette>
|
||||
{
|
||||
[HyperOS3WeatherVisualKind.Unknown] = new(
|
||||
GradientFrom: "#6B7785",
|
||||
GradientTo: "#98A4B3",
|
||||
Tint: "#55606E",
|
||||
PrimaryText: "#F8FBFF",
|
||||
SecondaryText: "#E1E8F0",
|
||||
TertiaryText: "#C2CCD8",
|
||||
ParticleColor: "#24FFFFFF"),
|
||||
[HyperOS3WeatherVisualKind.ClearDay] = new(
|
||||
GradientFrom: "#5F7FA3",
|
||||
GradientTo: "#9BB4CF",
|
||||
Tint: "#567495",
|
||||
PrimaryText: "#F8FCFF",
|
||||
SecondaryText: "#E5EEF8",
|
||||
TertiaryText: "#C3D3E4",
|
||||
ParticleColor: "#00FFFFFF"),
|
||||
[HyperOS3WeatherVisualKind.ClearNight] = new(
|
||||
GradientFrom: "#576B86",
|
||||
GradientTo: "#889CB6",
|
||||
Tint: "#495F79",
|
||||
PrimaryText: "#F9FBFF",
|
||||
SecondaryText: "#D9E4F0",
|
||||
TertiaryText: "#B4C3D6",
|
||||
ParticleColor: "#00FFFFFF"),
|
||||
[HyperOS3WeatherVisualKind.PartlyCloudyDay] = new(
|
||||
GradientFrom: "#607D9F",
|
||||
GradientTo: "#9BB2C8",
|
||||
Tint: "#55728F",
|
||||
PrimaryText: "#F8FCFF",
|
||||
SecondaryText: "#E4EDF7",
|
||||
TertiaryText: "#C4D4E4",
|
||||
ParticleColor: "#12FFFFFF"),
|
||||
[HyperOS3WeatherVisualKind.PartlyCloudyNight] = new(
|
||||
GradientFrom: "#5A6E87",
|
||||
GradientTo: "#8FA4BC",
|
||||
Tint: "#4D6178",
|
||||
PrimaryText: "#F8FBFF",
|
||||
SecondaryText: "#D9E5F0",
|
||||
TertiaryText: "#B6C5D7",
|
||||
ParticleColor: "#1FE8F2FF"),
|
||||
[HyperOS3WeatherVisualKind.CloudyDay] = new(
|
||||
GradientFrom: "#5D799A",
|
||||
GradientTo: "#95ADC6",
|
||||
Tint: "#526E8B",
|
||||
PrimaryText: "#F8FCFF",
|
||||
SecondaryText: "#E2ECF7",
|
||||
TertiaryText: "#C0D0E0",
|
||||
ParticleColor: "#26FFFFFF"),
|
||||
[HyperOS3WeatherVisualKind.CloudyNight] = new(
|
||||
GradientFrom: "#536882",
|
||||
GradientTo: "#869CB4",
|
||||
Tint: "#495E76",
|
||||
PrimaryText: "#F6FAFF",
|
||||
SecondaryText: "#D4E0ED",
|
||||
TertiaryText: "#B0BFD2",
|
||||
ParticleColor: "#30F0F5FF"),
|
||||
[HyperOS3WeatherVisualKind.Haze] = new(
|
||||
GradientFrom: "#6A7E95",
|
||||
GradientTo: "#A5B2BE",
|
||||
Tint: "#657789",
|
||||
PrimaryText: "#F7FBFF",
|
||||
SecondaryText: "#E3E8EE",
|
||||
TertiaryText: "#C1CBD6",
|
||||
ParticleColor: "#6FD6DEE8"),
|
||||
[HyperOS3WeatherVisualKind.Sleet] = new(
|
||||
GradientFrom: "#61788F",
|
||||
GradientTo: "#9AB0C4",
|
||||
Tint: "#587087",
|
||||
PrimaryText: "#F7FBFF",
|
||||
SecondaryText: "#DCE6F0",
|
||||
TertiaryText: "#B8C7D7",
|
||||
ParticleColor: "#98DCEBFF"),
|
||||
[HyperOS3WeatherVisualKind.RainLight] = new(
|
||||
GradientFrom: "#4F6786",
|
||||
GradientTo: "#7A92AF",
|
||||
Tint: "#425C7A",
|
||||
PrimaryText: "#F8FBFF",
|
||||
SecondaryText: "#D7E2EE",
|
||||
TertiaryText: "#AEBED0",
|
||||
ParticleColor: "#86CCDEFF"),
|
||||
[HyperOS3WeatherVisualKind.RainHeavy] = new(
|
||||
GradientFrom: "#435770",
|
||||
GradientTo: "#667F98",
|
||||
Tint: "#364961",
|
||||
PrimaryText: "#F9FCFF",
|
||||
SecondaryText: "#D3DEEB",
|
||||
TertiaryText: "#A9B8CB",
|
||||
ParticleColor: "#9FC4D8FF"),
|
||||
[HyperOS3WeatherVisualKind.Storm] = new(
|
||||
GradientFrom: "#3A4D63",
|
||||
GradientTo: "#5C7288",
|
||||
Tint: "#2F4055",
|
||||
PrimaryText: "#F9FCFF",
|
||||
SecondaryText: "#CEDAE8",
|
||||
TertiaryText: "#A6B6C8",
|
||||
ParticleColor: "#9EB8CCF2"),
|
||||
[HyperOS3WeatherVisualKind.Snow] = new(
|
||||
GradientFrom: "#8A9FBA",
|
||||
GradientTo: "#AEC1D6",
|
||||
Tint: "#6E829A",
|
||||
PrimaryText: "#F8FBFF",
|
||||
SecondaryText: "#D9E4EF",
|
||||
TertiaryText: "#B5C4D6",
|
||||
ParticleColor: "#CCFFFFFF"),
|
||||
[HyperOS3WeatherVisualKind.Fog] = new(
|
||||
GradientFrom: "#607793",
|
||||
GradientTo: "#90A7C2",
|
||||
Tint: "#4F6580",
|
||||
PrimaryText: "#F8FBFF",
|
||||
SecondaryText: "#DFEAF5",
|
||||
TertiaryText: "#B7C8DA",
|
||||
ParticleColor: "#88D9E5F1")
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<HyperOS3WeatherVisualKind, HyperOS3WeatherMotion> Motions =
|
||||
new Dictionary<HyperOS3WeatherVisualKind, HyperOS3WeatherMotion>
|
||||
{
|
||||
[HyperOS3WeatherVisualKind.Unknown] = new(
|
||||
DriftX: 8.0, DriftY: 5.0, ZoomBase: 1.050, ZoomAmplitude: 0.010,
|
||||
MotionOpacityBase: 0.24, MotionOpacityPulse: 0.05,
|
||||
LightOpacityBase: 0.60, LightOpacityPulse: 0.05,
|
||||
ShadeOpacityBase: 0.80, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.018, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0, ParticleSpeedMax: 0,
|
||||
ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0),
|
||||
[HyperOS3WeatherVisualKind.ClearDay] = new(
|
||||
DriftX: 8.0, DriftY: 4.0, ZoomBase: 1.055, ZoomAmplitude: 0.012,
|
||||
MotionOpacityBase: 0.22, MotionOpacityPulse: 0.05,
|
||||
LightOpacityBase: 0.68, LightOpacityPulse: 0.08,
|
||||
ShadeOpacityBase: 0.72, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.015, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0, ParticleSpeedMax: 0,
|
||||
ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0),
|
||||
[HyperOS3WeatherVisualKind.ClearNight] = new(
|
||||
DriftX: 10.0, DriftY: 6.0, ZoomBase: 1.060, ZoomAmplitude: 0.014,
|
||||
MotionOpacityBase: 0.28, MotionOpacityPulse: 0.06,
|
||||
LightOpacityBase: 0.58, LightOpacityPulse: 0.07,
|
||||
ShadeOpacityBase: 0.82, ShadeOpacityPulse: 0.04,
|
||||
PhaseStep: 0.018, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0, ParticleSpeedMax: 0,
|
||||
ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0),
|
||||
[HyperOS3WeatherVisualKind.PartlyCloudyDay] = new(
|
||||
DriftX: 10.0, DriftY: 6.0, ZoomBase: 1.058, ZoomAmplitude: 0.013,
|
||||
MotionOpacityBase: 0.26, MotionOpacityPulse: 0.05,
|
||||
LightOpacityBase: 0.65, LightOpacityPulse: 0.06,
|
||||
ShadeOpacityBase: 0.76, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.017, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0, ParticleSpeedMax: 0,
|
||||
ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0),
|
||||
[HyperOS3WeatherVisualKind.PartlyCloudyNight] = new(
|
||||
DriftX: 12.0, DriftY: 7.0, ZoomBase: 1.061, ZoomAmplitude: 0.013,
|
||||
MotionOpacityBase: 0.30, MotionOpacityPulse: 0.06,
|
||||
LightOpacityBase: 0.55, LightOpacityPulse: 0.05,
|
||||
ShadeOpacityBase: 0.82, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.019, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0, ParticleSpeedMax: 0,
|
||||
ParticleLengthMin: 0, ParticleLengthMax: 0, ParticleDriftPerTick: 0),
|
||||
[HyperOS3WeatherVisualKind.CloudyDay] = new(
|
||||
DriftX: 12.0, DriftY: 7.0, ZoomBase: 1.060, ZoomAmplitude: 0.013,
|
||||
MotionOpacityBase: 0.32, MotionOpacityPulse: 0.06,
|
||||
LightOpacityBase: 0.62, LightOpacityPulse: 0.07,
|
||||
ShadeOpacityBase: 0.80, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.020, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0.30, ParticleSpeedMax: 0.70,
|
||||
ParticleLengthMin: 14, ParticleLengthMax: 28, ParticleDriftPerTick: 0.10),
|
||||
[HyperOS3WeatherVisualKind.CloudyNight] = new(
|
||||
DriftX: 14.0, DriftY: 8.0, ZoomBase: 1.065, ZoomAmplitude: 0.013,
|
||||
MotionOpacityBase: 0.34, MotionOpacityPulse: 0.07,
|
||||
LightOpacityBase: 0.54, LightOpacityPulse: 0.06,
|
||||
ShadeOpacityBase: 0.85, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.021, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0.35, ParticleSpeedMax: 0.80,
|
||||
ParticleLengthMin: 16, ParticleLengthMax: 30, ParticleDriftPerTick: 0.12),
|
||||
[HyperOS3WeatherVisualKind.Haze] = new(
|
||||
DriftX: 9.0, DriftY: 5.0, ZoomBase: 1.052, ZoomAmplitude: 0.010,
|
||||
MotionOpacityBase: 0.30, MotionOpacityPulse: 0.04,
|
||||
LightOpacityBase: 0.54, LightOpacityPulse: 0.04,
|
||||
ShadeOpacityBase: 0.85, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.018, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0.20, ParticleSpeedMax: 0.45,
|
||||
ParticleLengthMin: 12, ParticleLengthMax: 28, ParticleDriftPerTick: 0.10),
|
||||
[HyperOS3WeatherVisualKind.Sleet] = new(
|
||||
DriftX: 7.0, DriftY: 9.0, ZoomBase: 1.048, ZoomAmplitude: 0.011,
|
||||
MotionOpacityBase: 0.31, MotionOpacityPulse: 0.06,
|
||||
LightOpacityBase: 0.52, LightOpacityPulse: 0.05,
|
||||
ShadeOpacityBase: 0.82, ShadeOpacityPulse: 0.04,
|
||||
PhaseStep: 0.026, ParticleCount: 20,
|
||||
ParticleSpeedMin: 1.20, ParticleSpeedMax: 2.40,
|
||||
ParticleLengthMin: 8, ParticleLengthMax: 18, ParticleDriftPerTick: 0.34),
|
||||
[HyperOS3WeatherVisualKind.RainLight] = new(
|
||||
DriftX: 6.0, DriftY: 10.0, ZoomBase: 1.050, ZoomAmplitude: 0.010,
|
||||
MotionOpacityBase: 0.30, MotionOpacityPulse: 0.08,
|
||||
LightOpacityBase: 0.50, LightOpacityPulse: 0.04,
|
||||
ShadeOpacityBase: 0.84, ShadeOpacityPulse: 0.04,
|
||||
PhaseStep: 0.030, ParticleCount: 18,
|
||||
ParticleSpeedMin: 1.80, ParticleSpeedMax: 3.20,
|
||||
ParticleLengthMin: 14, ParticleLengthMax: 26, ParticleDriftPerTick: 0.70),
|
||||
[HyperOS3WeatherVisualKind.RainHeavy] = new(
|
||||
DriftX: 5.0, DriftY: 11.0, ZoomBase: 1.045, ZoomAmplitude: 0.010,
|
||||
MotionOpacityBase: 0.34, MotionOpacityPulse: 0.10,
|
||||
LightOpacityBase: 0.42, LightOpacityPulse: 0.03,
|
||||
ShadeOpacityBase: 0.88, ShadeOpacityPulse: 0.05,
|
||||
PhaseStep: 0.036, ParticleCount: 30,
|
||||
ParticleSpeedMin: 2.80, ParticleSpeedMax: 4.80,
|
||||
ParticleLengthMin: 18, ParticleLengthMax: 34, ParticleDriftPerTick: 0.92),
|
||||
[HyperOS3WeatherVisualKind.Storm] = new(
|
||||
DriftX: 4.0, DriftY: 12.0, ZoomBase: 1.042, ZoomAmplitude: 0.012,
|
||||
MotionOpacityBase: 0.38, MotionOpacityPulse: 0.12,
|
||||
LightOpacityBase: 0.36, LightOpacityPulse: 0.02,
|
||||
ShadeOpacityBase: 0.91, ShadeOpacityPulse: 0.04,
|
||||
PhaseStep: 0.042, ParticleCount: 34,
|
||||
ParticleSpeedMin: 3.60, ParticleSpeedMax: 5.80,
|
||||
ParticleLengthMin: 20, ParticleLengthMax: 36, ParticleDriftPerTick: 1.08),
|
||||
[HyperOS3WeatherVisualKind.Snow] = new(
|
||||
DriftX: 9.0, DriftY: 7.0, ZoomBase: 1.055, ZoomAmplitude: 0.012,
|
||||
MotionOpacityBase: 0.28, MotionOpacityPulse: 0.06,
|
||||
LightOpacityBase: 0.74, LightOpacityPulse: 0.08,
|
||||
ShadeOpacityBase: 0.68, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.020, ParticleCount: 24,
|
||||
ParticleSpeedMin: 0.60, ParticleSpeedMax: 1.60,
|
||||
ParticleLengthMin: 3.0, ParticleLengthMax: 8.5, ParticleDriftPerTick: 0.24),
|
||||
[HyperOS3WeatherVisualKind.Fog] = new(
|
||||
DriftX: 7.0, DriftY: 5.0, ZoomBase: 1.050, ZoomAmplitude: 0.011,
|
||||
MotionOpacityBase: 0.30, MotionOpacityPulse: 0.05,
|
||||
LightOpacityBase: 0.58, LightOpacityPulse: 0.05,
|
||||
ShadeOpacityBase: 0.86, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.018, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0.25, ParticleSpeedMax: 0.70,
|
||||
ParticleLengthMin: 16, ParticleLengthMax: 34, ParticleDriftPerTick: 0.12)
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<HyperOS3WeatherWidgetKind, HyperOS3WeatherMetrics> Metrics =
|
||||
new Dictionary<HyperOS3WeatherWidgetKind, HyperOS3WeatherMetrics>
|
||||
{
|
||||
[HyperOS3WeatherWidgetKind.Realtime2x2] = new(0.47, 0.32, 0.30, 112, 28, 24, 20, 36, 8, 5),
|
||||
[HyperOS3WeatherWidgetKind.Hourly4x2] = new(0.47, 0.24, 0.22, 96, 24, 20, 16, 26, 7, 4),
|
||||
[HyperOS3WeatherWidgetKind.MultiDay4x2] = new(0.47, 0.24, 0.22, 96, 24, 20, 16, 26, 7, 4),
|
||||
[HyperOS3WeatherWidgetKind.WeatherClock2x1] = new(0.40, 0.18, 0.14, 42, 18, 15, 12, 18, 4, 3),
|
||||
[HyperOS3WeatherWidgetKind.Extended4x4] = new(0.47, 0.24, 0.22, 112, 26, 22, 18, 28, 9, 6)
|
||||
};
|
||||
|
||||
public static HyperOS3WeatherVisualKind ResolveVisualKind(int? weatherCode, bool isNight)
|
||||
{
|
||||
return XiaomiWeatherCodeMapper.ResolveBucket(weatherCode) switch
|
||||
{
|
||||
WeatherConditionBucket.Unknown => HyperOS3WeatherVisualKind.Unknown,
|
||||
WeatherConditionBucket.Clear => isNight ? HyperOS3WeatherVisualKind.ClearNight : HyperOS3WeatherVisualKind.ClearDay,
|
||||
WeatherConditionBucket.PartlyCloudy => isNight ? HyperOS3WeatherVisualKind.PartlyCloudyNight : HyperOS3WeatherVisualKind.PartlyCloudyDay,
|
||||
WeatherConditionBucket.Cloudy => isNight ? HyperOS3WeatherVisualKind.CloudyNight : HyperOS3WeatherVisualKind.CloudyDay,
|
||||
WeatherConditionBucket.Haze => HyperOS3WeatherVisualKind.Haze,
|
||||
WeatherConditionBucket.Sleet => HyperOS3WeatherVisualKind.Sleet,
|
||||
WeatherConditionBucket.RainLight => HyperOS3WeatherVisualKind.RainLight,
|
||||
WeatherConditionBucket.RainHeavy => HyperOS3WeatherVisualKind.RainHeavy,
|
||||
WeatherConditionBucket.Storm => HyperOS3WeatherVisualKind.Storm,
|
||||
WeatherConditionBucket.Snow => HyperOS3WeatherVisualKind.Snow,
|
||||
WeatherConditionBucket.Fog => HyperOS3WeatherVisualKind.Fog,
|
||||
_ => HyperOS3WeatherVisualKind.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
public static HyperOS3WeatherPalette ResolvePalette(HyperOS3WeatherVisualKind kind)
|
||||
{
|
||||
return Palettes.TryGetValue(kind, out var palette) ? palette : FallbackPalette;
|
||||
}
|
||||
|
||||
public static HyperOS3WeatherMotion ResolveMotion(HyperOS3WeatherVisualKind kind)
|
||||
{
|
||||
return Motions.TryGetValue(kind, out var motion) ? motion : FallbackMotion;
|
||||
}
|
||||
|
||||
public static HyperOS3WeatherMetrics ResolveMetrics(HyperOS3WeatherWidgetKind kind)
|
||||
{
|
||||
return Metrics.TryGetValue(kind, out var metrics)
|
||||
? metrics
|
||||
: Metrics[HyperOS3WeatherWidgetKind.Realtime2x2];
|
||||
}
|
||||
|
||||
public static string? ResolveBackgroundAsset(HyperOS3WeatherVisualKind kind)
|
||||
{
|
||||
return BackgroundAssets.TryGetValue(kind, out var asset) ? asset : null;
|
||||
}
|
||||
|
||||
public static string? ResolveIconAsset(HyperOS3WeatherVisualKind kind)
|
||||
{
|
||||
return ResolveMiniIconAsset(kind);
|
||||
}
|
||||
|
||||
public static string? ResolveHeroIconAsset(HyperOS3WeatherVisualKind kind)
|
||||
{
|
||||
return HeroIconAssets.TryGetValue(kind, out var asset) ? asset : null;
|
||||
}
|
||||
|
||||
public static string? ResolveMiniIconAsset(HyperOS3WeatherVisualKind kind)
|
||||
{
|
||||
return MiniIconAssets.TryGetValue(kind, out var asset) ? asset : null;
|
||||
}
|
||||
|
||||
public static string ResolveSunCoreAsset()
|
||||
{
|
||||
return "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sun_core.png";
|
||||
}
|
||||
|
||||
public static string ResolveSunRingAsset()
|
||||
{
|
||||
return "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sun_ring.png";
|
||||
}
|
||||
|
||||
public static string? ResolveParticleAsset(HyperOS3WeatherVisualKind kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
HyperOS3WeatherVisualKind.Sleet or HyperOS3WeatherVisualKind.RainLight or HyperOS3WeatherVisualKind.RainHeavy or HyperOS3WeatherVisualKind.Storm
|
||||
=> "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_rain_drop.png",
|
||||
HyperOS3WeatherVisualKind.Haze
|
||||
=> "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_haze.png",
|
||||
HyperOS3WeatherVisualKind.Fog
|
||||
=> "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_fog.png",
|
||||
HyperOS3WeatherVisualKind.Snow
|
||||
=> "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_snow_flake.png",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static bool ResolveIsNightPreferred(
|
||||
WeatherSnapshot snapshot,
|
||||
TimeZoneInfo? timeZone,
|
||||
DateTime fallbackLocalTime)
|
||||
{
|
||||
if (snapshot.Current.IsDaylight.HasValue)
|
||||
{
|
||||
return !snapshot.Current.IsDaylight.Value;
|
||||
}
|
||||
|
||||
var referenceTime = snapshot.ObservationTime?.DateTime ?? fallbackLocalTime;
|
||||
if (snapshot.ObservationTime.HasValue && timeZone is not null)
|
||||
{
|
||||
referenceTime = TimeZoneInfo.ConvertTime(snapshot.ObservationTime.Value, timeZone).DateTime;
|
||||
}
|
||||
|
||||
var date = DateOnly.FromDateTime(referenceTime);
|
||||
var todayForecast = snapshot.DailyForecasts.FirstOrDefault(item => item.Date == date);
|
||||
if (todayForecast is not null &&
|
||||
TryParseClockTime(todayForecast.SunriseTime, out var sunrise) &&
|
||||
TryParseClockTime(todayForecast.SunsetTime, out var sunset) &&
|
||||
sunrise < sunset)
|
||||
{
|
||||
var time = referenceTime.TimeOfDay;
|
||||
return time < sunrise || time >= sunset;
|
||||
}
|
||||
|
||||
if (snapshot.ObservationTime.HasValue)
|
||||
{
|
||||
var observed = snapshot.ObservationTime.Value;
|
||||
if (timeZone is not null)
|
||||
{
|
||||
observed = TimeZoneInfo.ConvertTime(observed, timeZone);
|
||||
}
|
||||
|
||||
return observed.Hour < 6 || observed.Hour >= 18;
|
||||
}
|
||||
|
||||
return fallbackLocalTime.Hour < 6 || fallbackLocalTime.Hour >= 18;
|
||||
}
|
||||
|
||||
private static bool TryParseClockTime(string? text, out TimeSpan value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = text.Trim();
|
||||
if (TimeSpan.TryParse(candidate, CultureInfo.InvariantCulture, out value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dto))
|
||||
{
|
||||
value = dto.TimeOfDay;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dt))
|
||||
{
|
||||
value = dt.TimeOfDay;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
<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.MultiDayWeatherWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Background="#6B7B8F">
|
||||
<Grid>
|
||||
<Border x:Name="BackgroundImageLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="BackgroundMotionLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.25"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Border.RenderTransform>
|
||||
<TransformGroup>
|
||||
<ScaleTransform ScaleX="1.07" ScaleY="1.07" />
|
||||
<TranslateTransform />
|
||||
</TransformGroup>
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundTintLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.12" />
|
||||
|
||||
<Border x:Name="BackgroundLightLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.52">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#45FFFFFF" Offset="0" />
|
||||
<GradientStop Color="#16FFFFFF" Offset="0.35" />
|
||||
<GradientStop Color="#00000000" Offset="0.64" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundShadeLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.68">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#00000000" Offset="0.42" />
|
||||
<GradientStop Color="#19000000" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Canvas x:Name="ParticleLayer" IsHitTestVisible="False" ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="ContentPaddingBorder" Padding="24,18" Background="Transparent">
|
||||
<Grid x:Name="LayoutRoot">
|
||||
<Grid x:Name="ContentGrid" RowDefinitions="Auto,Auto,*" RowSpacing="8">
|
||||
<Grid x:Name="TopRowGrid" Grid.Row="0" ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
||||
<TextBlock x:Name="TemperatureTextBlock"
|
||||
Grid.Column="0"
|
||||
Text="7°"
|
||||
FontSize="54"
|
||||
FontWeight="Light"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,-2,0,0"
|
||||
TextTrimming="None"
|
||||
MaxLines="1" />
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2"
|
||||
Margin="2,0,0,0">
|
||||
<StackPanel x:Name="BottomInfoStack"
|
||||
Orientation="Horizontal"
|
||||
Spacing="3"
|
||||
Margin="0,0,0,1"
|
||||
VerticalAlignment="Center">
|
||||
<Border x:Name="CityInfoBadge"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="0">
|
||||
<fi:SymbolIcon x:Name="LocationIcon"
|
||||
Symbol="Location"
|
||||
FontSize="13"
|
||||
IsVisible="False"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="CityTextBlock"
|
||||
Text="北京"
|
||||
FontSize="17"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Border x:Name="ConditionInfoBadge"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0"
|
||||
Margin="0">
|
||||
<StackPanel x:Name="ConditionIconStack"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="9">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="11°/4°"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Opacity="0.92" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Image x:Name="WeatherIconImage"
|
||||
Grid.Column="2"
|
||||
Width="66"
|
||||
Height="66"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Stretch="Uniform" />
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Height="1"
|
||||
Background="#2AFFFFFF"
|
||||
Margin="0,4,0,0" />
|
||||
|
||||
<Border x:Name="HourlyPanelBorder"
|
||||
Grid.Row="2"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
ClipToBounds="True"
|
||||
Padding="0,2,0,0"
|
||||
VerticalAlignment="Top">
|
||||
<Grid x:Name="HourlyGrid" ColumnDefinitions="*,*,*,*,*" ColumnSpacing="5">
|
||||
<StackPanel Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp0" Text="10°/5°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon0" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime0" Text="明天" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp1" Text="13°/4°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon1" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime1" Text="周四" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp2" Text="12°/3°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon2" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime2" Text="周五" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="3" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp3" Text="10°/2°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon3" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime3" Text="周六" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp4" Text="11°/3°" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon4" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime4" Text="周日" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -9,18 +9,16 @@
|
||||
Background="Transparent"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<!-- 涓诲崱鐗?-->
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFCFD"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="12,10">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- 澶撮儴 -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<fi:SymbolIcon x:Name="HeaderIcon" Symbol="{x:Static symbol:Symbol.MailInbox}" FontSize="16" />
|
||||
<TextBlock x:Name="HeaderTextBlock"
|
||||
Text="娑堟伅鐩掑瓙"
|
||||
Text="消息盒子"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold" />
|
||||
<Border x:Name="UnreadBadge"
|
||||
@@ -46,24 +44,32 @@
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- 閫氱煡鍒楄〃 -->
|
||||
<ScrollViewer Grid.Row="1"
|
||||
Margin="0,8,0,0"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="NotificationListPanel" Spacing="6" />
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 绌虹姸鎬?-->
|
||||
<TextBlock x:Name="EmptyStateText"
|
||||
Grid.Row="1"
|
||||
Text="鏆傛棤閫氱煡"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="#8B95A5"
|
||||
FontSize="13"
|
||||
IsVisible="False" />
|
||||
<StackPanel x:Name="EmptyStatePanel"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8"
|
||||
IsVisible="False">
|
||||
<TextBlock x:Name="EmptyStateText"
|
||||
Text="暂无通知"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="#8B95A5"
|
||||
FontSize="13"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center" />
|
||||
<Button x:Name="PermissionButton"
|
||||
Content="请求权限"
|
||||
HorizontalAlignment="Center"
|
||||
Click="OnPermissionButtonClick"
|
||||
IsVisible="False" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 闅愮妯″紡閬僵 -->
|
||||
<Border x:Name="PrivacyOverlay"
|
||||
Grid.Row="1"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
@@ -73,13 +79,12 @@
|
||||
VerticalAlignment="Center"
|
||||
Spacing="6">
|
||||
<fi:SymbolIcon Symbol="{x:Static symbol:Symbol.EyeOff}" FontSize="24" Foreground="#8B95A5" />
|
||||
<TextBlock Text="鎮ㄦ湁鏂扮殑閫氱煡"
|
||||
<TextBlock Text="隐私模式已隐藏通知内容"
|
||||
Foreground="#8B95A5"
|
||||
FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 搴曢儴鐘舵€?-->
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Grid.Row="2"
|
||||
FontSize="11"
|
||||
|
||||
@@ -65,11 +65,9 @@ public partial class NotificationBoxWidget : UserControl,
|
||||
public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context)
|
||||
{
|
||||
_notificationService = NotificationListenerServiceProvider.GetOrCreate(_appSettingsService);
|
||||
if (_notificationService != null)
|
||||
{
|
||||
_notificationService.NotificationReceived += OnNotificationReceived;
|
||||
_notificationService.NotificationRemoved += OnNotificationRemoved;
|
||||
}
|
||||
_notificationService.NotificationReceived += OnNotificationReceived;
|
||||
_notificationService.NotificationRemoved += OnNotificationRemoved;
|
||||
_notificationService.StatusChanged += OnStatusChanged;
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
@@ -89,6 +87,7 @@ public partial class NotificationBoxWidget : UserControl,
|
||||
{
|
||||
_notificationService.NotificationReceived -= OnNotificationReceived;
|
||||
_notificationService.NotificationRemoved -= OnNotificationRemoved;
|
||||
_notificationService.StatusChanged -= OnStatusChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +148,8 @@ public partial class NotificationBoxWidget : UserControl,
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
|
||||
var hasNotifications = _notificationService?.GetNotifications().Count > 0;
|
||||
var notifications = _notificationService?.GetNotifications() ?? [];
|
||||
var hasNotifications = notifications.Count > 0;
|
||||
PrivacyOverlay.IsVisible = _isPrivacyMode && hasNotifications && _notificationService?.GetUnreadCount() > 0;
|
||||
NotificationListPanel.IsVisible = !PrivacyOverlay.IsVisible;
|
||||
|
||||
@@ -157,51 +157,57 @@ public partial class NotificationBoxWidget : UserControl,
|
||||
UnreadBadge.IsVisible = unreadCount > 0;
|
||||
UnreadCountText.Text = unreadCount.ToString();
|
||||
|
||||
ClearButton.IsVisible = _componentSettingsSnapshot.NotificationBoxShowClearButton
|
||||
&& hasNotifications;
|
||||
ClearButton.IsVisible = _componentSettingsSnapshot.NotificationBoxShowClearButton && hasNotifications;
|
||||
|
||||
UpdateStatusText();
|
||||
RenderNotifications();
|
||||
RenderNotifications(notifications);
|
||||
}
|
||||
|
||||
private void RenderNotifications()
|
||||
private void RenderNotifications(IReadOnlyList<NotificationItem> notifications)
|
||||
{
|
||||
NotificationListPanel.Children.Clear();
|
||||
_notificationControls.Clear();
|
||||
|
||||
if (_notificationService == null)
|
||||
{
|
||||
EmptyStateText.IsVisible = true;
|
||||
EmptyStateText.Text = "通知服务未启动";
|
||||
ShowEmptyState("通知服务未启动", canRequestPermission: false);
|
||||
return;
|
||||
}
|
||||
|
||||
var notifications = _notificationService.GetNotifications();
|
||||
|
||||
if (notifications.Count == 0)
|
||||
{
|
||||
EmptyStateText.IsVisible = true;
|
||||
EmptyStateText.Text = "暂无通知";
|
||||
var status = _notificationService.GetStatus();
|
||||
var text = status.State is NotificationBoxServiceState.Running or NotificationBoxServiceState.Degraded
|
||||
? "暂无通知"
|
||||
: status.Message;
|
||||
ShowEmptyState(text, status.CanRequestPermission);
|
||||
return;
|
||||
}
|
||||
|
||||
EmptyStateText.IsVisible = false;
|
||||
EmptyStatePanel.IsVisible = false;
|
||||
PermissionButton.IsVisible = false;
|
||||
|
||||
notifications = ApplySorting(notifications);
|
||||
|
||||
var maxCount = _componentSettingsSnapshot.NotificationBoxMaxDisplayCount;
|
||||
notifications = notifications.Take(maxCount).ToList();
|
||||
var visibleNotifications = ApplySorting(notifications)
|
||||
.Take(Math.Max(1, _componentSettingsSnapshot.NotificationBoxMaxDisplayCount))
|
||||
.ToList();
|
||||
|
||||
if (_componentSettingsSnapshot.NotificationBoxGroupByApp)
|
||||
{
|
||||
RenderGroupedNotifications(notifications);
|
||||
RenderGroupedNotifications(visibleNotifications);
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderFlatNotifications(notifications);
|
||||
RenderFlatNotifications(visibleNotifications);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowEmptyState(string text, bool canRequestPermission)
|
||||
{
|
||||
EmptyStatePanel.IsVisible = true;
|
||||
EmptyStateText.Text = text;
|
||||
PermissionButton.IsVisible = canRequestPermission;
|
||||
}
|
||||
|
||||
private IReadOnlyList<NotificationItem> ApplySorting(IReadOnlyList<NotificationItem> notifications)
|
||||
{
|
||||
return _componentSettingsSnapshot.NotificationBoxSortOrder switch
|
||||
@@ -216,43 +222,37 @@ public partial class NotificationBoxWidget : UserControl,
|
||||
{
|
||||
foreach (var notification in notifications)
|
||||
{
|
||||
var control = CreateNotificationControl(notification);
|
||||
NotificationListPanel.Children.Add(control);
|
||||
_notificationControls.Add(control);
|
||||
AddNotificationControl(notification);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderGroupedNotifications(IReadOnlyList<NotificationItem> notifications)
|
||||
{
|
||||
var grouped = notifications.GroupBy(n => n.AppName).ToList();
|
||||
|
||||
foreach (var group in grouped)
|
||||
foreach (var group in notifications.GroupBy(n => n.AppName))
|
||||
{
|
||||
var groupHeader = new TextBlock
|
||||
NotificationListPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = group.Key,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#8B95A5")),
|
||||
Margin = new Thickness(0, 6, 0, 3)
|
||||
};
|
||||
NotificationListPanel.Children.Add(groupHeader);
|
||||
});
|
||||
|
||||
foreach (var notification in group)
|
||||
{
|
||||
var control = CreateNotificationControl(notification);
|
||||
NotificationListPanel.Children.Add(control);
|
||||
_notificationControls.Add(control);
|
||||
AddNotificationControl(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private NotificationItemControl CreateNotificationControl(NotificationItem notification)
|
||||
private void AddNotificationControl(NotificationItem notification)
|
||||
{
|
||||
var control = new NotificationItemControl(notification, _componentSettingsSnapshot, _isNightVisual);
|
||||
control.Clicked += OnNotificationClicked;
|
||||
control.MarkAsRead += OnMarkAsRead;
|
||||
return control;
|
||||
NotificationListPanel.Children.Add(control);
|
||||
_notificationControls.Add(control);
|
||||
}
|
||||
|
||||
private void OnNotificationReceived(object? sender, NotificationItem notification)
|
||||
@@ -273,8 +273,21 @@ public partial class NotificationBoxWidget : UserControl,
|
||||
});
|
||||
}
|
||||
|
||||
private void OnStatusChanged(object? sender, NotificationBoxStatus status)
|
||||
{
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
RefreshUI();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnNotificationClicked(object? sender, NotificationItem notification)
|
||||
{
|
||||
if (_notificationService?.TryActivate(notification) != true)
|
||||
{
|
||||
StatusTextBlock.Text = "无法打开此通知的来源应用";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMarkAsRead(object? sender, NotificationItem notification)
|
||||
@@ -290,11 +303,26 @@ public partial class NotificationBoxWidget : UserControl,
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void OnPermissionButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_notificationService != null)
|
||||
{
|
||||
await _notificationService.RequestPermissionAsync();
|
||||
RefreshUI();
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void UpdateStatusText()
|
||||
{
|
||||
var total = _notificationService?.GetNotifications().Count ?? 0;
|
||||
var max = _componentSettingsSnapshot.NotificationBoxMaxDisplayCount;
|
||||
StatusTextBlock.Text = $"共 {total} 条" + (total > max ? $"(显{max})" : "");
|
||||
var suffix = total > max ? $"(显示 {max} 条)" : string.Empty;
|
||||
var status = _notificationService?.GetStatus();
|
||||
StatusTextBlock.Text = status is null
|
||||
? $"共 {total} 条{suffix}"
|
||||
: $"{status.Message} · 共 {total} 条{suffix}";
|
||||
}
|
||||
|
||||
private void ClearSelection()
|
||||
@@ -350,23 +378,27 @@ public class NotificationItemControl : Border
|
||||
_settings = settings;
|
||||
_isNightVisual = isNightVisual;
|
||||
|
||||
Background = _item.IsRead
|
||||
? new SolidColorBrush(isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F5F5F5"))
|
||||
: new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#FFFFFF"));
|
||||
CornerRadius = new CornerRadius(6);
|
||||
Padding = new Thickness(10, 6);
|
||||
Cursor = new Cursor(StandardCursorType.Hand);
|
||||
BorderBrush = _item.IsRead
|
||||
? new SolidColorBrush(Colors.Transparent)
|
||||
: new SolidColorBrush(Color.Parse("#E24B2D"));
|
||||
BorderThickness = _item.IsRead ? new Thickness(0) : new Thickness(2, 0, 0, 0);
|
||||
|
||||
UpdateChrome();
|
||||
BuildUI();
|
||||
|
||||
PointerPressed += OnPointerPressed;
|
||||
PointerReleased += OnPointerReleased;
|
||||
}
|
||||
|
||||
private void UpdateChrome()
|
||||
{
|
||||
Background = _item.IsRead
|
||||
? new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F5F5F5"))
|
||||
: new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#FFFFFF"));
|
||||
BorderBrush = _item.IsRead
|
||||
? new SolidColorBrush(Colors.Transparent)
|
||||
: new SolidColorBrush(Color.Parse("#E24B2D"));
|
||||
BorderThickness = _item.IsRead ? new Thickness(0) : new Thickness(2, 0, 0, 0);
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var grid = new Grid { ColumnDefinitions = ColumnDefinitions.Parse("Auto,*,Auto") };
|
||||
@@ -381,7 +413,7 @@ public class NotificationItemControl : Border
|
||||
Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#4D5560") : Color.Parse("#E8EAED")),
|
||||
Margin = new Thickness(0, 0, 8, 0)
|
||||
};
|
||||
var iconText = new TextBlock
|
||||
iconBorder.Child = new TextBlock
|
||||
{
|
||||
Text = _item.AppName.Length > 0 ? _item.AppName[0].ToString() : "?",
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
@@ -390,51 +422,46 @@ public class NotificationItemControl : Border
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"))
|
||||
};
|
||||
iconBorder.Child = iconText;
|
||||
grid.Children.Add(iconBorder);
|
||||
}
|
||||
|
||||
var contentPanel = new StackPanel { Spacing = 1 };
|
||||
Grid.SetColumn(contentPanel, 1);
|
||||
|
||||
var titleBlock = new TextBlock
|
||||
contentPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = _item.Title,
|
||||
Text = string.IsNullOrWhiteSpace(_item.Title) ? _item.AppName : _item.Title,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
FontSize = 12,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 1,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"))
|
||||
};
|
||||
});
|
||||
|
||||
var contentBlock = new TextBlock
|
||||
{
|
||||
Text = _item.Content,
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671")),
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
contentPanel.Children.Add(titleBlock);
|
||||
if (!string.IsNullOrWhiteSpace(_item.Content))
|
||||
{
|
||||
contentPanel.Children.Add(contentBlock);
|
||||
contentPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = _item.Content,
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671")),
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
});
|
||||
}
|
||||
|
||||
grid.Children.Add(contentPanel);
|
||||
|
||||
if (_settings.NotificationBoxShowTimestamp)
|
||||
{
|
||||
var timeText = _settings.NotificationBoxTimeFormat == "Relative"
|
||||
? GetRelativeTime(_item.ReceivedTime)
|
||||
: _item.ReceivedTime.ToString("HH:mm");
|
||||
|
||||
var timeBlock = new TextBlock
|
||||
{
|
||||
Text = timeText,
|
||||
Text = _settings.NotificationBoxTimeFormat == "Relative"
|
||||
? GetRelativeTime(_item.ReceivedTime)
|
||||
: _item.ReceivedTime.ToString("HH:mm"),
|
||||
FontSize = 10,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#8B95A5")),
|
||||
Foreground = new SolidColorBrush(Color.Parse("#8B95A5")),
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
|
||||
Margin = new Thickness(6, 0, 0, 0)
|
||||
};
|
||||
@@ -448,23 +475,7 @@ public class NotificationItemControl : Border
|
||||
public void UpdateTheme(bool isNightVisual, double fontScale)
|
||||
{
|
||||
_isNightVisual = isNightVisual;
|
||||
Background = _item.IsRead
|
||||
? new SolidColorBrush(isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F5F5F5"))
|
||||
: new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#FFFFFF"));
|
||||
|
||||
if (Child is Grid grid)
|
||||
{
|
||||
foreach (var child in grid.Children)
|
||||
{
|
||||
if (child is StackPanel panel)
|
||||
{
|
||||
foreach (var textBlock in panel.Children.OfType<TextBlock>())
|
||||
{
|
||||
textBlock.FontSize *= fontScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
UpdateChrome();
|
||||
}
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
@@ -502,9 +513,9 @@ public class NotificationItemControl : Border
|
||||
var diff = DateTime.Now - time;
|
||||
|
||||
if (diff.TotalMinutes < 1) return "刚刚";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}分前";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}小时前";
|
||||
return $"{(int)diff.TotalDays}天前";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes} 分钟前";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours} 小时前";
|
||||
return $"{(int)diff.TotalDays} 天前";
|
||||
}
|
||||
|
||||
public bool IsSelected { get; set; }
|
||||
@@ -512,18 +523,3 @@ public class NotificationItemControl : Border
|
||||
public event EventHandler<NotificationItem>? Clicked;
|
||||
public event EventHandler<NotificationItem>? MarkAsRead;
|
||||
}
|
||||
|
||||
public static class NotificationListenerServiceProvider
|
||||
{
|
||||
private static NotificationListenerService? _instance;
|
||||
|
||||
public static NotificationListenerService GetOrCreate(ISettingsService settingsService)
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new NotificationListenerService(settingsService);
|
||||
_instance.InitializeAsync().ConfigureAwait(false);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
97
LanMountainDesktop/Views/Components/SubjectColorService.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
internal static class SubjectColorService
|
||||
{
|
||||
private static readonly (string Keyword, string Hex)[] Palette =
|
||||
[
|
||||
("语文", "#5B8FF9"),
|
||||
("数学", "#F6903D"),
|
||||
("英语", "#5AD8A6"),
|
||||
("物理", "#E8684A"),
|
||||
("化学", "#9270CA"),
|
||||
("生物", "#FF9845"),
|
||||
("历史", "#1E9493"),
|
||||
("地理", "#FF99C3"),
|
||||
("政治", "#7262FD"),
|
||||
("体育", "#78D3F8"),
|
||||
("音乐", "#F25E7E"),
|
||||
("美术", "#C2A1FD"),
|
||||
];
|
||||
|
||||
private const string DefaultHex = "#8B95A5";
|
||||
|
||||
public static Color ResolveColor(string subjectName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(subjectName))
|
||||
{
|
||||
foreach (var (keyword, hex) in Palette)
|
||||
{
|
||||
if (subjectName.Contains(keyword, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Color.Parse(hex);
|
||||
}
|
||||
}
|
||||
|
||||
var hash = StableHash(subjectName);
|
||||
var index = (int)(hash % (uint)Palette.Length);
|
||||
return Color.Parse(Palette[index].Hex);
|
||||
}
|
||||
|
||||
return Color.Parse(DefaultHex);
|
||||
}
|
||||
|
||||
public static Color ResolveBackgroundColor(string subjectName, bool isCurrent)
|
||||
{
|
||||
var baseColor = ResolveColor(subjectName);
|
||||
var alpha = isCurrent ? 0.18 : 0.08;
|
||||
return new Color(
|
||||
(byte)(alpha * 255),
|
||||
baseColor.R,
|
||||
baseColor.G,
|
||||
baseColor.B);
|
||||
}
|
||||
|
||||
public static Color ResolveForegroundColor(string subjectName, bool isNight)
|
||||
{
|
||||
var baseColor = ResolveColor(subjectName);
|
||||
if (isNight)
|
||||
{
|
||||
return new Color(
|
||||
0xFF,
|
||||
(byte)Math.Min(255, baseColor.R + 60),
|
||||
(byte)Math.Min(255, baseColor.G + 60),
|
||||
(byte)Math.Min(255, baseColor.B + 60));
|
||||
}
|
||||
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
public static IBrush ResolveColorBrush(string subjectName)
|
||||
{
|
||||
return new SolidColorBrush(ResolveColor(subjectName));
|
||||
}
|
||||
|
||||
public static IBrush ResolveBackgroundBrush(string subjectName, bool isCurrent)
|
||||
{
|
||||
return new SolidColorBrush(ResolveBackgroundColor(subjectName, isCurrent));
|
||||
}
|
||||
|
||||
public static IBrush ResolveForegroundBrush(string subjectName, bool isNight)
|
||||
{
|
||||
return new SolidColorBrush(ResolveForegroundColor(subjectName, isNight));
|
||||
}
|
||||
|
||||
private static uint StableHash(string input)
|
||||
{
|
||||
uint hash = 5381;
|
||||
foreach (var c in input)
|
||||
{
|
||||
hash = ((hash << 5) + hash) ^ (uint)c;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<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="260"
|
||||
d:DesignHeight="120"
|
||||
x:Class="LanMountainDesktop.Views.Components.WeatherClockWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Padding="14,12">
|
||||
<Grid x:Name="ContentGrid"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<StackPanel x:Name="LeftStack"
|
||||
ClipToBounds="True"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="TimeTextBlock"
|
||||
Text="15:07"
|
||||
FontSize="24"
|
||||
FontWeight="Bold"
|
||||
Foreground="#10131A"
|
||||
MaxLines="1"
|
||||
TextWrapping="NoWrap"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<StackPanel x:Name="DateWeatherStack"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="DateTextBlock"
|
||||
Text="8月14日"
|
||||
FontSize="14"
|
||||
FontWeight="Regular"
|
||||
Foreground="#7A7E87"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
TextWrapping="NoWrap"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Image x:Name="WeatherIconImage"
|
||||
Width="18"
|
||||
Height="18"
|
||||
VerticalAlignment="Center"
|
||||
Stretch="Uniform" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Border x:Name="AnalogDialBorder"
|
||||
Grid.Column="1"
|
||||
Width="52"
|
||||
Height="52"
|
||||
CornerRadius="26"
|
||||
Background="#F8FAFF"
|
||||
BorderBrush="#12000000"
|
||||
BorderThickness="1"
|
||||
VerticalAlignment="Center">
|
||||
<Viewbox Stretch="Uniform">
|
||||
<Grid Width="104"
|
||||
Height="104">
|
||||
<Canvas x:Name="TickCanvas"
|
||||
Width="104"
|
||||
Height="104"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Canvas x:Name="HandsCanvas"
|
||||
Width="104"
|
||||
Height="104"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Ellipse x:Name="CenterDotOuter"
|
||||
Width="12"
|
||||
Height="12"
|
||||
Fill="#4F7CC0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Ellipse x:Name="CenterDotInner"
|
||||
Width="5"
|
||||
Height="5"
|
||||
Fill="#1A74F2"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -1,758 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentChromeContextAware
|
||||
{
|
||||
private sealed record WeatherClockConfig(
|
||||
string LanguageCode,
|
||||
string Locale,
|
||||
string LocationKey,
|
||||
double Latitude,
|
||||
double Longitude);
|
||||
|
||||
private const double DialDesignSize = 104;
|
||||
private const double DialCenter = DialDesignSize / 2d;
|
||||
|
||||
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _clockTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
||||
private readonly DispatcherTimer _weatherRefreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMinutes(12)
|
||||
};
|
||||
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly Line _hourHandLine = CreateHandLine("#232938", 4.0);
|
||||
private readonly Line _minuteHandLine = CreateHandLine("#2F3749", 2.8);
|
||||
private readonly Line _secondHandLine = CreateHandLine("#1A74F2", 1.9);
|
||||
|
||||
private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService;
|
||||
private TimeZoneService? _timeZoneService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private double _currentCellSize = 48;
|
||||
private ComponentChromeContext? _chromeContext;
|
||||
private bool _isAttached;
|
||||
private bool _dialInitialized;
|
||||
private bool _handsInitialized;
|
||||
private bool _isRefreshing;
|
||||
private bool _weatherAutoRefreshEnabled = true;
|
||||
private bool? _isNightModeApplied;
|
||||
private string _languageCode = "zh-CN";
|
||||
private HyperOS3WeatherVisualKind _activeVisualKind = HyperOS3WeatherVisualKind.CloudyDay;
|
||||
private string _componentId = BuiltInComponentIds.DesktopWeatherClock;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
public WeatherClockWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_clockTimer.Tick += OnClockTimerTick;
|
||||
_weatherRefreshTimer.Tick += OnWeatherRefreshTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
|
||||
InitializeDialIfNeeded();
|
||||
InitializeHandsIfNeeded();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyDefaultWeatherIcon();
|
||||
UpdateClockVisual();
|
||||
ApplyAutoRefreshSettings();
|
||||
}
|
||||
|
||||
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||
{
|
||||
ClearTimeZoneService();
|
||||
_timeZoneService = timeZoneService;
|
||||
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
|
||||
UpdateClockVisual();
|
||||
}
|
||||
|
||||
public void ClearTimeZoneService()
|
||||
{
|
||||
if (_timeZoneService is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
|
||||
_timeZoneService = null;
|
||||
}
|
||||
|
||||
public void SetWeatherInfoService(IWeatherInfoService weatherInfoService)
|
||||
{
|
||||
_weatherInfoService = weatherInfoService ?? DefaultWeatherInfoService;
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopWeatherClock
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentChromeContext(ComponentChromeContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_chromeContext = context;
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.WeatherClock2x1);
|
||||
var scale = ResolveScale();
|
||||
var targetHeight = Bounds.Height > 1
|
||||
? Math.Clamp(Bounds.Height, 38, 160)
|
||||
: Math.Clamp(_currentCellSize * 0.92, 38, 120);
|
||||
var targetWidth = Bounds.Width > 1
|
||||
? Math.Clamp(Bounds.Width, 48, 520)
|
||||
: Math.Clamp(_currentCellSize * 2.15, 88, 260);
|
||||
var compactness = Math.Clamp((176 - targetWidth) / 86d, 0, 1);
|
||||
var ultraCompact = targetWidth < 126 || targetHeight < 46;
|
||||
var compactFactor = Lerp(1, ultraCompact ? 0.64 : 0.72, compactness);
|
||||
var mainRectangleCornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius(_chromeContext);
|
||||
|
||||
var horizontalPadding = ComponentChromeCornerRadiusHelper.SafeValue(
|
||||
targetHeight * Lerp(0.18, 0.12, compactness),
|
||||
5,
|
||||
30,
|
||||
_chromeContext);
|
||||
var verticalPadding = ComponentChromeCornerRadiusHelper.SafeValue(
|
||||
targetHeight * Lerp(0.14, 0.10, compactness),
|
||||
3,
|
||||
20,
|
||||
_chromeContext);
|
||||
|
||||
RootBorder.CornerRadius = mainRectangleCornerRadius;
|
||||
RootBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||
|
||||
var columnSpacing = Math.Clamp(targetHeight * Lerp(0.16, 0.08, compactness), 2, 22);
|
||||
LeftStack.Spacing = Math.Clamp(targetHeight * Lerp(0.06, 0.04, compactness), 1.5, 10);
|
||||
DateWeatherStack.Spacing = Math.Clamp(targetHeight * Lerp(0.10, 0.06, compactness), 3, 14);
|
||||
|
||||
var contentHeight = Math.Max(24, targetHeight - (verticalPadding * 2));
|
||||
var contentWidth = Math.Max(48, targetWidth - (horizontalPadding * 2));
|
||||
var minimumLeftWidth = Math.Clamp(contentWidth * Lerp(0.56, 0.64, compactness), ultraCompact ? 34 : 52, 360);
|
||||
var maxDialByWidth = Math.Max(0, contentWidth - minimumLeftWidth - columnSpacing);
|
||||
var dialByHeight = contentHeight * Lerp(0.94, 0.82, compactness);
|
||||
var dialMinSize = ultraCompact ? 14 : 20;
|
||||
var dialSize = Math.Min(dialByHeight, maxDialByWidth);
|
||||
if (dialSize < dialMinSize && maxDialByWidth >= dialMinSize * 0.8)
|
||||
{
|
||||
dialSize = dialMinSize;
|
||||
}
|
||||
|
||||
dialSize = Math.Clamp(dialSize, 0, 140);
|
||||
var showDial = dialSize >= 12;
|
||||
if (!showDial)
|
||||
{
|
||||
dialSize = 0;
|
||||
columnSpacing = 0;
|
||||
}
|
||||
|
||||
var leftContentWidth = Math.Max(0, contentWidth - (showDial ? dialSize + columnSpacing : 0));
|
||||
if (showDial && leftContentWidth < 26)
|
||||
{
|
||||
var fittedDial = Math.Max(12, Math.Min(dialSize, Math.Max(0, contentWidth - columnSpacing - 26)));
|
||||
dialSize = fittedDial;
|
||||
leftContentWidth = Math.Max(0, contentWidth - dialSize - columnSpacing);
|
||||
if (leftContentWidth < 20)
|
||||
{
|
||||
showDial = false;
|
||||
dialSize = 0;
|
||||
columnSpacing = 0;
|
||||
leftContentWidth = contentWidth;
|
||||
}
|
||||
}
|
||||
|
||||
ContentGrid.ColumnSpacing = showDial ? columnSpacing : 0;
|
||||
if (ContentGrid.ColumnDefinitions.Count >= 2)
|
||||
{
|
||||
ContentGrid.ColumnDefinitions[0].Width = new GridLength(leftContentWidth, GridUnitType.Pixel);
|
||||
ContentGrid.ColumnDefinitions[1].Width = new GridLength(showDial ? dialSize : 0, GridUnitType.Pixel);
|
||||
}
|
||||
|
||||
var timeTextWidth = leftContentWidth * 0.92;
|
||||
var timeCharCount = 5;
|
||||
var maxTimeFontSize = timeTextWidth / (timeCharCount * 0.58);
|
||||
var baseTimeFontSize = Math.Clamp(maxTimeFontSize, 12, 48);
|
||||
var timeFontSize = Math.Clamp(baseTimeFontSize * scale * compactFactor, 10, 48);
|
||||
TimeTextBlock.FontSize = timeFontSize;
|
||||
|
||||
var dateFontSize = Math.Clamp(timeFontSize * 0.48, 8, 22);
|
||||
DateTextBlock.FontSize = dateFontSize;
|
||||
|
||||
var weatherIconSize = Math.Clamp(dateFontSize * 1.1, 10, 24);
|
||||
WeatherIconImage.Width = weatherIconSize;
|
||||
WeatherIconImage.Height = weatherIconSize;
|
||||
|
||||
TimeTextBlock.FontWeight = ToVariableWeight(Lerp(620, 760, Math.Clamp((scale - 0.68) / 1.35, 0, 1)));
|
||||
DateTextBlock.FontWeight = ToVariableWeight(Lerp(540, 680, Math.Clamp((scale - 0.68) / 1.35, 0, 1)));
|
||||
|
||||
LeftStack.Width = leftContentWidth;
|
||||
LeftStack.MaxWidth = leftContentWidth;
|
||||
DateWeatherStack.MaxWidth = leftContentWidth;
|
||||
TimeTextBlock.MaxWidth = timeTextWidth;
|
||||
|
||||
var showDateLine = leftContentWidth >= Math.Max(36, timeFontSize * 1.4) && contentHeight >= 38;
|
||||
DateWeatherStack.IsVisible = showDateLine;
|
||||
WeatherIconImage.IsVisible = showDateLine && leftContentWidth >= Math.Max(48, dateFontSize * 3.2);
|
||||
|
||||
var dateReservedWidth = WeatherIconImage.IsVisible
|
||||
? weatherIconSize + DateWeatherStack.Spacing
|
||||
: 0;
|
||||
DateTextBlock.MaxWidth = Math.Max(12, leftContentWidth - dateReservedWidth);
|
||||
|
||||
AnalogDialBorder.IsVisible = showDial;
|
||||
AnalogDialBorder.Width = dialSize;
|
||||
AnalogDialBorder.Height = dialSize;
|
||||
AnalogDialBorder.CornerRadius = new CornerRadius(dialSize / 2d);
|
||||
|
||||
ApplyModeVisualIfNeeded();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
UpdateClockVisual();
|
||||
_clockTimer.Start();
|
||||
UpdateWeatherRefreshTimerState();
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_clockTimer.Stop();
|
||||
_weatherRefreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_isNightModeApplied = null;
|
||||
ApplyModeVisualIfNeeded();
|
||||
}
|
||||
|
||||
private void OnClockTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
UpdateClockVisual();
|
||||
}
|
||||
|
||||
private async void OnWeatherRefreshTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnTimeZoneChanged(object? sender, EventArgs e)
|
||||
{
|
||||
UpdateClockVisual();
|
||||
}
|
||||
|
||||
private async Task RefreshWeatherAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
var config = LoadConfig();
|
||||
_languageCode = config.LanguageCode;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.LocationKey))
|
||||
{
|
||||
ApplyDefaultWeatherIcon();
|
||||
_isRefreshing = false;
|
||||
UpdateClockVisual();
|
||||
return;
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var query = new WeatherQuery(
|
||||
LocationKey: config.LocationKey,
|
||||
Latitude: config.Latitude,
|
||||
Longitude: config.Longitude,
|
||||
ForecastDays: 1,
|
||||
Locale: config.Locale,
|
||||
ForceRefresh: forceRefresh);
|
||||
|
||||
var result = await _weatherInfoService.GetWeatherAsync(query, cts.Token);
|
||||
if (cts.IsCancellationRequested || !_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success || result.Data is null)
|
||||
{
|
||||
ApplyDefaultWeatherIcon();
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyWeatherSnapshot(result.Data);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore canceled refresh requests.
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!cts.IsCancellationRequested && _isAttached)
|
||||
{
|
||||
ApplyDefaultWeatherIcon();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCts, cts))
|
||||
{
|
||||
_refreshCts = null;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
_isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyWeatherSnapshot(WeatherSnapshot snapshot)
|
||||
{
|
||||
var isNight = ResolveIsNight(snapshot);
|
||||
_activeVisualKind = HyperOS3WeatherTheme.ResolveVisualKind(snapshot.Current.WeatherCode, isNight);
|
||||
WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(_activeVisualKind));
|
||||
}
|
||||
|
||||
private void ApplyDefaultWeatherIcon()
|
||||
{
|
||||
var isNight = IsNightNow();
|
||||
_activeVisualKind = isNight ? HyperOS3WeatherVisualKind.ClearNight : HyperOS3WeatherVisualKind.CloudyDay;
|
||||
WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(_activeVisualKind));
|
||||
}
|
||||
|
||||
private void UpdateClockVisual()
|
||||
{
|
||||
ApplyModeVisualIfNeeded();
|
||||
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
TimeTextBlock.Text = now.ToString("HH:mm", CultureInfo.CurrentCulture);
|
||||
DateTextBlock.Text = FormatDate(now);
|
||||
|
||||
var hourAngle = (now.Hour % 12 + now.Minute / 60d + now.Second / 3600d) * 30d;
|
||||
var minuteAngle = (now.Minute + now.Second / 60d) * 6d;
|
||||
var secondAngle = (now.Second + now.Millisecond / 1000d) * 6d;
|
||||
|
||||
SetHandGeometry(_hourHandLine, hourAngle, forwardLength: 23.5, backwardLength: 5.0);
|
||||
SetHandGeometry(_minuteHandLine, minuteAngle, forwardLength: 33.5, backwardLength: 6.5);
|
||||
SetHandGeometry(_secondHandLine, secondAngle, forwardLength: 39.0, backwardLength: 10.0);
|
||||
}
|
||||
|
||||
private void InitializeDialIfNeeded()
|
||||
{
|
||||
if (_dialInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BuildTicks(isNightMode: false);
|
||||
_dialInitialized = true;
|
||||
}
|
||||
|
||||
private void InitializeHandsIfNeeded()
|
||||
{
|
||||
if (_handsInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HandsCanvas.Children.Clear();
|
||||
HandsCanvas.Children.Add(_hourHandLine);
|
||||
HandsCanvas.Children.Add(_minuteHandLine);
|
||||
HandsCanvas.Children.Add(_secondHandLine);
|
||||
_handsInitialized = true;
|
||||
}
|
||||
|
||||
private void BuildTicks(bool isNightMode)
|
||||
{
|
||||
TickCanvas.Children.Clear();
|
||||
var tickColor = isNightMode ? "#CED7EA" : "#1C2333";
|
||||
|
||||
for (var i = 0; i < 12; i++)
|
||||
{
|
||||
var angle = (i * 30 - 90) * Math.PI / 180d;
|
||||
var isMajor = i % 3 == 0;
|
||||
var outerRadius = DialCenter - 8;
|
||||
var innerRadius = outerRadius - (isMajor ? 13.5 : 9.5);
|
||||
|
||||
var x1 = DialCenter + Math.Cos(angle) * innerRadius;
|
||||
var y1 = DialCenter + Math.Sin(angle) * innerRadius;
|
||||
var x2 = DialCenter + Math.Cos(angle) * outerRadius;
|
||||
var y2 = DialCenter + Math.Sin(angle) * outerRadius;
|
||||
|
||||
TickCanvas.Children.Add(new Line
|
||||
{
|
||||
StartPoint = new Point(x1, y1),
|
||||
EndPoint = new Point(x2, y2),
|
||||
Stroke = CreateBrush(tickColor),
|
||||
StrokeThickness = isMajor ? 2.8 : 1.9,
|
||||
StrokeLineCap = PenLineCap.Round
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyModeVisualIfNeeded()
|
||||
{
|
||||
var isNightMode = ResolveIsNightMode();
|
||||
if (_isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isNightModeApplied = isNightMode;
|
||||
ApplyModeVisual(isNightMode);
|
||||
}
|
||||
|
||||
private void ApplyModeVisual(bool isNightMode)
|
||||
{
|
||||
var gradientFrom = isNightMode ? "#2A3346" : "#FFFFFF";
|
||||
var gradientTo = isNightMode ? "#202A3B" : "#F6F8FC";
|
||||
var dialSurface = isNightMode ? "#1B2434" : "#F8FAFF";
|
||||
var backgroundSamples = WeatherTypographyAccessibility.BuildBackgroundSamples(
|
||||
gradientFrom,
|
||||
gradientTo,
|
||||
dialSurface,
|
||||
isNightMode);
|
||||
|
||||
RootBorder.Background = CreateGradientBrush(gradientFrom, gradientTo);
|
||||
RootBorder.BorderBrush = CreateBrush(isNightMode ? "#36F2F5FF" : "#14000000");
|
||||
|
||||
AnalogDialBorder.Background = isNightMode
|
||||
? CreateBrush("#1B2434")
|
||||
: CreateBrush("#F8FAFF");
|
||||
AnalogDialBorder.BorderBrush = CreateBrush(isNightMode ? "#34DDE7FF" : "#12000000");
|
||||
|
||||
if (isNightMode)
|
||||
{
|
||||
TimeTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
"#F8FBFF",
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast);
|
||||
DateTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
"#BCC8DD",
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast);
|
||||
}
|
||||
else
|
||||
{
|
||||
TimeTextBlock.Foreground = CreateBrush("#10131A");
|
||||
DateTextBlock.Foreground = CreateBrush("#7A7E87");
|
||||
}
|
||||
|
||||
_hourHandLine.Stroke = CreateBrush(isNightMode ? "#F1F5FF" : "#232938");
|
||||
_minuteHandLine.Stroke = CreateBrush(isNightMode ? "#D6E0F2" : "#2F3749");
|
||||
_secondHandLine.Stroke = CreateBrush("#1A74F2");
|
||||
CenterDotOuter.Fill = CreateBrush(isNightMode ? "#7BAAE8" : "#4F7CC0");
|
||||
CenterDotInner.Fill = CreateBrush("#1A74F2");
|
||||
|
||||
BuildTicks(isNightMode);
|
||||
}
|
||||
|
||||
private WeatherClockConfig LoadConfig()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
var locale = string.Equals(languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
|
||||
? "zh_cn"
|
||||
: "en_us";
|
||||
|
||||
var latitude = NormalizeLatitude(snapshot.WeatherLatitude);
|
||||
var longitude = NormalizeLongitude(snapshot.WeatherLongitude);
|
||||
var locationKey = snapshot.WeatherLocationKey?.Trim() ?? string.Empty;
|
||||
|
||||
var modeIsCoordinates = string.Equals(
|
||||
snapshot.WeatherLocationMode,
|
||||
"Coordinates",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
if (modeIsCoordinates && string.IsNullOrWhiteSpace(locationKey))
|
||||
{
|
||||
locationKey = BuildCoordinateLocationKey(latitude, longitude);
|
||||
}
|
||||
|
||||
return new WeatherClockConfig(
|
||||
LanguageCode: languageCode,
|
||||
Locale: locale,
|
||||
LocationKey: locationKey,
|
||||
Latitude: latitude,
|
||||
Longitude: longitude);
|
||||
}
|
||||
|
||||
private string FormatDate(DateTime dateTime)
|
||||
{
|
||||
var isZh = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase);
|
||||
if (isZh)
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{dateTime.Month}\u6708{dateTime.Day}\u65e5");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var culture = CultureInfo.GetCultureInfo(_languageCode);
|
||||
return dateTime.ToString("MMM d", culture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return dateTime.ToString("MMM d", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 2.20);
|
||||
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 56d, 0.65, 2.80) : 1;
|
||||
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 180d, 0.65, 2.80) : 1;
|
||||
return Math.Clamp(Math.Min(heightScale, widthScale) * 1.02 * cellScale, 0.62, 2.40);
|
||||
}
|
||||
|
||||
private bool ResolveIsNight(WeatherSnapshot snapshot)
|
||||
{
|
||||
return HyperOS3WeatherTheme.ResolveIsNightPreferred(
|
||||
snapshot,
|
||||
_timeZoneService?.CurrentTimeZone,
|
||||
_timeZoneService?.GetCurrentTime() ?? DateTime.Now);
|
||||
}
|
||||
|
||||
private bool IsNightNow()
|
||||
{
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
return now.Hour < 6 || now.Hour >= 18;
|
||||
}
|
||||
|
||||
private bool ResolveIsNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ActualThemeVariant == ThemeVariant.Light)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||
value is ISolidColorBrush solidBrush)
|
||||
{
|
||||
return CalculateRelativeLuminance(solidBrush.Color) < 0.45;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void SetHandGeometry(Line hand, double angleDeg, double forwardLength, double backwardLength)
|
||||
{
|
||||
var radians = (angleDeg - 90) * Math.PI / 180d;
|
||||
var cos = Math.Cos(radians);
|
||||
var sin = Math.Sin(radians);
|
||||
|
||||
hand.StartPoint = new Point(
|
||||
DialCenter - (cos * backwardLength),
|
||||
DialCenter - (sin * backwardLength));
|
||||
hand.EndPoint = new Point(
|
||||
DialCenter + (cos * forwardLength),
|
||||
DialCenter + (sin * forwardLength));
|
||||
}
|
||||
|
||||
private static Line CreateHandLine(string colorHex, double thickness)
|
||||
{
|
||||
return new Line
|
||||
{
|
||||
StartPoint = new Point(DialCenter, DialCenter),
|
||||
EndPoint = new Point(DialCenter, DialCenter - 32),
|
||||
Stroke = CreateBrush(colorHex),
|
||||
StrokeThickness = thickness,
|
||||
StrokeLineCap = PenLineCap.Round
|
||||
};
|
||||
}
|
||||
|
||||
private static IBrush CreateBrush(string colorHex)
|
||||
{
|
||||
return new SolidColorBrush(Color.Parse(colorHex));
|
||||
}
|
||||
|
||||
private static IBrush CreateGradientBrush(string fromHex, string toHex)
|
||||
{
|
||||
return new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops = new GradientStops
|
||||
{
|
||||
new GradientStop(Color.Parse(fromHex), 0),
|
||||
new GradientStop(Color.Parse(toHex), 1)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static double Lerp(double from, double to, double t)
|
||||
{
|
||||
return from + ((to - from) * t);
|
||||
}
|
||||
|
||||
private static FontWeight ToVariableWeight(double weight)
|
||||
{
|
||||
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
|
||||
}
|
||||
|
||||
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 static string BuildCoordinateLocationKey(double latitude, double longitude)
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"coord:{latitude:F4},{longitude:F4}");
|
||||
}
|
||||
|
||||
private static double NormalizeLatitude(double value)
|
||||
{
|
||||
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||
{
|
||||
return 39.9042;
|
||||
}
|
||||
|
||||
return Math.Clamp(value, -90, 90);
|
||||
}
|
||||
|
||||
private static double NormalizeLongitude(double value)
|
||||
{
|
||||
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||
{
|
||||
return 116.4074;
|
||||
}
|
||||
|
||||
return Math.Clamp(value, -180, 180);
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 12;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
enabled = snapshot.WeatherAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_weatherAutoRefreshEnabled = enabled;
|
||||
_weatherRefreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
UpdateWeatherRefreshTimerState();
|
||||
}
|
||||
|
||||
private void UpdateWeatherRefreshTimerState()
|
||||
{
|
||||
if (_isAttached && _weatherAutoRefreshEnabled)
|
||||
{
|
||||
if (!_weatherRefreshTimer.IsEnabled)
|
||||
{
|
||||
_weatherRefreshTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_weatherRefreshTimer.Stop();
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 12;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(12);
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
cts?.Cancel();
|
||||
cts?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
internal static class WeatherTypographyAccessibility
|
||||
{
|
||||
// WCAG-inspired targets used by the project theme system.
|
||||
public const double WcagNormalTextContrast = 4.5;
|
||||
public const double WcagLargeTextContrast = 3.0;
|
||||
private const double LightTextLuminanceFloor = 0.58;
|
||||
|
||||
public static IReadOnlyList<Color> BuildBackgroundSamples(
|
||||
string gradientFromHex,
|
||||
string gradientToHex,
|
||||
string tintHex,
|
||||
bool isNightVisual)
|
||||
{
|
||||
var from = Color.Parse(gradientFromHex);
|
||||
var to = Color.Parse(gradientToHex);
|
||||
var tint = Color.Parse(tintHex);
|
||||
var mid = ColorMath.Blend(from, to, 0.52);
|
||||
var tinted = ColorMath.Blend(mid, tint, isNightVisual ? 0.34 : 0.28);
|
||||
var shaded = ColorMath.Blend(tinted, Color.Parse("#FF0B1220"), isNightVisual ? 0.24 : 0.16);
|
||||
var lightProbe = ColorMath.Blend(mid, Color.Parse("#FFFFFFFF"), 0.12);
|
||||
|
||||
return
|
||||
[
|
||||
from,
|
||||
to,
|
||||
mid,
|
||||
tinted,
|
||||
shaded,
|
||||
lightProbe
|
||||
];
|
||||
}
|
||||
|
||||
public static IBrush CreateReadableBrush(
|
||||
string preferredHex,
|
||||
IReadOnlyList<Color> backgroundSamples,
|
||||
double minRatio,
|
||||
byte desiredAlpha = 0xFF)
|
||||
{
|
||||
var preferred = Color.Parse(preferredHex);
|
||||
return new SolidColorBrush(CreateReadableColor(preferred, backgroundSamples, minRatio, desiredAlpha));
|
||||
}
|
||||
|
||||
private static Color CreateReadableColor(
|
||||
Color preferred,
|
||||
IReadOnlyList<Color> backgroundSamples,
|
||||
double minRatio,
|
||||
byte desiredAlpha)
|
||||
{
|
||||
var lightPreferred = EnsureLightTone(Color.FromArgb(0xFF, preferred.R, preferred.G, preferred.B));
|
||||
if (backgroundSamples.Count == 0)
|
||||
{
|
||||
return desiredAlpha >= 0xFF
|
||||
? lightPreferred
|
||||
: Color.FromArgb(desiredAlpha, lightPreferred.R, lightPreferred.G, lightPreferred.B);
|
||||
}
|
||||
|
||||
var opaque = EnsureContrastPreservingTone(lightPreferred, backgroundSamples, minRatio);
|
||||
if (desiredAlpha >= 0xFF)
|
||||
{
|
||||
return Color.FromArgb(0xFF, opaque.R, opaque.G, opaque.B);
|
||||
}
|
||||
|
||||
var alpha = AdjustAlphaForContrast(opaque, backgroundSamples, minRatio, desiredAlpha);
|
||||
return Color.FromArgb(alpha, opaque.R, opaque.G, opaque.B);
|
||||
}
|
||||
|
||||
private static Color EnsureContrastPreservingTone(
|
||||
Color preferred,
|
||||
IReadOnlyList<Color> backgroundSamples,
|
||||
double minRatio)
|
||||
{
|
||||
if (MinContrastRatio(preferred, backgroundSamples) >= minRatio)
|
||||
{
|
||||
return preferred;
|
||||
}
|
||||
|
||||
var white = Color.Parse("#FFFFFFFF");
|
||||
|
||||
if (TryFindBlendRatio(preferred, white, backgroundSamples, minRatio, out var whiteDelta))
|
||||
{
|
||||
return ColorMath.Blend(preferred, white, whiteDelta);
|
||||
}
|
||||
|
||||
// Enforce light typography: never fall back to dark text.
|
||||
return white;
|
||||
}
|
||||
|
||||
private static bool TryFindBlendRatio(
|
||||
Color source,
|
||||
Color target,
|
||||
IReadOnlyList<Color> backgroundSamples,
|
||||
double minRatio,
|
||||
out double blendRatio)
|
||||
{
|
||||
if (MinContrastRatio(target, backgroundSamples) < minRatio)
|
||||
{
|
||||
blendRatio = double.PositiveInfinity;
|
||||
return false;
|
||||
}
|
||||
|
||||
var low = 0d;
|
||||
var high = 1d;
|
||||
for (var i = 0; i < 16; i++)
|
||||
{
|
||||
var mid = (low + high) / 2d;
|
||||
var candidate = ColorMath.Blend(source, target, mid);
|
||||
if (MinContrastRatio(candidate, backgroundSamples) >= minRatio)
|
||||
{
|
||||
high = mid;
|
||||
}
|
||||
else
|
||||
{
|
||||
low = mid;
|
||||
}
|
||||
}
|
||||
|
||||
blendRatio = high;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte AdjustAlphaForContrast(
|
||||
Color opaqueColor,
|
||||
IReadOnlyList<Color> backgroundSamples,
|
||||
double minRatio,
|
||||
byte desiredAlpha)
|
||||
{
|
||||
var alpha = desiredAlpha;
|
||||
while (alpha < 0xFF)
|
||||
{
|
||||
var candidate = Color.FromArgb(alpha, opaqueColor.R, opaqueColor.G, opaqueColor.B);
|
||||
if (MinContrastRatio(candidate, backgroundSamples) >= minRatio)
|
||||
{
|
||||
return alpha;
|
||||
}
|
||||
|
||||
alpha = (byte)Math.Min(0xFF, alpha + 4);
|
||||
}
|
||||
|
||||
return 0xFF;
|
||||
}
|
||||
|
||||
private static double MinContrastRatio(Color foreground, IReadOnlyList<Color> backgroundSamples)
|
||||
{
|
||||
var minimum = double.MaxValue;
|
||||
for (var i = 0; i < backgroundSamples.Count; i++)
|
||||
{
|
||||
var bg = backgroundSamples[i];
|
||||
var visibleForeground = foreground.A >= 0xFF
|
||||
? Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B)
|
||||
: CompositeOverBackground(foreground, bg);
|
||||
var ratio = ColorMath.ContrastRatio(visibleForeground, bg);
|
||||
if (ratio < minimum)
|
||||
{
|
||||
minimum = ratio;
|
||||
}
|
||||
}
|
||||
|
||||
return minimum;
|
||||
}
|
||||
|
||||
private static Color CompositeOverBackground(Color foreground, Color background)
|
||||
{
|
||||
var alpha = foreground.A / 255d;
|
||||
var red = (byte)Math.Round((foreground.R * alpha) + (background.R * (1 - alpha)));
|
||||
var green = (byte)Math.Round((foreground.G * alpha) + (background.G * (1 - alpha)));
|
||||
var blue = (byte)Math.Round((foreground.B * alpha) + (background.B * (1 - alpha)));
|
||||
return Color.FromArgb(0xFF, red, green, blue);
|
||||
}
|
||||
|
||||
private static bool IsLightText(Color color)
|
||||
{
|
||||
return RelativeLuminance(color) >= LightTextLuminanceFloor;
|
||||
}
|
||||
|
||||
private static Color EnsureLightTone(Color color)
|
||||
{
|
||||
if (IsLightText(color))
|
||||
{
|
||||
return color;
|
||||
}
|
||||
|
||||
var white = Color.Parse("#FFFFFFFF");
|
||||
var low = 0d;
|
||||
var high = 1d;
|
||||
for (var i = 0; i < 16; i++)
|
||||
{
|
||||
var mid = (low + high) / 2d;
|
||||
var candidate = ColorMath.Blend(color, white, mid);
|
||||
if (IsLightText(candidate))
|
||||
{
|
||||
high = mid;
|
||||
}
|
||||
else
|
||||
{
|
||||
low = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return ColorMath.Blend(color, white, high);
|
||||
}
|
||||
|
||||
private static double RelativeLuminance(Color color)
|
||||
{
|
||||
var red = ToLinear(color.R / 255d);
|
||||
var green = ToLinear(color.G / 255d);
|
||||
var blue = ToLinear(color.B / 255d);
|
||||
return (0.2126 * red) + (0.7152 * green) + (0.0722 * blue);
|
||||
}
|
||||
|
||||
private static double ToLinear(double channel)
|
||||
{
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
<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="320"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.WeatherWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Background="#6B7B8F">
|
||||
<Grid>
|
||||
<Border x:Name="BackgroundImageLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="BackgroundMotionLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.20"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Border.RenderTransform>
|
||||
<TransformGroup>
|
||||
<ScaleTransform ScaleX="1.05"
|
||||
ScaleY="1.05" />
|
||||
<TranslateTransform />
|
||||
</TransformGroup>
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundTintLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.16" />
|
||||
|
||||
<Border x:Name="BackgroundLightLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.62">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="1,1">
|
||||
<GradientStop Color="#52FFFFFF"
|
||||
Offset="0" />
|
||||
<GradientStop Color="#1AFFFFFF"
|
||||
Offset="0.30" />
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0.56" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundShadeLayer"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.74">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="0,1">
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0.46" />
|
||||
<GradientStop Color="#2009182D"
|
||||
Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Canvas x:Name="ParticleLayer"
|
||||
IsHitTestVisible="False"
|
||||
ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="ContentPaddingBorder"
|
||||
Padding="18,16"
|
||||
Background="Transparent">
|
||||
<Grid x:Name="LayoutRoot">
|
||||
<Grid x:Name="ContentGrid"
|
||||
RowDefinitions="Auto,*,Auto"
|
||||
RowSpacing="0">
|
||||
<Grid x:Name="TopRowGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="6">
|
||||
<TextBlock x:Name="TemperatureTextBlock"
|
||||
Grid.Column="0"
|
||||
Text="7°"
|
||||
FontSize="48"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Top"
|
||||
Margin="-1,-7,0,0"
|
||||
TextTrimming="None"
|
||||
MaxLines="1" />
|
||||
|
||||
<Image x:Name="WeatherIconImage"
|
||||
Grid.Column="2"
|
||||
Width="84"
|
||||
Height="84"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-4,0,0"
|
||||
Stretch="Uniform" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel x:Name="BottomInfoStack"
|
||||
Grid.Row="2"
|
||||
VerticalAlignment="Bottom"
|
||||
HorizontalAlignment="Left"
|
||||
Spacing="0"
|
||||
Margin="0,0,0,2">
|
||||
<StackPanel x:Name="ConditionStack"
|
||||
Orientation="Vertical"
|
||||
Spacing="2"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,1">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Left"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="11°/4°"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Left"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</StackPanel>
|
||||
<Border x:Name="CityInfoBadge"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Left">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="0"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center">
|
||||
<fi:SymbolIcon x:Name="LocationIcon"
|
||||
Symbol="Location"
|
||||
FontSize="12"
|
||||
IsVisible="False"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="CityTextBlock"
|
||||
Text="北京"
|
||||
FontSize="17"
|
||||
FontWeight="Regular"
|
||||
HorizontalAlignment="Left"
|
||||
TextAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -1,45 +0,0 @@
|
||||
using System;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
internal readonly record struct WeatherVisualSpec(
|
||||
HyperOS3WeatherVisualKind VisualKind,
|
||||
string DisplayText,
|
||||
string? BackgroundAsset,
|
||||
string? PrimaryIconAsset,
|
||||
string? CompactIconAsset,
|
||||
string? ParticleAsset);
|
||||
|
||||
internal static class XiaomiWeatherVisualResolver
|
||||
{
|
||||
public static WeatherVisualSpec Resolve(string? weatherText, int? weatherCode, bool isNight, string locale)
|
||||
{
|
||||
var visualKind = HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, isNight);
|
||||
return new WeatherVisualSpec(
|
||||
visualKind,
|
||||
ResolveDisplayText(weatherText, weatherCode, locale),
|
||||
HyperOS3WeatherTheme.ResolveBackgroundAsset(visualKind),
|
||||
HyperOS3WeatherTheme.ResolveHeroIconAsset(visualKind),
|
||||
HyperOS3WeatherTheme.ResolveMiniIconAsset(visualKind),
|
||||
HyperOS3WeatherTheme.ResolveParticleAsset(visualKind));
|
||||
}
|
||||
|
||||
public static string ResolveDisplayText(string? weatherText, int? weatherCode, string locale)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(weatherText))
|
||||
{
|
||||
return weatherText.Trim();
|
||||
}
|
||||
|
||||
var mappedText = XiaomiWeatherCodeMapper.ResolveDisplayText(weatherCode, locale);
|
||||
if (!string.IsNullOrWhiteSpace(mappedText))
|
||||
{
|
||||
return mappedText;
|
||||
}
|
||||
|
||||
return locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase)
|
||||
? "\u672a\u77e5\u5929\u6c14"
|
||||
: "Unknown";
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ public partial class MainWindow : Window
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.EnableFadeTransition), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.MultiInstanceLaunchBehavior), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.EnableSlideTransition), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
@@ -685,6 +686,7 @@ public partial class MainWindow : Window
|
||||
EnableFadeTransition = existingSnapshot.EnableFadeTransition,
|
||||
EnableSlideTransition = existingSnapshot.EnableSlideTransition,
|
||||
ShowInTaskbar = existingSnapshot.ShowInTaskbar,
|
||||
MultiInstanceLaunchBehavior = existingSnapshot.MultiInstanceLaunchBehavior,
|
||||
EnableFusedDesktop = existingSnapshot.EnableFusedDesktop,
|
||||
DisabledPluginIds = existingSnapshot.DisabledPluginIds,
|
||||
StudyFrameMs = existingSnapshot.StudyFrameMs,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private bool _isSingleInstancePromptVisible;
|
||||
|
||||
internal void ShowSingleInstanceNotice()
|
||||
{
|
||||
void ShowPrompt()
|
||||
{
|
||||
UiExceptionGuard.FireAndForgetGuarded(
|
||||
ShowSingleInstanceNoticeCoreAsync,
|
||||
"MainWindow.ShowSingleInstanceNotice");
|
||||
}
|
||||
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
ShowPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(ShowPrompt, DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
private async Task ShowSingleInstanceNoticeCoreAsync()
|
||||
{
|
||||
if (_isSingleInstancePromptVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isSingleInstancePromptVisible = true;
|
||||
|
||||
try
|
||||
{
|
||||
var dialog = new FAContentDialog
|
||||
{
|
||||
Title = L("single_instance.notice.title", "Already running"),
|
||||
Content = L(
|
||||
"single_instance.notice.description",
|
||||
"LanMountainDesktop is already running. The existing window will stay active, so no new instance was started."),
|
||||
PrimaryButtonText = L("single_instance.notice.button", "OK"),
|
||||
DefaultButton = FAContentDialogButton.Primary
|
||||
};
|
||||
|
||||
await dialog.ShowAsync(this);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSingleInstancePromptVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,24 @@
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding MultiInstanceLaunchBehaviorHeader}"
|
||||
Description="{Binding MultiInstanceLaunchBehaviorDescription}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󱡈" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<ComboBox Width="240"
|
||||
ItemsSource="{Binding MultiInstanceLaunchBehaviors}"
|
||||
SelectedItem="{Binding SelectedMultiInstanceLaunchBehavior}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding PreviewHeader}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󱲀" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
|
||||
@@ -65,6 +65,50 @@
|
||||
|
||||
<Separator Classes="settings-separator" />
|
||||
|
||||
<controls:IconText Icon="Alert"
|
||||
Text="{Binding NotificationBoxHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding NotificationBoxEnabledHeader}"
|
||||
Description="{Binding NotificationBoxEnabledDescription}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰚸" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding IsNotificationBoxEnabled}" />
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding NotificationBoxPrivacyHeader}"
|
||||
Description="{Binding NotificationBoxPrivacyDescription}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰏱" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding IsNotificationBoxPrivacyMode}" />
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding LinuxCaptureModeHeader}"
|
||||
Description="{Binding LinuxCaptureModeDescription}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰗜" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<ComboBox Width="180"
|
||||
ItemsSource="{Binding LinuxCaptureModes}"
|
||||
SelectedItem="{Binding SelectedLinuxCaptureMode}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<Separator Classes="settings-separator" />
|
||||
|
||||
<controls:IconText Icon="Beaker"
|
||||
Text="{Binding TestHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:controls="using:LanMountainDesktop.Controls"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.UpdateSettingsPage"
|
||||
@@ -9,295 +10,263 @@
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="settings-section-title"
|
||||
Text="Update" />
|
||||
Text="{Binding PageTitle}" />
|
||||
<TextBlock Classes="settings-section-description"
|
||||
Text="Check for updates, watch download and install progress, and keep the update workflow recoverable from this page." />
|
||||
Text="{Binding PageDescription}" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="settings-section-card">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="16">
|
||||
<Border Classes="settings-section-card-icon-host"
|
||||
Width="56"
|
||||
Height="56"
|
||||
Padding="10">
|
||||
<fi:SymbolIcon Symbol="ArrowSync"
|
||||
FontSize="22" />
|
||||
</Border>
|
||||
<controls:IconText Icon="ArrowSync"
|
||||
Text="{Binding StatusSectionHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="6"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Classes="settings-card-header"
|
||||
Text="Current update status" />
|
||||
<TextBlock Classes="settings-card-description"
|
||||
Text="The status line below reflects the current update phase and any contextual message returned by the orchestrator." />
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
TextWrapping="Wrap"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
Margin="0,4,0,0" />
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
Margin="0,4,0,0">
|
||||
<Border Background="#223D5979"
|
||||
BorderBrush="#326D8FB7"
|
||||
BorderThickness="1"
|
||||
CornerRadius="999"
|
||||
Padding="10,4">
|
||||
<TextBlock Text="{Binding PhaseText}"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold" />
|
||||
</Border>
|
||||
<Border Background="#223D5979"
|
||||
BorderBrush="#326D8FB7"
|
||||
BorderThickness="1"
|
||||
CornerRadius="999"
|
||||
Padding="10,4"
|
||||
IsVisible="{Binding IsUpdateAvailable}">
|
||||
<TextBlock Text="Update available"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold" />
|
||||
</Border>
|
||||
<Border Background="#223D5979"
|
||||
BorderBrush="#326D8FB7"
|
||||
BorderThickness="1"
|
||||
CornerRadius="999"
|
||||
Padding="10,4"
|
||||
IsVisible="{Binding IsPaused}">
|
||||
<TextBlock Text="Paused"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<ui:FASettingsExpander Header="{Binding CheckCardTitle}"
|
||||
Description="{Binding StatusMessage}"
|
||||
IsClickEnabled="{Binding CanCheck}"
|
||||
Command="{Binding CheckCommand}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰊈"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<Button Classes="settings-accent-button"
|
||||
Content="{Binding CheckButtonText}"
|
||||
Command="{Binding CheckCommand}"
|
||||
IsEnabled="{Binding CanCheck}" />
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<StackPanel Grid.Column="2"
|
||||
Spacing="10"
|
||||
<ui:FASettingsExpander Header="{Binding ProgressTitle}"
|
||||
Description="{Binding ProgressDescription}"
|
||||
IsVisible="{Binding IsProgressSectionVisible}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰮲"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Button Classes="settings-accent-button"
|
||||
Content="Check for updates"
|
||||
Command="{Binding CheckCommand}" />
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="{Binding PhaseText}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding LastCheckedText}"
|
||||
TextAlignment="Right"
|
||||
TextWrapping="Wrap"
|
||||
Width="220" />
|
||||
Text="{Binding ProgressFraction, StringFormat='{}{0:P0}'}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-section-card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="settings-card-header"
|
||||
Text="Release facts" />
|
||||
<TextBlock Classes="settings-card-description"
|
||||
Text="Keep the current version, published release, and update type visible without collapsing the layout while states change." />
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto"
|
||||
ColumnDefinitions="*,*"
|
||||
ColumnSpacing="12"
|
||||
RowSpacing="12">
|
||||
<Border Classes="settings-list-item">
|
||||
<StackPanel Classes="settings-item">
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="Current version" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding CurrentVersionText}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1"
|
||||
Classes="settings-list-item">
|
||||
<StackPanel Classes="settings-item">
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="Latest version" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding LatestVersionText}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Classes="settings-list-item">
|
||||
<StackPanel Classes="settings-item">
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="Published at" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding PublishedAtText}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Classes="settings-list-item">
|
||||
<StackPanel Classes="settings-item">
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="Last checked" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding LastCheckedText}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2"
|
||||
Grid.ColumnSpan="2"
|
||||
Classes="settings-list-item">
|
||||
<StackPanel Classes="settings-item">
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="Update type" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding UpdateTypeText}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-section-card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="settings-card-header"
|
||||
Text="Progress" />
|
||||
<TextBlock Classes="settings-card-description"
|
||||
Text="Watch download, installation, verification, and recovery progress here." />
|
||||
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="{Binding PhaseText}" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding ProgressFraction, StringFormat='{}{0:P0}'}"
|
||||
HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
|
||||
</ui:FASettingsExpander.Footer>
|
||||
<ui:FASettingsExpanderItem>
|
||||
<StackPanel Spacing="12">
|
||||
<ProgressBar Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding ProgressFraction}"
|
||||
Height="12"
|
||||
IsVisible="{Binding IsProgressVisible}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="10"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsBusy}">
|
||||
<ui:FAProgressRing Width="20"
|
||||
Height="20"
|
||||
IsIndeterminate="True" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding ProgressDetail}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding ProgressDetail}"
|
||||
TextWrapping="Wrap" />
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding !IsBusy}" />
|
||||
|
||||
<Border Background="#223D5979"
|
||||
BorderBrush="#326D8FB7"
|
||||
BorderThickness="1"
|
||||
CornerRadius="10"
|
||||
Padding="12"
|
||||
IsVisible="{Binding IsPaused}">
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="Paused. Resume to continue from the current state." />
|
||||
</Border>
|
||||
<ui:FAInfoBar Title="{Binding PausedBadgeText}"
|
||||
Message="{Binding PausedHintText}"
|
||||
IsOpen="True"
|
||||
IsClosable="False"
|
||||
IsVisible="{Binding IsPaused}">
|
||||
<ui:FAInfoBar.IconSource>
|
||||
<ui:FAFontIconSource Glyph=""
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FAInfoBar.IconSource>
|
||||
</ui:FAInfoBar>
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
IsVisible="{Binding CanDownload}">
|
||||
<Button Classes="settings-accent-button"
|
||||
Content="{Binding DownloadButtonText}"
|
||||
Command="{Binding DownloadCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
IsVisible="{Binding CanInstall}">
|
||||
<Button Classes="settings-accent-button"
|
||||
Content="{Binding InstallButtonText}"
|
||||
Command="{Binding InstallCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
IsVisible="{Binding CanPause}">
|
||||
<Button Content="{Binding PauseButtonText}"
|
||||
Command="{Binding PauseCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
IsVisible="{Binding CanResume}">
|
||||
<Button Classes="settings-accent-button"
|
||||
Content="{Binding ResumeButtonText}"
|
||||
Command="{Binding ResumeCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
IsVisible="{Binding CanRollback}">
|
||||
<Button Content="{Binding RollbackButtonText}"
|
||||
Command="{Binding RollbackCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
IsVisible="{Binding CanCancel}">
|
||||
<Button Content="{Binding CancelButtonText}"
|
||||
Command="{Binding CancelCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</ui:FASettingsExpanderItem>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<Border Classes="settings-section-card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="settings-card-header"
|
||||
Text="Actions" />
|
||||
<TextBlock Classes="settings-card-description"
|
||||
Text="The buttons below stay in place while the update phase changes, so the page does not jump around." />
|
||||
<Separator Classes="settings-separator" />
|
||||
|
||||
<Grid ColumnDefinitions="*,*,*"
|
||||
RowDefinitions="Auto,Auto,Auto"
|
||||
ColumnSpacing="12"
|
||||
RowSpacing="10">
|
||||
<Button Classes="settings-accent-button"
|
||||
Content="Check"
|
||||
Command="{Binding CheckCommand}" />
|
||||
<Button Grid.Column="1"
|
||||
Content="Download"
|
||||
Command="{Binding DownloadCommand}" />
|
||||
<Button Grid.Column="2"
|
||||
Content="Install"
|
||||
Command="{Binding InstallCommand}" />
|
||||
<controls:IconText Icon="Info"
|
||||
Text="{Binding ReleaseFactsTitle}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<Button Grid.Row="1"
|
||||
Content="Pause"
|
||||
Command="{Binding PauseCommand}" />
|
||||
<Button Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Content="Resume"
|
||||
Command="{Binding ResumeCommand}" />
|
||||
<Button Grid.Row="1"
|
||||
Grid.Column="2"
|
||||
Content="Rollback"
|
||||
Command="{Binding RollbackCommand}" />
|
||||
<ui:FASettingsExpander Header="{Binding CurrentVersionLabel}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰊈"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding CurrentVersionText}" />
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<Button Grid.Row="2"
|
||||
Grid.ColumnSpan="3"
|
||||
Content="Cancel"
|
||||
Command="{Binding CancelCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ui:FASettingsExpander Header="{Binding LatestVersionLabel}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰭎"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding LatestVersionDisplayText}" />
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Classes="settings-expander-card"
|
||||
Header="Update preferences"
|
||||
Description="Choose the update channel, download source, mode, and thread count without leaving this page.">
|
||||
<ui:FASettingsExpander Header="{Binding PublishedAtLabel}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰅨"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding PublishedAtText}" />
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding LastCheckedLabel}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰭎"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding LastCheckedText}" />
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding UpdateTypeLabel}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰔄"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpanderItem>
|
||||
<StackPanel Spacing="12">
|
||||
<Grid ColumnDefinitions="180,*"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto"
|
||||
RowSpacing="10"
|
||||
ColumnSpacing="12">
|
||||
<TextBlock Grid.Row="0"
|
||||
Classes="settings-item-label"
|
||||
Text="Channel"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Text="{Binding SelectedUpdateChannelValue}"
|
||||
Watermark="stable / preview" />
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding UpdateTypeText}" />
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Classes="settings-item-label"
|
||||
Text="Source"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Text="{Binding SelectedUpdateSourceValue}"
|
||||
Watermark="plonds / github / proxy" />
|
||||
<Separator Classes="settings-separator" />
|
||||
|
||||
<TextBlock Grid.Row="2"
|
||||
Classes="settings-item-label"
|
||||
Text="Mode"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="2"
|
||||
Grid.Column="1"
|
||||
Text="{Binding SelectedUpdateModeValue}"
|
||||
Watermark="manual / confirm / silent" />
|
||||
<controls:IconText Icon="Settings"
|
||||
Text="{Binding PreferencesTitle}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<TextBlock Grid.Row="3"
|
||||
Classes="settings-item-label"
|
||||
Text="Download threads"
|
||||
<ui:FASettingsExpander Header="{Binding PreferencesTitle}"
|
||||
Description="{Binding PreferencesDescription}"
|
||||
IsExpanded="True">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰔄"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpanderItem Content="{Binding ChannelLabel}">
|
||||
<ui:FASettingsExpanderItem.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰤈"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpanderItem.IconSource>
|
||||
<ui:FASettingsExpanderItem.Footer>
|
||||
<ComboBox Width="220"
|
||||
ItemsSource="{Binding ChannelOptions}"
|
||||
SelectedItem="{Binding SelectedChannel}"
|
||||
DisplayMemberBinding="{Binding Label}" />
|
||||
</ui:FASettingsExpanderItem.Footer>
|
||||
</ui:FASettingsExpanderItem>
|
||||
<ui:FASettingsExpanderItem Content="{Binding SourceLabel}">
|
||||
<ui:FASettingsExpanderItem.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰭎"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpanderItem.IconSource>
|
||||
<ui:FASettingsExpanderItem.Footer>
|
||||
<ComboBox Width="220"
|
||||
ItemsSource="{Binding SourceOptions}"
|
||||
SelectedItem="{Binding SelectedSource}"
|
||||
DisplayMemberBinding="{Binding Label}" />
|
||||
</ui:FASettingsExpanderItem.Footer>
|
||||
</ui:FASettingsExpanderItem>
|
||||
<ui:FASettingsExpanderItem Content="{Binding ModeLabel}">
|
||||
<ui:FASettingsExpanderItem.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰣨"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpanderItem.IconSource>
|
||||
<ui:FASettingsExpanderItem.Footer>
|
||||
<ComboBox Width="220"
|
||||
ItemsSource="{Binding ModeOptions}"
|
||||
SelectedItem="{Binding SelectedMode}"
|
||||
DisplayMemberBinding="{Binding Label}" />
|
||||
</ui:FASettingsExpanderItem.Footer>
|
||||
</ui:FASettingsExpanderItem>
|
||||
<ui:FASettingsExpanderItem Content="{Binding DownloadThreadsLabel}">
|
||||
<ui:FASettingsExpanderItem.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰅨"
|
||||
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpanderItem.IconSource>
|
||||
<ui:FASettingsExpanderItem.Footer>
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Slider Width="140"
|
||||
Minimum="1"
|
||||
Maximum="16"
|
||||
Value="{Binding DownloadThreadsSliderValue}"
|
||||
TickFrequency="1"
|
||||
IsSnapToTickEnabled="True" />
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="{Binding DownloadThreadsSliderValue, StringFormat='{}{0:F0}'}"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Row="3"
|
||||
Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Slider Width="220"
|
||||
Minimum="1"
|
||||
Maximum="16"
|
||||
Value="{Binding DownloadThreadsSliderValue}"
|
||||
TickFrequency="1"
|
||||
IsSnapToTickEnabled="True" />
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="{Binding DownloadThreadsSliderValue, StringFormat='{}{0:F0}'}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ui:FASettingsExpanderItem.Footer>
|
||||
</ui:FASettingsExpanderItem>
|
||||
</ui:FASettingsExpander>
|
||||
</StackPanel>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
Background="Transparent"
|
||||
TransparencyLevelHint="Transparent"
|
||||
Background="{x:Null}"
|
||||
Title="LanMountainDesktop Fused Desktop">
|
||||
<Window.Styles>
|
||||
<Style Selector="Border.fused-desktop-component-host">
|
||||
@@ -32,9 +33,9 @@
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid x:Name="OverlayRoot">
|
||||
<Grid x:Name="OverlayRoot"
|
||||
Background="{x:Null}">
|
||||
<Canvas x:Name="ComponentCanvas"
|
||||
Background="#01000000"
|
||||
PointerPressed="OnCanvasPointerPressed" />
|
||||
|
||||
<Border x:Name="EditToolbar"
|
||||
|
||||
@@ -232,6 +232,7 @@ public partial class TransparentOverlayWindow : Window
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(UpdateInteractiveRegions, DispatcherPriority.Background);
|
||||
DispatcherTimer.RunOnce(LogTransparencyDiagnostics, TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
@@ -266,6 +267,22 @@ public partial class TransparentOverlayWindow : Window
|
||||
Height = workArea.Height / scaling;
|
||||
}
|
||||
|
||||
private void LogTransparencyDiagnostics()
|
||||
{
|
||||
var actualTransparency = ActualTransparencyLevel;
|
||||
if (actualTransparency == WindowTransparencyLevel.Transparent)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"TransparentOverlay",
|
||||
$"ActualTransparencyLevel={actualTransparency}; overlay should be visually transparent.");
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"TransparentOverlay",
|
||||
$"ActualTransparencyLevel={actualTransparency}; expected Transparent. The platform, window styles, or desktop host attachment may be preventing true transparency.");
|
||||
}
|
||||
|
||||
private void EnsureGridContext()
|
||||
{
|
||||
var viewport = new Size(Math.Max(1, Width), Math.Max(1, Height));
|
||||
|
||||
41
LanMountainDesktop/WindowsIdentity/AppxManifest.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
IgnorableNamespaces="uap uap5 rescap">
|
||||
|
||||
<Identity
|
||||
Name="LanMountainDesktop.NotificationIdentity"
|
||||
Publisher="CN=LanMountainDesktop"
|
||||
Version="0.0.0.0" />
|
||||
|
||||
<Properties>
|
||||
<DisplayName>LanMountainDesktop</DisplayName>
|
||||
<PublisherDisplayName>LanMountainDesktop Team</PublisherDisplayName>
|
||||
<Logo>Assets\logo_nightly.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26100.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Applications>
|
||||
<Application Id="App"
|
||||
Executable="LanMountainDesktop.Launcher.exe"
|
||||
EntryPoint="Windows.FullTrustApplication">
|
||||
<uap:VisualElements
|
||||
DisplayName="LanMountainDesktop"
|
||||
Description="LanMountainDesktop"
|
||||
BackgroundColor="transparent"
|
||||
Square44x44Logo="Assets\logo_nightly.png"
|
||||
Square150x150Logo="Assets\logo_nightly.png" />
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
<uap5:Capability Name="userNotificationListener" />
|
||||
</Capabilities>
|
||||
</Package>
|
||||
@@ -146,8 +146,12 @@ Name: "{autodesktop}\{cm:AppShortcutName}"; Filename: "{app}\{#MyAppExeName}"; T
|
||||
Root: HKA; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue
|
||||
|
||||
[Run]
|
||||
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""if (Get-Command Add-AppxPackage -ErrorAction SilentlyContinue) {{ try {{ Add-AppxPackage -Register '{app}\WindowsIdentity\AppxManifest.xml' -ExternalLocation '{app}' -ForceApplicationShutdown -ErrorAction Stop }} catch {{ Write-Host $_.Exception.Message }} }}"""; Flags: runhidden
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--launch-source postinstall"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[UninstallRun]
|
||||
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""if (Get-Command Get-AppxPackage -ErrorAction SilentlyContinue) {{ Get-AppxPackage -Name 'LanMountainDesktop.NotificationIdentity' | Remove-AppxPackage -ErrorAction SilentlyContinue }}"""; Flags: runhidden
|
||||
|
||||
[Code]
|
||||
const
|
||||
UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1';
|
||||
|
||||