changed.更了好多

This commit is contained in:
lincube
2026-05-12 16:46:49 +08:00
parent 563f12caa1
commit 33c264f6dd
127 changed files with 5257 additions and 10534 deletions

View File

@@ -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)
{

View File

@@ -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.

View File

@@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 910 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 734 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -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>

View File

@@ -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",

View File

@@ -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": "コードネーム",

View File

@@ -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": "버전 코드명",

View File

@@ -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": "版本代号",

View File

@@ -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()

View File

@@ -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;

View File

@@ -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";
}

View File

@@ -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

View File

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

View File

@@ -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("&lt;", "<");
result = result.Replace("&gt;", ">");
result = result.Replace("&amp;", "&");
result = result.Replace("&quot;", "\"");
return result.Trim();
var result = Regex.Replace(html, "<[^>]+>", string.Empty);
return result
.Replace("&lt;", "<", StringComparison.Ordinal)
.Replace("&gt;", ">", StringComparison.Ordinal)
.Replace("&amp;", "&", StringComparison.Ordinal)
.Replace("&quot;", "\"", 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;
}
}

View File

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

View File

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

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

View File

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

View File

@@ -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()
{

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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">

View File

@@ -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)

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

@@ -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>

View File

@@ -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"

View File

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

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

View File

@@ -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&#x6708;14&#x65E5;"
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>

View File

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

View File

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

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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";
}
}

View File

@@ -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,

View File

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

View File

@@ -47,6 +47,24 @@
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding MultiInstanceLaunchBehaviorHeader}"
Description="{Binding MultiInstanceLaunchBehaviorDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF1848;" 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="&#xF1C80;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />

View File

@@ -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="&#xF06B8;" 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="&#xF03F1;" 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="&#xF05DC;" 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" />

View File

@@ -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="&#xF0288;"
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="&#xF0BB2;"
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="&#xF28D;"
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="&#xF0288;"
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="&#xF0B4E;"
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="&#xF0168;"
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="&#xF0B4E;"
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="&#xF0504;"
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="&#xF0504;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpanderItem Content="{Binding ChannelLabel}">
<ui:FASettingsExpanderItem.IconSource>
<ui:FAFontIconSource Glyph="&#xF0908;"
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="&#xF0B4E;"
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="&#xF08E8;"
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="&#xF0168;"
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>

View File

@@ -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"

View File

@@ -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));

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

View File

@@ -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';