diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3c34d7..6f7c1c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,8 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} + assembly_version: ${{ steps.version.outputs.assembly_version }} + informational_version: ${{ steps.version.outputs.informational_version }} tag: ${{ steps.version.outputs.tag }} checkout_ref: ${{ steps.version.outputs.checkout_ref }} @@ -47,8 +49,15 @@ jobs: CHECKOUT_REF="${GITHUB_SHA}" fi VERSION="${TAG#v}" + IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}" + while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do + VERSION_PARTS+=("0") + done + ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}" echo "tag=${TAG}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT + echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT build-windows: @@ -73,26 +82,16 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Update version in .csproj - run: | - $VERSION = "${{ needs.prepare.outputs.version }}" - $csprojFiles = @( - "LanMountainDesktop/LanMountainDesktop.csproj" - ) - - foreach ($csprojPath in $csprojFiles) { - Write-Host "Updating version in $csprojPath to $VERSION" - $content = Get-Content $csprojPath -Raw - $content = $content -replace '.*?', "$VERSION" - Set-Content $csprojPath $content - } - shell: pwsh - - name: Restore run: dotnet restore ${{ env.Solution_Name }} - name: Build - run: dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal + run: > + dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal + -p:Version=${{ needs.prepare.outputs.version }} + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - name: Publish run: | @@ -106,7 +105,11 @@ jobs: -p:DebugType=none ` -p:DebugSymbols=false ` -p:PublishTrimmed=false ` - -p:PublishReadyToRun=false + -p:PublishReadyToRun=false ` + -p:Version=${{ needs.prepare.outputs.version }} ` + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} ` + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} ` + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} shell: pwsh - name: Install Inno Setup @@ -242,17 +245,16 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Update version in .csproj - run: | - VERSION="${{ needs.prepare.outputs.version }}" - echo "Updating version in LanMountainDesktop.csproj to $VERSION" - sed -i "s/.*<\/Version>/$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj - - name: Restore run: dotnet restore ${{ env.Solution_Name }} - name: Build - run: dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal + run: > + dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal + -p:Version=${{ needs.prepare.outputs.version }} + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - name: Publish run: | @@ -266,7 +268,11 @@ jobs: -p:DebugType=none \ -p:DebugSymbols=false \ -p:PublishTrimmed=false \ - -p:PublishReadyToRun=false + -p:PublishReadyToRun=false \ + -p:Version=${{ needs.prepare.outputs.version }} \ + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - name: Package as DEB run: | @@ -384,17 +390,16 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Update version in .csproj - run: | - VERSION="${{ needs.prepare.outputs.version }}" - echo "Updating version in LanMountainDesktop.csproj to $VERSION" - sed -i '' "s/.*<\/Version>/$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj - - name: Restore run: dotnet restore ${{ env.Solution_Name }} - name: Build - run: dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal + run: > + dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal + -p:Version=${{ needs.prepare.outputs.version }} + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - name: Publish run: | @@ -408,7 +413,11 @@ jobs: -p:DebugType=none \ -p:DebugSymbols=false \ -p:PublishTrimmed=false \ - -p:PublishReadyToRun=false + -p:PublishReadyToRun=false \ + -p:Version=${{ needs.prepare.outputs.version }} \ + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} - name: Package as DMG run: | diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 3a03541..dfeb85c 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -493,7 +493,10 @@ public partial class App : Application var themeChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase); + changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase); var languageChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase); @@ -516,16 +519,32 @@ public partial class App : Application private void ApplyAdaptiveThemeResources(ThemeAppearanceSettingsState themeState) { - var accentColor = TryParseThemeColor(themeState.ThemeColor); + var wallpaperState = _settingsFacade.Wallpaper.Get(); + var monetPalette = _settingsFacade.Theme.BuildPalette( + themeState.IsNightMode, + wallpaperState.WallpaperPath, + themeState.ThemeColor); + var accentColor = ResolveAccentColor(themeState.ThemeColor, monetPalette); var context = new ThemeColorContext( accentColor, IsLightBackground: !themeState.IsNightMode, IsLightNavBackground: !themeState.IsNightMode, - IsNightMode: themeState.IsNightMode); + IsNightMode: themeState.IsNightMode, + MonetColors: monetPalette.MonetColors); ThemeColorSystemService.ApplyThemeResources(Resources, context); GlassEffectService.ApplyGlassResources(Resources, context); } + private static Color ResolveAccentColor(string? colorText, MonetPalette monetPalette) + { + if (monetPalette.MonetColors is { Count: > 0 }) + { + return monetPalette.MonetColors[0]; + } + + return TryParseThemeColor(colorText); + } + private static Color TryParseThemeColor(string? colorText) { if (!string.IsNullOrWhiteSpace(colorText)) @@ -589,6 +608,14 @@ public partial class App : Application _exitCleanupCompleted = true; AppSettingsService.SettingsSaved -= OnAppSettingsSaved; _settingsFacade.Settings.Changed -= OnSettingsChanged; + try + { + HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit(); + } + catch (Exception ex) + { + AppLogger.Warn("UpdateWorkflow", "Failed to apply pending update during exit cleanup.", ex); + } try { diff --git a/LanMountainDesktop/Assets/about_banner.png b/LanMountainDesktop/Assets/about_banner.png new file mode 100644 index 0000000..db9d81c Binary files /dev/null and b/LanMountainDesktop/Assets/about_banner.png differ diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 02caa3d..3bd6c25 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -242,7 +242,7 @@ "settings.general.preview_date_label": "Date", "settings.general.render_mode_restart_message": "Rendering mode changes require restarting the app.", "settings.appearance.title": "Appearance", - "settings.appearance.description": "Adjust theme and status bar presentation.", + "settings.appearance.description": "Adjust theme, wallpaper, and window chrome.", "settings.appearance.theme_header": "Theme", "settings.color.enable_night_mode_toggle": "Enable night mode", "settings.color.use_system_chrome_toggle": "Use system window chrome", @@ -310,10 +310,36 @@ "settings.about.render_mode.current_format": "Current backend: {0}", "settings.about.render_mode.impl_format": "Runtime implementation: {0}", "settings.about.render_mode.impl_unavailable": "Runtime implementation details are unavailable.", - "settings.about.description": "Application details and update preferences.", + "settings.about.description": "Application details.", + "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.preferences_description": "Choose the release channel, installer download source, installation behavior, and download parallelism.", + "settings.update.last_checked_label": "Last Checked", + "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.install_now_button": "Install Now", + "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.about.update_header": "Updates", "settings.about.version_label": "Version", + "settings.about.codename_label": "Codename", "settings.about.render_backend_label": "Render Backend", "settings.about.render_backend_format": "Render Backend: {0}", "settings.restart_dialog.title": "Restart required", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 94d08a6..e8158f7 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -247,7 +247,7 @@ "settings.general.preview_date_label": "日期", "settings.general.render_mode_restart_message": "渲染模式变更需要重启应用。", "settings.appearance.title": "外观", - "settings.appearance.description": "切换主题与状态栏展示。", + "settings.appearance.description": "调整主题、壁纸与窗口外观。", "settings.appearance.theme_header": "主题", "settings.color.enable_night_mode_toggle": "启用夜间模式", "settings.color.use_system_chrome_toggle": "使用系统窗口标题栏", @@ -315,10 +315,36 @@ "settings.about.render_mode.current_format": "当前后端:{0}", "settings.about.render_mode.impl_format": "运行时实现:{0}", "settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。", - "settings.about.description": "应用信息与更新偏好。", + "settings.about.description": "应用信息。", + "settings.update.description": "检查更新、选择发布通道与下载源,并控制更新安装方式。", + "settings.update.status_card_title": "更新状态", + "settings.update.status_card_description": "检查新版本、查看发布信息,并在有更新时继续下载或安装。", + "settings.update.preferences_header": "更新偏好", + "settings.update.preferences_description": "选择发布通道、安装包下载源、安装方式以及下载并行线程数。", + "settings.update.last_checked_label": "上次检查", + "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.install_now_button": "立即安装", + "settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。", + "settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。", "settings.about.app_info_header": "应用信息", "settings.about.update_header": "更新", "settings.about.version_label": "版本", + "settings.about.codename_label": "版本代号", "settings.about.render_backend_label": "渲染后端", "settings.about.render_backend_format": "渲染后端:{0}", "settings.restart_dialog.title": "需要重启应用", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 1202fca..3120c07 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -60,7 +60,21 @@ public sealed class AppSettingsSnapshot public bool IncludePrereleaseUpdates { get; set; } - public string UpdateChannel { get; set; } = string.Empty; + public string UpdateChannel { get; set; } = "stable"; + + public string UpdateMode { get; set; } = "download_then_confirm"; + + public string UpdateDownloadSource { get; set; } = "github"; + + public int UpdateDownloadThreads { get; set; } = 4; + + public string? PendingUpdateInstallerPath { get; set; } + + public string? PendingUpdateVersion { get; set; } + + public long? PendingUpdatePublishedAtUtcMs { get; set; } + + public long? LastUpdateCheckUtcMs { get; set; } public List TopStatusComponentIds { get; set; } = []; diff --git a/LanMountainDesktop/Services/ComponentEditorWindowService.cs b/LanMountainDesktop/Services/ComponentEditorWindowService.cs index bf5ef0e..5749347 100644 --- a/LanMountainDesktop/Services/ComponentEditorWindowService.cs +++ b/LanMountainDesktop/Services/ComponentEditorWindowService.cs @@ -124,7 +124,10 @@ internal sealed class ComponentEditorWindowService : IComponentEditorWindowServi var themeState = _settingsFacade.Theme.Get(); var wallpaperState = _settingsFacade.Wallpaper.Get(); var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperState.WallpaperPath); - var monetPalette = _settingsFacade.Theme.BuildPalette(themeState.IsNightMode, wallpaperState.WallpaperPath); + var monetPalette = _settingsFacade.Theme.BuildPalette( + themeState.IsNightMode, + wallpaperState.WallpaperPath, + themeState.ThemeColor); var palette = ComponentEditorMaterialThemeAdapter.Build( themeState, wallpaperState, diff --git a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs index 174b7ae..b2a6f0a 100644 --- a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs @@ -172,6 +172,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable public async Task DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, + string downloadSource, + int maxParallelSegments, IProgress? progress = null, CancellationToken cancellationToken = default) { @@ -193,11 +195,14 @@ public sealed class GitHubReleaseUpdateService : IDisposable var progressAdapter = progress is null ? null : new Progress(info => progress.Report(info.Progress)); + var effectiveSource = ApplyDownloadSource(asset.BrowserDownloadUrl, downloadSource); var result = await _downloadService.DownloadAsync( - asset.BrowserDownloadUrl, + effectiveSource, destinationFilePath, - new DownloadOptions(ExpectedSizeBytes: asset.SizeBytes > 0 ? asset.SizeBytes : null), + new DownloadOptions( + ExpectedSizeBytes: asset.SizeBytes > 0 ? asset.SizeBytes : null, + MaxParallelSegments: UpdateSettingsValues.NormalizeDownloadThreads(maxParallelSegments)), progressAdapter, cancellationToken); @@ -460,4 +465,23 @@ public sealed class GitHubReleaseUpdateService : IDisposable return value[..maxLength]; } + + private static string ApplyDownloadSource(string browserDownloadUrl, string? downloadSource) + { + if (!string.Equals( + UpdateSettingsValues.NormalizeDownloadSource(downloadSource), + UpdateSettingsValues.DownloadSourceGhProxy, + StringComparison.OrdinalIgnoreCase)) + { + return browserDownloadUrl; + } + + var normalizedBase = UpdateSettingsValues.DefaultGhProxyBaseUrl.TrimEnd('/') + "/"; + if (browserDownloadUrl.StartsWith(normalizedBase, StringComparison.OrdinalIgnoreCase)) + { + return browserDownloadUrl; + } + + return normalizedBase + browserDownloadUrl; + } } diff --git a/LanMountainDesktop/Services/GlassEffectService.cs b/LanMountainDesktop/Services/GlassEffectService.cs index b8ceac8..50bb293 100644 --- a/LanMountainDesktop/Services/GlassEffectService.cs +++ b/LanMountainDesktop/Services/GlassEffectService.cs @@ -1,4 +1,5 @@ -using Avalonia.Controls; +using System.Linq; +using Avalonia.Controls; using Avalonia.Media; using LanMountainDesktop.Theme; @@ -6,63 +7,59 @@ namespace LanMountainDesktop.Services; public static class GlassEffectService { - private const double DayPanelBlurRadius = 40; - private const double DayStrongBlurRadius = 60; - private const double DayOverlayBlurRadius = 80; - private const double NightPanelBlurRadius = 45; - private const double NightStrongBlurRadius = 65; - private const double NightOverlayBlurRadius = 85; - public static void ApplyGlassResources(IResourceDictionary resources, ThemeColorContext context) { - // Mica 材质:不透明,但混合壁纸颜色 - // 提取壁纸颜色的透明度(0-1),用于控制 Mica 效果强度 - var wallpaperTintOpacity = 0.15; // 壁纸颜色混合比例 - - var neutralBase = context.IsNightMode ? Color.Parse("#FF202020") : Color.Parse("#FFF3F3F3"); - var neutralElevated = context.IsNightMode ? Color.Parse("#FF2C2C2C") : Color.Parse("#FFFAFAFA"); - - // Mica 效果:将壁纸颜色混合到中性基色中 - var micaBackground = ColorMath.Blend(neutralBase, context.AccentColor, wallpaperTintOpacity); - var micaElevated = ColorMath.Blend(neutralElevated, context.AccentColor, wallpaperTintOpacity * 0.8); - - // 按钮颜色 - var buttonBackground = context.IsNightMode ? - Color.FromArgb(0x33, micaBackground.R, micaBackground.G, micaBackground.B) : - Color.FromArgb(0x4D, micaBackground.R, micaBackground.G, micaBackground.B); - + var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? []; + var primary = monetColors.Length > 0 ? monetColors[0] : context.AccentColor; + var secondary = monetColors.Length > 1 + ? monetColors[1] + : ColorMath.Blend(primary, Color.Parse("#FFFFFFFF"), 0.12); + + var panelBase = context.IsNightMode + ? ColorMath.Blend(Color.Parse("#FF101722"), primary, 0.26) + : ColorMath.Blend(Color.Parse("#FFF9FBFE"), primary, 0.14); + var panelRaised = context.IsNightMode + ? ColorMath.Blend(Color.Parse("#FF15202C"), secondary, 0.30) + : ColorMath.Blend(Color.Parse("#FFFFFFFF"), secondary, 0.18); + var overlayBase = context.IsNightMode + ? ColorMath.Blend(Color.Parse("#FF0E1622"), primary, 0.36) + : ColorMath.Blend(Color.Parse("#FFF3F7FD"), primary, 0.20); + + var buttonBackground = Color.FromArgb( + context.IsNightMode ? (byte)0x4D : (byte)0x52, + panelRaised.R, + panelRaised.G, + panelRaised.B); + var buttonBorder = Color.FromArgb( + context.IsNightMode ? (byte)0x36 : (byte)0x26, + primary.R, + primary.G, + primary.B); + resources["AdaptiveButtonBackgroundBrush"] = new SolidColorBrush(buttonBackground); - resources["AdaptiveButtonBorderBrush"] = new SolidColorBrush( - Color.FromArgb(0x1A, neutralElevated.R, neutralElevated.G, neutralElevated.B)); + resources["AdaptiveButtonBorderBrush"] = new SolidColorBrush(buttonBorder); resources["AdaptiveButtonHoverBackgroundBrush"] = new SolidColorBrush( - ColorMath.WithAlpha(buttonBackground, context.IsNightMode ? (byte)0x4D : (byte)0x66)); + ColorMath.WithAlpha(ColorMath.Blend(buttonBackground, primary, 0.18), context.IsNightMode ? (byte)0x72 : (byte)0x7A)); resources["AdaptiveButtonPressedBackgroundBrush"] = new SolidColorBrush( - ColorMath.WithAlpha(buttonBackground, context.IsNightMode ? (byte)0x66 : (byte)0x80)); + ColorMath.WithAlpha(ColorMath.Blend(buttonBackground, primary, 0.30), context.IsNightMode ? (byte)0x8A : (byte)0x8C)); - // 面板颜色 - 使用 Mica 材质 resources["AdaptiveGlassPanelBackgroundBrush"] = new SolidColorBrush( - Color.FromArgb(context.IsNightMode ? (byte)0xF0 : (byte)0xF8, - micaBackground.R, micaBackground.G, micaBackground.B)); + Color.FromArgb(context.IsNightMode ? (byte)0xF2 : (byte)0xFA, panelBase.R, panelBase.G, panelBase.B)); resources["AdaptiveGlassPanelBorderBrush"] = new SolidColorBrush( - Color.FromArgb(0x1F, neutralElevated.R, neutralElevated.G, neutralElevated.B)); + Color.FromArgb(context.IsNightMode ? (byte)0x38 : (byte)0x24, primary.R, primary.G, primary.B)); resources["AdaptiveGlassStrongBackgroundBrush"] = new SolidColorBrush( - Color.FromArgb(context.IsNightMode ? (byte)0xF4 : (byte)0xFB, - micaElevated.R, micaElevated.G, micaElevated.B)); + Color.FromArgb(context.IsNightMode ? (byte)0xF6 : (byte)0xFC, panelRaised.R, panelRaised.G, panelRaised.B)); resources["AdaptiveGlassStrongBorderBrush"] = new SolidColorBrush( - Color.FromArgb(0x29, neutralElevated.R, neutralElevated.G, neutralElevated.B)); + Color.FromArgb(context.IsNightMode ? (byte)0x4A : (byte)0x2C, secondary.R, secondary.G, secondary.B)); resources["AdaptiveGlassOverlayBackgroundBrush"] = new SolidColorBrush( - Color.FromArgb(context.IsNightMode ? (byte)0xE6 : (byte)0xF2, - micaBackground.R, micaBackground.G, micaBackground.B)); + Color.FromArgb(context.IsNightMode ? (byte)0xEA : (byte)0xF4, overlayBase.R, overlayBase.G, overlayBase.B)); - // 模糊半径(Mica 不需要强模糊) - resources["AdaptiveGlassPanelBlurRadius"] = context.IsNightMode ? 20.0 : 30.0; - resources["AdaptiveGlassStrongBlurRadius"] = context.IsNightMode ? 25.0 : 35.0; - resources["AdaptiveGlassOverlayBlurRadius"] = context.IsNightMode ? 30.0 : 40.0; - - // 不透明度(Mica 材质接近不透明) - resources["AdaptiveGlassPanelOpacity"] = context.IsNightMode ? 0.99 : 1.0; - resources["AdaptiveGlassStrongOpacity"] = context.IsNightMode ? 1.0 : 1.0; - resources["AdaptiveGlassOverlayOpacity"] = context.IsNightMode ? 0.94 : 0.97; - resources["AdaptiveGlassNoiseOpacity"] = context.IsNightMode ? 0.01 : 0.008; + resources["AdaptiveGlassPanelBlurRadius"] = context.IsNightMode ? 22.0 : 28.0; + resources["AdaptiveGlassStrongBlurRadius"] = context.IsNightMode ? 28.0 : 34.0; + resources["AdaptiveGlassOverlayBlurRadius"] = context.IsNightMode ? 34.0 : 40.0; + resources["AdaptiveGlassPanelOpacity"] = 1.0; + resources["AdaptiveGlassStrongOpacity"] = 1.0; + resources["AdaptiveGlassOverlayOpacity"] = context.IsNightMode ? 0.95 : 0.98; + resources["AdaptiveGlassNoiseOpacity"] = context.IsNightMode ? 0.012 : 0.008; } } diff --git a/LanMountainDesktop/Services/MonetColorService.cs b/LanMountainDesktop/Services/MonetColorService.cs index c8f31f6..33cc00f 100644 --- a/LanMountainDesktop/Services/MonetColorService.cs +++ b/LanMountainDesktop/Services/MonetColorService.cs @@ -12,10 +12,10 @@ namespace LanMountainDesktop.Services; public sealed class MonetColorService { - public MonetPalette BuildPalette(Bitmap? wallpaper, bool nightMode) + public MonetPalette BuildPalette(Bitmap? wallpaper, bool nightMode, Color? preferredSeed = null) { var recommended = BuildRecommendedPalette(nightMode); - var seed = TryExtractSeedColor(wallpaper) ?? TryGetSystemMonetSeedColor() ?? Color.Parse("#FF3B82F6"); + var seed = preferredSeed ?? TryExtractSeedColor(wallpaper) ?? TryGetSystemMonetSeedColor() ?? Color.Parse("#FF3B82F6"); var monet = BuildMonetPalette(seed, nightMode); return new MonetPalette(recommended, monet); } diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index 650da15..fced06e 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -37,7 +37,17 @@ public sealed record WeatherSettingsState( bool NoTlsRequests, string LocationQuery); public sealed record RegionSettingsState(string LanguageCode, string? TimeZoneId); -public sealed record UpdateSettingsState(bool AutoCheckUpdates, bool IncludePrereleaseUpdates, string UpdateChannel); +public sealed record UpdateSettingsState( + bool AutoCheckUpdates, + bool IncludePrereleaseUpdates, + string UpdateChannel, + string UpdateMode, + string UpdateDownloadSource, + int UpdateDownloadThreads, + string? PendingUpdateInstallerPath, + string? PendingUpdateVersion, + long? PendingUpdatePublishedAtUtcMs, + long? LastUpdateCheckUtcMs); public sealed record PluginManagementSettingsState(IReadOnlyList DisabledPluginIds); public sealed record PluginMarketDependencyInfo( string Id, @@ -106,7 +116,7 @@ public interface IThemeAppearanceService { ThemeAppearanceSettingsState Get(); void Save(ThemeAppearanceSettingsState state); - MonetPalette BuildPalette(bool nightMode, string? wallpaperPath); + MonetPalette BuildPalette(bool nightMode, string? wallpaperPath, string? preferredSeedColor = null); } public interface IStatusBarSettingsService @@ -164,6 +174,8 @@ public interface IUpdateSettingsService Task DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, + string downloadSource, + int maxParallelSegments, IProgress? progress = null, CancellationToken cancellationToken = default); } @@ -197,6 +209,7 @@ public interface IPluginMarketSettingsService public interface IApplicationInfoService { string GetAppVersionText(); + string GetAppCodenameText(); AppRenderBackendInfo GetRenderBackendInfo(); } diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 02bef72..50e127d 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -2,8 +2,10 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Avalonia.Media; using Avalonia.Media.Imaging; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; @@ -251,9 +253,15 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService ]); } - public MonetPalette BuildPalette(bool nightMode, string? wallpaperPath) + public MonetPalette BuildPalette(bool nightMode, string? wallpaperPath, string? preferredSeedColor = null) { Bitmap? bitmap = null; + Color? preferredSeed = null; + + if (!string.IsNullOrWhiteSpace(preferredSeedColor) && Color.TryParse(preferredSeedColor, out var parsedSeed)) + { + preferredSeed = parsedSeed; + } try { @@ -274,7 +282,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService try { - return _monetColorService.BuildPalette(bitmap, nightMode); + return _monetColorService.BuildPalette(bitmap, nightMode, preferredSeed); } finally { @@ -530,18 +538,49 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl public UpdateSettingsState Get() { var snapshot = _settingsService.Load(); + var normalizedChannel = UpdateSettingsValues.NormalizeChannel( + snapshot.UpdateChannel, + snapshot.IncludePrereleaseUpdates); return new UpdateSettingsState( snapshot.AutoCheckUpdates, - snapshot.IncludePrereleaseUpdates, - snapshot.UpdateChannel); + string.Equals(normalizedChannel, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase), + normalizedChannel, + UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode), + UpdateSettingsValues.NormalizeDownloadSource(snapshot.UpdateDownloadSource), + UpdateSettingsValues.NormalizeDownloadThreads(snapshot.UpdateDownloadThreads), + snapshot.PendingUpdateInstallerPath, + snapshot.PendingUpdateVersion, + snapshot.PendingUpdatePublishedAtUtcMs, + snapshot.LastUpdateCheckUtcMs); } public void Save(UpdateSettingsState state) { var snapshot = _settingsService.Load(); + var normalizedChannel = UpdateSettingsValues.NormalizeChannel( + state.UpdateChannel, + state.IncludePrereleaseUpdates); snapshot.AutoCheckUpdates = state.AutoCheckUpdates; - snapshot.IncludePrereleaseUpdates = state.IncludePrereleaseUpdates; - snapshot.UpdateChannel = state.UpdateChannel; + snapshot.IncludePrereleaseUpdates = string.Equals( + normalizedChannel, + UpdateSettingsValues.ChannelPreview, + StringComparison.OrdinalIgnoreCase); + snapshot.UpdateChannel = normalizedChannel; + snapshot.UpdateMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode); + snapshot.UpdateDownloadSource = UpdateSettingsValues.NormalizeDownloadSource(state.UpdateDownloadSource); + snapshot.UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads); + snapshot.PendingUpdateInstallerPath = string.IsNullOrWhiteSpace(state.PendingUpdateInstallerPath) + ? null + : state.PendingUpdateInstallerPath.Trim(); + snapshot.PendingUpdateVersion = string.IsNullOrWhiteSpace(state.PendingUpdateVersion) + ? null + : state.PendingUpdateVersion.Trim(); + snapshot.PendingUpdatePublishedAtUtcMs = state.PendingUpdatePublishedAtUtcMs is > 0 + ? state.PendingUpdatePublishedAtUtcMs + : null; + snapshot.LastUpdateCheckUtcMs = state.LastUpdateCheckUtcMs is > 0 + ? state.LastUpdateCheckUtcMs + : null; _settingsService.SaveSnapshot( SettingsScope.App, snapshot, @@ -549,7 +588,14 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl [ nameof(AppSettingsSnapshot.AutoCheckUpdates), nameof(AppSettingsSnapshot.IncludePrereleaseUpdates), - nameof(AppSettingsSnapshot.UpdateChannel) + nameof(AppSettingsSnapshot.UpdateChannel), + nameof(AppSettingsSnapshot.UpdateMode), + nameof(AppSettingsSnapshot.UpdateDownloadSource), + nameof(AppSettingsSnapshot.UpdateDownloadThreads), + nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), + nameof(AppSettingsSnapshot.PendingUpdateVersion), + nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs), + nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs) ]); } @@ -564,10 +610,18 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl public Task DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, + string downloadSource, + int maxParallelSegments, IProgress? progress = null, CancellationToken cancellationToken = default) { - return _releaseUpdateService.DownloadAssetAsync(asset, destinationFilePath, progress, cancellationToken); + return _releaseUpdateService.DownloadAssetAsync( + asset, + destinationFilePath, + downloadSource, + maxParallelSegments, + progress, + cancellationToken); } public void Dispose() @@ -795,15 +849,50 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService internal sealed class ApplicationInfoService : IApplicationInfoService { + private const string Codename = "Administrate"; + public string GetAppVersionText() { - var version = typeof(App).Assembly.GetName().Version; - return version is null - ? "0.0.0" - : new Version( - Math.Max(0, version.Major), - Math.Max(0, version.Minor), - Math.Max(0, version.Build)).ToString(3); + var assembly = typeof(App).Assembly; + var informationalVersion = assembly + .GetCustomAttribute()? + .InformationalVersion; + if (!string.IsNullOrWhiteSpace(informationalVersion)) + { + var normalizedInformationalVersion = informationalVersion.Split('+', 2)[0].Trim(); + if (!string.IsNullOrWhiteSpace(normalizedInformationalVersion)) + { + return normalizedInformationalVersion; + } + } + + var version = assembly.GetName().Version; + if (version is null) + { + return "0.0.0"; + } + + if (version.Revision >= 0) + { + return version.ToString(4); + } + + if (version.Build >= 0) + { + return version.ToString(3); + } + + if (version.Minor >= 0) + { + return version.ToString(2); + } + + return version.ToString(); + } + + public string GetAppCodenameText() + { + return Codename; } public AppRenderBackendInfo GetRenderBackendInfo() diff --git a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs index 8185a82..30e3d52 100644 --- a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs +++ b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs @@ -281,6 +281,9 @@ internal sealed class SettingsWindowService : ISettingsWindowService refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) || changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase) || changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase); if (languageChanged) @@ -307,16 +310,33 @@ internal sealed class SettingsWindowService : ISettingsWindowService ? ThemeVariant.Dark : ThemeVariant.Light; - var accentColor = TryParseThemeColor(themeState.ThemeColor); + var settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); + var wallpaperState = settingsFacade.Wallpaper.Get(); + var monetPalette = settingsFacade.Theme.BuildPalette( + themeState.IsNightMode, + wallpaperState.WallpaperPath, + themeState.ThemeColor); + var accentColor = ResolveAccentColor(themeState.ThemeColor, monetPalette); var context = new ThemeColorContext( accentColor, IsLightBackground: !themeState.IsNightMode, IsLightNavBackground: !themeState.IsNightMode, - IsNightMode: themeState.IsNightMode); + IsNightMode: themeState.IsNightMode, + MonetColors: monetPalette.MonetColors); ThemeColorSystemService.ApplyThemeResources(window.Resources, context); GlassEffectService.ApplyGlassResources(window.Resources, context); } + private static Color ResolveAccentColor(string? colorText, MonetPalette monetPalette) + { + if (monetPalette.MonetColors is { Count: > 0 }) + { + return monetPalette.MonetColors[0]; + } + + return TryParseThemeColor(colorText); + } + private static Color TryParseThemeColor(string? colorText) { if (!string.IsNullOrWhiteSpace(colorText)) diff --git a/LanMountainDesktop/Services/ThemeColorSystemService.cs b/LanMountainDesktop/Services/ThemeColorSystemService.cs index 41c4727..9262ebf 100644 --- a/LanMountainDesktop/Services/ThemeColorSystemService.cs +++ b/LanMountainDesktop/Services/ThemeColorSystemService.cs @@ -1,4 +1,5 @@ -using Avalonia.Controls; +using System.Linq; +using Avalonia.Controls; using Avalonia.Media; using LanMountainDesktop.Theme; @@ -67,7 +68,12 @@ public static class ThemeColorSystemService private static AppThemePalette BuildPalette(ThemeColorContext context) { - var accent = context.AccentColor; + var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? []; + var accent = monetColors.Length > 0 ? monetColors[0] : context.AccentColor; + var secondarySeed = monetColors.Length > 1 + ? monetColors[1] + : ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.14); + var accentLight1 = ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.22); var accentLight2 = ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.38); var accentLight3 = ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.54); @@ -76,11 +82,24 @@ public static class ThemeColorSystemService var accentDark3 = ColorMath.Blend(accent, Color.Parse("#FF020617"), 0.40); var primary = context.IsNightMode ? accentLight1 : accentDark1; - var secondary = context.IsNightMode ? accentLight2 : accentDark2; + var secondary = context.IsNightMode + ? ColorMath.Blend(secondarySeed, Color.Parse("#FFFFFFFF"), 0.16) + : ColorMath.Blend(secondarySeed, Color.Parse("#FF111827"), 0.14); - var surfaceBase = context.IsNightMode ? Color.Parse("#FF0B1220") : Color.Parse("#FFF3F7FB"); - var surfaceRaised = context.IsNightMode ? Color.Parse("#FF1E293B") : Color.Parse("#FFFFFFFF"); - var surfaceOverlay = context.IsNightMode ? Color.Parse("#CC0B1220") : Color.Parse("#CCE2E8F0"); + var surfaceBase = context.IsNightMode + ? ColorMath.Blend(Color.Parse("#FF0A1018"), accent, 0.18) + : ColorMath.Blend(Color.Parse("#FFF7F9FD"), accent, 0.09); + var surfaceRaised = context.IsNightMode + ? ColorMath.Blend(Color.Parse("#FF121A24"), secondarySeed, 0.24) + : ColorMath.Blend(Color.Parse("#FFFCFEFF"), secondarySeed, 0.12); + var surfaceOverlayBase = context.IsNightMode + ? ColorMath.Blend(Color.Parse("#FF18212D"), accent, 0.28) + : ColorMath.Blend(Color.Parse("#FFF1F5FB"), accent, 0.16); + var surfaceOverlay = Color.FromArgb( + context.IsNightMode ? (byte)0xE8 : (byte)0xF2, + surfaceOverlayBase.R, + surfaceOverlayBase.G, + surfaceOverlayBase.B); var textPrimaryPreferred = context.IsLightBackground ? Color.Parse("#FF0B1220") : Color.Parse("#FFF8FAFC"); var textPrimary = ColorMath.EnsureContrast(textPrimaryPreferred, surfaceRaised, WcagNormalTextContrast); @@ -96,7 +115,9 @@ public static class ThemeColorSystemService ? ColorMath.EnsureContrast(ColorMath.Blend(accent, Color.Parse("#FF0B1220"), 0.20), surfaceRaised, WcagNormalTextContrast) : ColorMath.EnsureContrast(ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.16), surfaceRaised, WcagNormalTextContrast); - var navSurface = context.IsLightNavBackground ? surfaceRaised : Color.Parse("#FF111827"); + var navSurface = context.IsLightNavBackground + ? ColorMath.Blend(surfaceRaised, accentLight2, 0.08) + : ColorMath.Blend(Color.Parse("#FF111827"), accentDark2, 0.24); var navText = ColorMath.EnsureContrast( context.IsLightNavBackground ? Color.Parse("#FF0B1220") : Color.Parse("#FFF8FAFC"), navSurface, @@ -104,16 +125,22 @@ public static class ThemeColorSystemService var selectedSurfaceForContrast = ColorMath.Blend(accent, navSurface, 0.18); var navSelectedText = ColorMath.EnsureContrast(Color.Parse("#FFFFFFFF"), selectedSurfaceForContrast, WcagNormalTextContrast); - var navItemBackground = context.IsLightNavBackground ? Color.Parse("#33FFFFFF") : Color.Parse("#2A0F172A"); + var navItemBackground = context.IsLightNavBackground + ? Color.FromArgb(0x33, surfaceRaised.R, surfaceRaised.G, surfaceRaised.B) + : Color.FromArgb(0x38, navSurface.R, navSurface.G, navSurface.B); var navItemHoverBackground = context.IsLightNavBackground - ? ColorMath.WithAlpha(ColorMath.Blend(accentLight2, Color.Parse("#FFFFFFFF"), 0.48), 0x66) - : ColorMath.WithAlpha(ColorMath.Blend(accentDark1, Color.Parse("#33111827"), 0.32), 0x78); + ? ColorMath.WithAlpha(ColorMath.Blend(accentLight2, surfaceRaised, 0.30), 0x7A) + : ColorMath.WithAlpha(ColorMath.Blend(accentDark1, navSurface, 0.26), 0x88); var navItemSelectedBackground = ColorMath.WithAlpha(accent, context.IsNightMode ? (byte)0xCE : (byte)0xD9); var navSelectionIndicator = ColorMath.EnsureContrast(accentLight1, navSurface, WcagLargeTextContrast); var toggleOn = context.IsNightMode ? accent : accentDark1; - var toggleOff = context.IsNightMode ? Color.Parse("#66475569") : Color.Parse("#66CBD5E1"); - var toggleBorder = context.IsNightMode ? Color.Parse("#80E2E8F0") : Color.Parse("#8094A3B8"); + var toggleOff = context.IsNightMode + ? Color.FromArgb(0x88, accentDark2.R, accentDark2.G, accentDark2.B) + : Color.FromArgb(0x88, accentLight2.R, accentLight2.G, accentLight2.B); + var toggleBorder = context.IsNightMode + ? ColorMath.WithAlpha(ColorMath.Blend(accentLight2, Color.Parse("#FFF8FAFC"), 0.28), 0x8C) + : ColorMath.WithAlpha(ColorMath.Blend(accentDark2, Color.Parse("#FF334155"), 0.26), 0x78); var onAccent = ColorMath.EnsureContrast(Color.Parse("#FFFFFFFF"), accent, WcagNormalTextContrast); return new AppThemePalette( diff --git a/LanMountainDesktop/Services/UpdateSettingsValues.cs b/LanMountainDesktop/Services/UpdateSettingsValues.cs new file mode 100644 index 0000000..bb5e7f4 --- /dev/null +++ b/LanMountainDesktop/Services/UpdateSettingsValues.cs @@ -0,0 +1,63 @@ +using System; + +namespace LanMountainDesktop.Services; + +public static class UpdateSettingsValues +{ + public const string ChannelStable = "stable"; + public const string ChannelPreview = "preview"; + + public const string ModeManual = "manual"; + public const string ModeDownloadThenConfirm = "download_then_confirm"; + public const string ModeSilentOnExit = "silent_on_exit"; + + public const string DownloadSourceGitHub = "github"; + public const string DownloadSourceGhProxy = "gh-proxy"; + + public const int DefaultDownloadThreads = 4; + public const int MinDownloadThreads = 1; + public const int MaxDownloadThreads = 128; + public const string DefaultGhProxyBaseUrl = "https://gh-proxy.com/"; + + public static string NormalizeChannel(string? value, bool includePrereleaseFallback = false) + { + if (string.Equals(value, ChannelPreview, StringComparison.OrdinalIgnoreCase)) + { + return ChannelPreview; + } + + if (string.Equals(value, ChannelStable, StringComparison.OrdinalIgnoreCase)) + { + return ChannelStable; + } + + return includePrereleaseFallback ? ChannelPreview : ChannelStable; + } + + public static string NormalizeMode(string? value) + { + if (string.Equals(value, ModeManual, StringComparison.OrdinalIgnoreCase)) + { + return ModeManual; + } + + if (string.Equals(value, ModeSilentOnExit, StringComparison.OrdinalIgnoreCase)) + { + return ModeSilentOnExit; + } + + return ModeDownloadThenConfirm; + } + + public static string NormalizeDownloadSource(string? value) + { + return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase) + ? DownloadSourceGhProxy + : DownloadSourceGitHub; + } + + public static int NormalizeDownloadThreads(int value) + { + return Math.Clamp(value, MinDownloadThreads, MaxDownloadThreads); + } +} diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs new file mode 100644 index 0000000..091bfea --- /dev/null +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -0,0 +1,291 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services.Settings; + +namespace LanMountainDesktop.Services; + +public sealed record UpdatePendingInfo( + string InstallerPath, + string VersionText, + DateTimeOffset? PublishedAt); + +public sealed record UpdateInstallerLaunchResult( + bool Success, + bool UserCancelledElevation, + string? ErrorMessage); + +internal static class HostUpdateWorkflowServiceProvider +{ + private static readonly object Gate = new(); + private static UpdateWorkflowService? _instance; + + public static UpdateWorkflowService GetOrCreate() + { + lock (Gate) + { + return _instance ??= new UpdateWorkflowService(HostSettingsFacadeProvider.GetOrCreate()); + } + } +} + +public sealed class UpdateWorkflowService +{ + private readonly ISettingsFacadeService _settingsFacade; + private readonly string _updatesDirectory; + + public UpdateWorkflowService(ISettingsFacadeService settingsFacade) + { + _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); + _updatesDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop", + "Updates"); + } + + public UpdatePendingInfo? GetPendingUpdate() + { + var state = _settingsFacade.Update.Get(); + return GetPendingUpdate(state); + } + + public async Task CheckForUpdatesAsync( + Version currentVersion, + CancellationToken cancellationToken = default) + { + var state = _settingsFacade.Update.Get(); + var includePrerelease = string.Equals( + UpdateSettingsValues.NormalizeChannel(state.UpdateChannel, state.IncludePrereleaseUpdates), + UpdateSettingsValues.ChannelPreview, + StringComparison.OrdinalIgnoreCase); + + var result = await _settingsFacade.Update.CheckForUpdatesAsync( + currentVersion, + includePrerelease, + cancellationToken); + + SaveState(state with + { + LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }); + + return result; + } + + public async Task DownloadReleaseAsync( + UpdateCheckResult checkResult, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(checkResult); + + if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null) + { + return new UpdateDownloadResult(false, null, "No compatible update asset is available."); + } + + var state = _settingsFacade.Update.Get(); + var existingPending = GetPendingUpdate(state); + if (existingPending is not null && + string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) && + File.Exists(existingPending.InstallerPath)) + { + return new UpdateDownloadResult(true, existingPending.InstallerPath, null); + } + + Directory.CreateDirectory(_updatesDirectory); + var fileName = SanitizeFileName(checkResult.PreferredAsset.Name); + var destinationPath = Path.Combine(_updatesDirectory, fileName); + + var result = await _settingsFacade.Update.DownloadAssetAsync( + checkResult.PreferredAsset, + destinationPath, + state.UpdateDownloadSource, + state.UpdateDownloadThreads, + progress, + cancellationToken); + + if (result.Success) + { + SaveState(state with + { + PendingUpdateInstallerPath = result.FilePath ?? destinationPath, + PendingUpdateVersion = checkResult.LatestVersionText, + PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue + ? null + : checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(), + LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }); + } + + return result; + } + + public async Task AutoCheckIfEnabledAsync( + Version currentVersion, + CancellationToken cancellationToken = default) + { + var state = _settingsFacade.Update.Get(); + if (!state.AutoCheckUpdates) + { + return; + } + + try + { + var result = await CheckForUpdatesAsync(currentVersion, cancellationToken); + if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null) + { + return; + } + + var normalizedMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode); + if (string.Equals(normalizedMode, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await DownloadReleaseAsync(result, cancellationToken: cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + AppLogger.Warn("UpdateWorkflow", "Automatic update check failed.", ex); + } + } + + public UpdateInstallerLaunchResult LaunchPendingInstallerNow() + { + return LaunchPendingInstaller(silent: false, exitApplicationAfterLaunch: true); + } + + public bool TryApplyPendingUpdateOnExit() + { + var state = _settingsFacade.Update.Get(); + if (!string.Equals( + UpdateSettingsValues.NormalizeMode(state.UpdateMode), + UpdateSettingsValues.ModeSilentOnExit, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var result = LaunchPendingInstaller(silent: true, exitApplicationAfterLaunch: false); + if (!result.Success && !string.IsNullOrWhiteSpace(result.ErrorMessage)) + { + AppLogger.Warn("UpdateWorkflow", $"Silent update on exit failed: {result.ErrorMessage}"); + } + + return result.Success; + } + + public void ClearPendingUpdate() + { + var state = _settingsFacade.Update.Get(); + SaveState(state with + { + PendingUpdateInstallerPath = null, + PendingUpdateVersion = null, + PendingUpdatePublishedAtUtcMs = null + }); + } + + private UpdateInstallerLaunchResult LaunchPendingInstaller(bool silent, bool exitApplicationAfterLaunch) + { + var state = _settingsFacade.Update.Get(); + var pending = GetPendingUpdate(state); + if (pending is null) + { + return new UpdateInstallerLaunchResult(false, false, "No pending installer is available."); + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = pending.InstallerPath, + WorkingDirectory = Path.GetDirectoryName(pending.InstallerPath) ?? _updatesDirectory, + UseShellExecute = true, + Verb = OperatingSystem.IsWindows() ? "runas" : string.Empty, + Arguments = silent ? "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" : string.Empty + }; + + Process.Start(startInfo); + ClearPendingUpdate(); + + if (exitApplicationAfterLaunch) + { + App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest( + Source: "Update", + Reason: silent + ? "Silent installer launched." + : "Installer launched from update page.")); + } + + return new UpdateInstallerLaunchResult(true, false, null); + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) + { + return new UpdateInstallerLaunchResult(false, true, ex.Message); + } + catch (Exception ex) + { + return new UpdateInstallerLaunchResult(false, false, ex.Message); + } + } + + private UpdatePendingInfo? GetPendingUpdate(UpdateSettingsState state) + { + var installerPath = state.PendingUpdateInstallerPath?.Trim(); + if (string.IsNullOrWhiteSpace(installerPath)) + { + return null; + } + + if (!File.Exists(installerPath)) + { + ClearPendingUpdate(); + return null; + } + + DateTimeOffset? publishedAt = state.PendingUpdatePublishedAtUtcMs is > 0 + ? DateTimeOffset.FromUnixTimeMilliseconds(state.PendingUpdatePublishedAtUtcMs.Value) + : null; + + return new UpdatePendingInfo( + installerPath, + string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion, + publishedAt); + } + + private void SaveState(UpdateSettingsState state) + { + _settingsFacade.Update.Save(state); + } + + private static string SanitizeFileName(string? fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return FormattableString.Invariant($"LanMountainDesktop-update-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.exe"); + } + + var invalid = Path.GetInvalidFileNameChars(); + Span buffer = stackalloc char[fileName.Length]; + var index = 0; + foreach (var ch in fileName) + { + buffer[index++] = Array.IndexOf(invalid, ch) >= 0 ? '_' : ch; + } + + return new string(buffer[..index]); + } +} diff --git a/LanMountainDesktop/Theme/ThemeColorContext.cs b/LanMountainDesktop/Theme/ThemeColorContext.cs index 60a4075..49574b6 100644 --- a/LanMountainDesktop/Theme/ThemeColorContext.cs +++ b/LanMountainDesktop/Theme/ThemeColorContext.cs @@ -1,4 +1,5 @@ -using Avalonia.Media; +using System.Collections.Generic; +using Avalonia.Media; namespace LanMountainDesktop.Theme; @@ -6,4 +7,5 @@ public sealed record ThemeColorContext( Color AccentColor, bool IsLightBackground, bool IsLightNavBackground, - bool IsNightMode); + bool IsNightMode, + IReadOnlyList? MonetColors = null); diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index 3f1f39a..7b7949d 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -1001,96 +1001,293 @@ public sealed partial class AboutSettingsPageViewModel : ViewModelBase private readonly ISettingsFacadeService _settingsFacade; private readonly LocalizationService _localizationService = new(); private readonly string _languageCode; - private bool _isInitializing; public AboutSettingsPageViewModel(ISettingsFacadeService settingsFacade) { _settingsFacade = settingsFacade; _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); - UpdateChannels = CreateUpdateChannels(); RefreshLocalizedText(); - var update = _settingsFacade.Update.Get(); - _isInitializing = true; - AutoCheckUpdates = update.AutoCheckUpdates; - IncludePrereleaseUpdates = update.IncludePrereleaseUpdates; - SelectedUpdateChannel = UpdateChannels.FirstOrDefault(option => - string.Equals( - option.Value, - string.IsNullOrWhiteSpace(update.UpdateChannel) ? "stable" : update.UpdateChannel, - StringComparison.OrdinalIgnoreCase)) - ?? UpdateChannels[0]; - - var versionText = _settingsFacade.ApplicationInfo.GetAppVersionText(); + VersionText = _settingsFacade.ApplicationInfo.GetAppVersionText(); + CodenameText = _settingsFacade.ApplicationInfo.GetAppCodenameText(); var backendInfo = _settingsFacade.ApplicationInfo.GetRenderBackendInfo(); - var renderBackendText = string.IsNullOrWhiteSpace(backendInfo.ImplementationTypeName) + RenderBackendText = string.IsNullOrWhiteSpace(backendInfo.ImplementationTypeName) ? backendInfo.ActualBackend : $"{backendInfo.ActualBackend} ({backendInfo.ImplementationTypeName})"; - VersionText = string.Format( - CultureInfo.CurrentCulture, - L("settings.about.version_format", "Version: {0}"), - versionText); - RenderBackendText = string.Format( - CultureInfo.CurrentCulture, - L("settings.about.render_backend_format", "Render Backend: {0}"), - renderBackendText); - UpdateStatus = L("settings.update.status_idle", "No update check has been performed yet."); - _isInitializing = false; } - public IReadOnlyList UpdateChannels { get; } - [ObservableProperty] private string _versionText = "-"; + [ObservableProperty] + private string _codenameText = "-"; + [ObservableProperty] private string _renderBackendText = "-"; - [ObservableProperty] - private bool _autoCheckUpdates; - - [ObservableProperty] - private bool _includePrereleaseUpdates; - - [ObservableProperty] - private SelectionOption _selectedUpdateChannel = new("stable", "Stable"); - - [ObservableProperty] - private string _updateStatus = string.Empty; - - [ObservableProperty] - private bool _isCheckingForUpdates; - [ObservableProperty] private string _pageTitle = string.Empty; [ObservableProperty] private string _pageDescription = string.Empty; - [ObservableProperty] - private string _autoCheckUpdatesLabel = string.Empty; - - [ObservableProperty] - private string _includePrereleaseUpdatesLabel = string.Empty; - - [ObservableProperty] - private string _updateChannelLabel = string.Empty; - - [ObservableProperty] - private string _checkForUpdatesButtonText = string.Empty; - [ObservableProperty] private string _appInfoHeader = string.Empty; - [ObservableProperty] - private string _updateHeader = string.Empty; - [ObservableProperty] private string _versionLabel = string.Empty; + [ObservableProperty] + private string _codenameLabel = string.Empty; + [ObservableProperty] private string _renderBackendLabel = string.Empty; + private void RefreshLocalizedText() + { + PageTitle = L("settings.about.title", "About"); + PageDescription = L("settings.about.description", "Application details."); + AppInfoHeader = L("settings.about.app_info_header", "Application Information"); + VersionLabel = L("settings.about.version_label", "Version"); + CodenameLabel = L("settings.about.codename_label", "Codename"); + RenderBackendLabel = L("settings.about.render_backend_label", "Render Backend"); + } + + private string L(string key, string fallback) + => _localizationService.GetString(_languageCode, key, fallback); +} + +public sealed partial class UpdateSettingsPageViewModel : ViewModelBase +{ + private readonly ISettingsFacadeService _settingsFacade; + private readonly UpdateWorkflowService _updateWorkflowService; + private readonly LocalizationService _localizationService = new(); + private readonly string _languageCode; + private readonly Version _currentVersion; + private bool _isInitializing; + private UpdateCheckResult? _lastCheckResult; + + public IReadOnlyList UpdateChannelOptions { get; } + + public IReadOnlyList UpdateSourceOptions { get; } + + public IReadOnlyList UpdateModeOptions { get; } + + public IReadOnlyList DownloadThreadOptions { get; } + + public UpdateSettingsPageViewModel( + ISettingsFacadeService settingsFacade, + UpdateWorkflowService? updateWorkflowService = null) + { + _settingsFacade = settingsFacade; + _updateWorkflowService = updateWorkflowService ?? HostUpdateWorkflowServiceProvider.GetOrCreate(); + _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); + RefreshLocalizedText(); + UpdateChannelOptions = CreateUpdateChannelOptions(); + UpdateSourceOptions = CreateUpdateSourceOptions(); + UpdateModeOptions = CreateUpdateModeOptions(); + DownloadThreadOptions = CreateDownloadThreadOptions(); + + var versionText = _settingsFacade.ApplicationInfo.GetAppVersionText(); + _currentVersion = Version.TryParse(versionText, out var parsedVersion) + ? parsedVersion + : new Version(0, 0, 0); + + CurrentVersionText = versionText; + LoadStateFromSettings(); + } + + [ObservableProperty] + private bool _autoCheckUpdates; + + [ObservableProperty] + private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable; + + [ObservableProperty] + private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub; + + [ObservableProperty] + private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm; + + [ObservableProperty] + private string _currentVersionText = "-"; + + [ObservableProperty] + private string _updateStatus = string.Empty; + + [ObservableProperty] + private bool _isCheckingForUpdates; + + [ObservableProperty] + private bool _isDownloading; + + [ObservableProperty] + private double _downloadProgressValue; + + [ObservableProperty] + private bool _isDownloadProgressVisible; + + [ObservableProperty] + private string _downloadProgressText = string.Empty; + + [ObservableProperty] + private string _pageTitle = string.Empty; + + [ObservableProperty] + private string _pageDescription = string.Empty; + + [ObservableProperty] + private string _statusCardTitle = string.Empty; + + [ObservableProperty] + private string _statusCardDescription = string.Empty; + + [ObservableProperty] + private string _preferencesHeader = string.Empty; + + [ObservableProperty] + private string _preferencesDescription = string.Empty; + + [ObservableProperty] + private string _autoCheckUpdatesLabel = string.Empty; + + [ObservableProperty] + private string _updateChannelLabel = string.Empty; + + [ObservableProperty] + private string _updateSourceLabel = string.Empty; + + [ObservableProperty] + private string _updateModeLabel = 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 _checkForUpdatesButtonText = string.Empty; + + [ObservableProperty] + private string _downloadButtonText = string.Empty; + + [ObservableProperty] + private string _installNowButtonText = string.Empty; + + [ObservableProperty] + private string _latestVersionText = string.Empty; + + [ObservableProperty] + private string _publishedAtText = string.Empty; + + [ObservableProperty] + private string _lastCheckedText = string.Empty; + + [ObservableProperty] + private bool _isLatestVersionVisible; + + [ObservableProperty] + private bool _isPublishedAtVisible; + + [ObservableProperty] + private bool _isLastCheckedVisible; + + [ObservableProperty] + private bool _hasPendingInstaller; + + [ObservableProperty] + private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads; + + [ObservableProperty] + private string _selectedUpdateChannelDescription = string.Empty; + + [ObservableProperty] + private string _selectedUpdateModeDescription = string.Empty; + + [ObservableProperty] + private string _selectedUpdateSourceDescription = string.Empty; + + [ObservableProperty] + private string _downloadThreadsLabel = string.Empty; + + [ObservableProperty] + private string _downloadThreadsDescription = string.Empty; + + [ObservableProperty] + private string _stableChannelText = string.Empty; + + [ObservableProperty] + private string _previewChannelText = string.Empty; + + [ObservableProperty] + private string _gitHubSourceText = string.Empty; + + [ObservableProperty] + private string _ghProxySourceText = string.Empty; + + [ObservableProperty] + private string _manualModeText = string.Empty; + + [ObservableProperty] + private string _downloadThenConfirmModeText = string.Empty; + + [ObservableProperty] + private string _silentOnExitModeText = string.Empty; + + [ObservableProperty] + private SelectionOption? _selectedUpdateChannelOption; + + [ObservableProperty] + private SelectionOption? _selectedUpdateSourceOption; + + [ObservableProperty] + private SelectionOption? _selectedUpdateModeOption; + + [ObservableProperty] + private SelectionOption? _selectedDownloadThreadsOption; + + [ObservableProperty] + private string _downloadThreadsText = UpdateSettingsValues.DefaultDownloadThreads.ToString(CultureInfo.CurrentCulture); + + public bool IsStableChannelSelected => + string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelStable, StringComparison.OrdinalIgnoreCase); + + public bool IsPreviewChannelSelected => + string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase); + + public bool IsGitHubSourceSelected => + string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase); + + public bool IsGhProxySourceSelected => + string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase); + + public bool IsManualModeSelected => + string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase); + + public bool IsDownloadThenConfirmModeSelected => + string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase); + + public bool IsSilentOnExitModeSelected => + string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase); + + public bool IsDownloadButtonVisible => + !HasPendingInstaller && + _lastCheckResult is { Success: true, IsUpdateAvailable: true, PreferredAsset: not null }; + + public bool IsInstallButtonVisible => HasPendingInstaller; + + public string DownloadThreadsValueText => + UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture); + + private bool IsBusy => IsCheckingForUpdates || IsDownloading; + partial void OnAutoCheckUpdatesChanged(bool value) { if (_isInitializing) @@ -1101,7 +1298,72 @@ public sealed partial class AboutSettingsPageViewModel : ViewModelBase SaveUpdateSettings(); } - partial void OnIncludePrereleaseUpdatesChanged(bool value) + partial void OnSelectedUpdateChannelOptionChanged(SelectionOption? value) + { + if (value is not null && + !string.Equals(SelectedUpdateChannelValue, value.Value, StringComparison.OrdinalIgnoreCase)) + { + SelectedUpdateChannelValue = value.Value; + } + } + + partial void OnSelectedUpdateSourceOptionChanged(SelectionOption? value) + { + if (value is not null && + !string.Equals(SelectedUpdateSourceValue, value.Value, StringComparison.OrdinalIgnoreCase)) + { + SelectedUpdateSourceValue = value.Value; + } + } + + partial void OnSelectedUpdateModeOptionChanged(SelectionOption? value) + { + if (value is not null && + !string.Equals(SelectedUpdateModeValue, value.Value, StringComparison.OrdinalIgnoreCase)) + { + SelectedUpdateModeValue = value.Value; + } + } + + partial void OnSelectedDownloadThreadsOptionChanged(SelectionOption? value) + { + if (value is null || !int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + { + return; + } + + ApplyDownloadThreadsValue(parsed, !_isInitializing); + } + + partial void OnSelectedUpdateChannelValueChanged(string value) + { + if (_isInitializing) + { + return; + } + + _lastCheckResult = null; + if (!HasPendingInstaller) + { + LatestVersionText = string.Empty; + PublishedAtText = string.Empty; + IsLatestVersionVisible = false; + IsPublishedAtVisible = false; + } + + SaveUpdateSettings(); + UpdateStatus = string.Format( + CultureInfo.CurrentCulture, + L("settings.update.status_channel_changed_format", "Update channel switched to {0}. Please check again."), + string.Equals(value, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase) + ? L("settings.update.channel_preview", "Preview") + : L("settings.update.channel_stable", "Stable")); + SelectedUpdateChannelDescription = BuildUpdateChannelDescription(value); + SyncSelectedOptions(); + RefreshActionState(); + } + + partial void OnSelectedUpdateSourceValueChanged(string value) { if (_isInitializing) { @@ -1109,63 +1371,196 @@ public sealed partial class AboutSettingsPageViewModel : ViewModelBase } SaveUpdateSettings(); + SelectedUpdateSourceDescription = BuildUpdateSourceDescription(value); + UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved."); + SyncSelectedOptions(); } - partial void OnSelectedUpdateChannelChanged(SelectionOption value) + partial void OnSelectedUpdateModeValueChanged(string value) { - if (_isInitializing || value is null) + if (_isInitializing) { return; } SaveUpdateSettings(); + SelectedUpdateModeDescription = BuildUpdateModeDescription(value); + UpdateStatus = HasPendingInstaller + ? BuildPendingReadyStatus() + : L("settings.update.status_preferences_saved", "Update preferences saved."); + SyncSelectedOptions(); + RefreshActionState(); } - private void SaveUpdateSettings() + partial void OnDownloadThreadsSliderValueChanged(double value) { - _settingsFacade.Update.Save(new UpdateSettingsState( - AutoCheckUpdates, - IncludePrereleaseUpdates, - SelectedUpdateChannel.Value)); - UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved."); - } + var normalized = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(value)); + if (Math.Abs(value - normalized) > double.Epsilon) + { + DownloadThreadsSliderValue = normalized; + return; + } - [RelayCommand] - private async Task CheckForUpdatesAsync() - { - if (IsCheckingForUpdates) + OnPropertyChanged(nameof(DownloadThreadsValueText)); + if (_isInitializing) { return; } + SaveUpdateSettings(); + UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved."); + SyncSelectedOptions(); + } + + partial void OnDownloadThreadsTextChanged(string value) + { + if (_isInitializing) + { + return; + } + + if (!TryParseDownloadThreads(value, out var parsed)) + { + return; + } + + ApplyDownloadThreadsValue(parsed, true); + } + + partial void OnHasPendingInstallerChanged(bool value) + { + RefreshActionState(); + if (!value) + { + UpdateStatus = L("settings.update.status_ready", "Ready to check for updates."); + } + } + + partial void OnIsCheckingForUpdatesChanged(bool value) + { + CheckForUpdatesCommand.NotifyCanExecuteChanged(); + DownloadLatestReleaseCommand.NotifyCanExecuteChanged(); + InstallPendingUpdateCommand.NotifyCanExecuteChanged(); + } + + partial void OnIsDownloadingChanged(bool value) + { + CheckForUpdatesCommand.NotifyCanExecuteChanged(); + DownloadLatestReleaseCommand.NotifyCanExecuteChanged(); + InstallPendingUpdateCommand.NotifyCanExecuteChanged(); + } + + [RelayCommand] + private void SelectStableChannel() + { + SelectedUpdateChannelValue = UpdateSettingsValues.ChannelStable; + } + + [RelayCommand] + private void SelectPreviewChannel() + { + SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview; + } + + [RelayCommand] + private void SelectGitHubSource() + { + SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub; + } + + [RelayCommand] + private void SelectGhProxySource() + { + SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGhProxy; + } + + [RelayCommand] + private void SelectManualMode() + { + SelectedUpdateModeValue = UpdateSettingsValues.ModeManual; + } + + [RelayCommand] + private void SelectDownloadThenConfirmMode() + { + SelectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm; + } + + [RelayCommand] + private void SelectSilentOnExitMode() + { + SelectedUpdateModeValue = UpdateSettingsValues.ModeSilentOnExit; + } + + private void SaveUpdateSettings() + { + var current = _settingsFacade.Update.Get(); + _settingsFacade.Update.Save(current with + { + AutoCheckUpdates = AutoCheckUpdates, + IncludePrereleaseUpdates = string.Equals( + SelectedUpdateChannelValue, + UpdateSettingsValues.ChannelPreview, + StringComparison.OrdinalIgnoreCase), + UpdateChannel = SelectedUpdateChannelValue, + UpdateMode = SelectedUpdateModeValue, + UpdateDownloadSource = SelectedUpdateSourceValue, + UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)) + }); + } + + private bool CanCheckForUpdates() => !IsBusy; + + [RelayCommand(CanExecute = nameof(CanCheckForUpdates))] + private async Task CheckForUpdatesAsync() + { try { IsCheckingForUpdates = true; - var version = Version.TryParse(VersionText, out var currentVersion) - ? currentVersion - : new Version(0, 0, 0); + IsDownloadProgressVisible = false; + DownloadProgressValue = 0; + DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); + UpdateStatus = L("settings.update.status_checking", "Checking GitHub releases..."); + + var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion); + _lastCheckResult = result.Success ? result : null; + RefreshLastCheckedFromSettings(); - var result = await _settingsFacade.Update.CheckForUpdatesAsync(version, IncludePrereleaseUpdates); if (!result.Success) { UpdateStatus = string.IsNullOrWhiteSpace(result.ErrorMessage) ? L("settings.update.status_check_failed", "Failed to check for updates.") - : result.ErrorMessage; + : string.Format( + CultureInfo.CurrentCulture, + L("settings.update.status_check_failed_format", "Update check failed: {0}"), + result.ErrorMessage); return; } - UpdateStatus = result.IsUpdateAvailable - ? string.Format( - CultureInfo.CurrentCulture, - L( - "settings.update.status_available_summary_format", - "Update available: {0} (current: {1})"), - result.LatestVersionText, - result.CurrentVersionText) - : string.Format( - CultureInfo.CurrentCulture, - L("settings.update.status_up_to_date_format", "You are up to date ({0})."), - result.CurrentVersionText); + ApplyCheckResultDisplay(result); + if (!result.IsUpdateAvailable) + { + return; + } + + if (result.PreferredAsset is null) + { + UpdateStatus = L( + "settings.update.status_asset_missing", + "A new release is available, but no compatible installer was found."); + return; + } + + if (!string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase)) + { + await DownloadLatestReleaseCoreAsync(result, invokedFromCheck: true); + return; + } + + UpdateStatus = string.Format( + CultureInfo.CurrentCulture, + L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."), + result.LatestVersionText); } finally { @@ -1173,27 +1568,345 @@ public sealed partial class AboutSettingsPageViewModel : ViewModelBase } } - private IReadOnlyList CreateUpdateChannels() + private bool CanDownloadLatestRelease() => !IsBusy && IsDownloadButtonVisible; + + [RelayCommand(CanExecute = nameof(CanDownloadLatestRelease))] + private async Task DownloadLatestReleaseAsync() { - return - [ - new SelectionOption("stable", L("settings.update.channel_stable", "Stable")), - new SelectionOption("preview", L("settings.update.channel_preview", "Preview")) - ]; + await DownloadLatestReleaseCoreAsync(_lastCheckResult, invokedFromCheck: false); + } + + private bool CanInstallPendingUpdate() => !IsBusy && HasPendingInstaller; + + [RelayCommand(CanExecute = nameof(CanInstallPendingUpdate))] + private void InstallPendingUpdate() + { + var result = _updateWorkflowService.LaunchPendingInstallerNow(); + if (result.Success) + { + UpdateStatus = L( + "settings.update.status_installer_started", + "Installer started. The app will close for update."); + HasPendingInstaller = false; + return; + } + + UpdateStatus = result.UserCancelledElevation + ? L( + "settings.update.status_elevation_cancelled", + "Administrator permission was not granted. Update was cancelled.") + : string.Format( + CultureInfo.CurrentCulture, + L("settings.update.status_launch_failed_format", "Failed to start installer: {0}"), + result.ErrorMessage ?? L("settings.update.status_installer_missing", "Installer file was not found after download.")); } private void RefreshLocalizedText() { - PageTitle = L("settings.about.title", "About"); - PageDescription = L("settings.about.description", "Application details and update preferences."); - AppInfoHeader = L("settings.about.app_info_header", "Application Information"); - UpdateHeader = L("settings.about.update_header", "Updates"); - VersionLabel = L("settings.about.version_label", "Version"); - RenderBackendLabel = L("settings.about.render_backend_label", "Render Backend"); + PageTitle = L("settings.update.title", "Update"); + PageDescription = L("settings.update.description", "Update checks and release channel preferences."); + StatusCardTitle = L("settings.update.status_card_title", "Update Status"); + StatusCardDescription = L("settings.update.status_card_description", "Check for updates and review the latest release information."); + PreferencesHeader = L("settings.update.preferences_header", "Update Preferences"); + PreferencesDescription = L("settings.update.preferences_description", "Choose your release channel, download source, behavior, and download speed."); AutoCheckUpdatesLabel = L("settings.update.auto_check_toggle", "Automatically check for updates on startup"); - IncludePrereleaseUpdatesLabel = L("settings.update.include_prerelease_toggle", "Include prerelease versions"); UpdateChannelLabel = L("settings.update.channel_label", "Update Channel"); + UpdateSourceLabel = L("settings.update.source_label", "Download Source"); + UpdateModeLabel = L("settings.update.mode_label", "Update Mode"); + DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads"); + DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates."); CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates"); + DownloadButtonText = L("settings.update.download_install_button", "Download & Install"); + InstallNowButtonText = L("settings.update.install_now_button", "Install Now"); + 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"); + StableChannelText = L("settings.update.channel_stable", "Stable"); + PreviewChannelText = L("settings.update.channel_preview", "Preview"); + GitHubSourceText = L("settings.update.source_github", "GitHub"); + GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy"); + ManualModeText = L("settings.update.mode_manual", "Manual Update"); + DownloadThenConfirmModeText = L("settings.update.mode_download_then_confirm", "Silent Download"); + SilentOnExitModeText = L("settings.update.mode_silent_on_exit", "Silent Install"); + SelectedUpdateChannelDescription = BuildUpdateChannelDescription(SelectedUpdateChannelValue); + SelectedUpdateModeDescription = BuildUpdateModeDescription(SelectedUpdateModeValue); + SelectedUpdateSourceDescription = BuildUpdateSourceDescription(SelectedUpdateSourceValue); + } + + private void LoadStateFromSettings() + { + var update = _settingsFacade.Update.Get(); + _isInitializing = true; + AutoCheckUpdates = update.AutoCheckUpdates; + SelectedUpdateChannelValue = UpdateSettingsValues.NormalizeChannel(update.UpdateChannel, update.IncludePrereleaseUpdates); + SelectedUpdateSourceValue = UpdateSettingsValues.NormalizeDownloadSource(update.UpdateDownloadSource); + SelectedUpdateModeValue = UpdateSettingsValues.NormalizeMode(update.UpdateMode); + DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(update.UpdateDownloadThreads); + DownloadThreadsText = ((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture); + _isInitializing = false; + + SyncSelectedOptions(); + RefreshLastCheckedFromSettings(); + ApplyPendingState(update); + if (!HasPendingInstaller) + { + UpdateStatus = L("settings.update.status_idle", "No update check has been performed yet."); + } + + RefreshActionState(); + } + + private void RefreshLastCheckedFromSettings() + { + var update = _settingsFacade.Update.Get(); + LastCheckedText = FormatTimestamp(update.LastUpdateCheckUtcMs); + IsLastCheckedVisible = !string.IsNullOrWhiteSpace(LastCheckedText); + } + + private void ApplyPendingState(UpdateSettingsState update) + { + var pending = _updateWorkflowService.GetPendingUpdate(); + HasPendingInstaller = pending is not null; + if (pending is null) + { + return; + } + + LatestVersionText = pending.VersionText; + IsLatestVersionVisible = !string.IsNullOrWhiteSpace(LatestVersionText); + PublishedAtText = pending.PublishedAt is null ? string.Empty : FormatTimestamp(pending.PublishedAt.Value.ToUnixTimeMilliseconds()); + IsPublishedAtVisible = !string.IsNullOrWhiteSpace(PublishedAtText); + UpdateStatus = BuildPendingReadyStatus(); + } + + private void ApplyCheckResultDisplay(UpdateCheckResult result) + { + if (result.IsUpdateAvailable) + { + LatestVersionText = result.LatestVersionText; + IsLatestVersionVisible = !string.IsNullOrWhiteSpace(LatestVersionText); + PublishedAtText = result.Release is null || result.Release.PublishedAt == DateTimeOffset.MinValue + ? string.Empty + : FormatTimestamp(result.Release.PublishedAt.ToUnixTimeMilliseconds()); + IsPublishedAtVisible = !string.IsNullOrWhiteSpace(PublishedAtText); + return; + } + + LatestVersionText = string.Empty; + PublishedAtText = string.Empty; + IsLatestVersionVisible = false; + IsPublishedAtVisible = false; + UpdateStatus = string.Format( + CultureInfo.CurrentCulture, + L("settings.update.status_up_to_date_format", "You are up to date ({0})."), + result.CurrentVersionText); + } + + private async Task DownloadLatestReleaseCoreAsync(UpdateCheckResult? result, bool invokedFromCheck) + { + if (result is null || !result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null) + { + return; + } + + try + { + IsDownloading = true; + IsDownloadProgressVisible = true; + DownloadProgressValue = 0; + DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); + UpdateStatus = L("settings.update.status_downloading", "Downloading installer..."); + + var progress = new Progress(value => + { + DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d); + DownloadProgressText = string.Format( + CultureInfo.CurrentCulture, + L("settings.update.download_progress_format", "Download progress: {0:F0}%"), + DownloadProgressValue); + }); + + var downloadResult = await _updateWorkflowService.DownloadReleaseAsync(result, progress); + if (!downloadResult.Success) + { + UpdateStatus = string.Format( + CultureInfo.CurrentCulture, + L("settings.update.status_download_failed_format", "Download failed: {0}"), + downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates.")); + return; + } + + ApplyPendingState(_settingsFacade.Update.Get()); + UpdateStatus = BuildPendingReadyStatus(); + if (!invokedFromCheck) + { + _lastCheckResult = result; + } + } + finally + { + IsDownloading = false; + IsDownloadProgressVisible = false; + } + } + + private string BuildPendingReadyStatus() + { + return string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase) + ? L("settings.update.status_downloaded_exit", "Update downloaded. It will be installed when you exit the app.") + : L("settings.update.status_downloaded_confirm", "Update downloaded. Review it and choose when to install."); + } + + private string BuildUpdateModeDescription(string? value) + { + return UpdateSettingsValues.NormalizeMode(value) switch + { + UpdateSettingsValues.ModeManual => L( + "settings.update.mode_manual_desc", + "Only check for updates. You decide when downloads and installation happen."), + UpdateSettingsValues.ModeSilentOnExit => L( + "settings.update.mode_silent_on_exit_desc", + "Download updates in the background and install them the next time you exit the app."), + _ => L( + "settings.update.mode_download_then_confirm_desc", + "Download updates in the background and ask for confirmation before installing them.") + }; + } + + private string BuildUpdateChannelDescription(string? value) + { + return UpdateSettingsValues.NormalizeChannel(value) switch + { + UpdateSettingsValues.ChannelPreview => L( + "settings.update.channel_preview_desc", + "Preview builds may contain newer features but can be less stable."), + _ => L( + "settings.update.channel_stable_desc", + "Stable builds prioritize reliability and are recommended for most users.") + }; + } + + private string BuildUpdateSourceDescription(string? value) + { + return UpdateSettingsValues.NormalizeDownloadSource(value) switch + { + UpdateSettingsValues.DownloadSourceGhProxy => L( + "settings.update.source_ghproxy_desc", + "Use the gh-proxy mirror when downloading GitHub release assets."), + _ => L( + "settings.update.source_github_desc", + "Download release assets directly from GitHub.") + }; + } + + private string FormatTimestamp(long? utcMs) + { + if (utcMs is not > 0) + { + return string.Empty; + } + + try + { + return DateTimeOffset + .FromUnixTimeMilliseconds(utcMs.Value) + .ToLocalTime() + .ToString("g", CultureInfo.CurrentCulture); + } + catch (ArgumentOutOfRangeException) + { + return string.Empty; + } + } + + private void RefreshActionState() + { + OnPropertyChanged(nameof(IsDownloadButtonVisible)); + OnPropertyChanged(nameof(IsInstallButtonVisible)); + OnPropertyChanged(nameof(DownloadThreadsValueText)); + } + + private IReadOnlyList CreateUpdateChannelOptions() + { + return + [ + new SelectionOption(UpdateSettingsValues.ChannelStable, StableChannelText), + new SelectionOption(UpdateSettingsValues.ChannelPreview, PreviewChannelText) + ]; + } + + private IReadOnlyList CreateUpdateSourceOptions() + { + return + [ + new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText), + new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText) + ]; + } + + private IReadOnlyList CreateUpdateModeOptions() + { + return + [ + new SelectionOption(UpdateSettingsValues.ModeManual, ManualModeText), + new SelectionOption(UpdateSettingsValues.ModeDownloadThenConfirm, DownloadThenConfirmModeText), + new SelectionOption(UpdateSettingsValues.ModeSilentOnExit, SilentOnExitModeText) + ]; + } + + private IReadOnlyList CreateDownloadThreadOptions() + { + return Enumerable + .Range(UpdateSettingsValues.MinDownloadThreads, UpdateSettingsValues.MaxDownloadThreads) + .Select(value => new SelectionOption( + value.ToString(CultureInfo.InvariantCulture), + value.ToString(CultureInfo.CurrentCulture))) + .ToList(); + } + + private void SyncSelectedOptions() + { + SelectedUpdateChannelOption = UpdateChannelOptions.FirstOrDefault(option => + string.Equals(option.Value, SelectedUpdateChannelValue, StringComparison.OrdinalIgnoreCase)); + SelectedUpdateSourceOption = UpdateSourceOptions.FirstOrDefault(option => + string.Equals(option.Value, SelectedUpdateSourceValue, StringComparison.OrdinalIgnoreCase)); + SelectedUpdateModeOption = UpdateModeOptions.FirstOrDefault(option => + string.Equals(option.Value, SelectedUpdateModeValue, StringComparison.OrdinalIgnoreCase)); + SelectedDownloadThreadsOption = DownloadThreadOptions.FirstOrDefault(option => + string.Equals( + option.Value, + UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.InvariantCulture), + StringComparison.OrdinalIgnoreCase)); + } + + private void ApplyDownloadThreadsValue(int value, bool saveChanges) + { + var normalized = UpdateSettingsValues.NormalizeDownloadThreads(value); + var normalizedText = normalized.ToString(CultureInfo.CurrentCulture); + + var previousInitializing = _isInitializing; + _isInitializing = true; + DownloadThreadsSliderValue = normalized; + DownloadThreadsText = normalizedText; + _isInitializing = previousInitializing; + SyncSelectedOptions(); + + if (saveChanges) + { + SaveUpdateSettings(); + UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved."); + } + } + + private static bool TryParseDownloadThreads(string? value, out int parsed) + { + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.CurrentCulture, out parsed)) + { + return true; + } + + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed); } private string L(string key, string fallback) diff --git a/LanMountainDesktop/ViewModels/StatusBarSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/StatusBarSettingsPageViewModel.cs new file mode 100644 index 0000000..91d4f66 --- /dev/null +++ b/LanMountainDesktop/ViewModels/StatusBarSettingsPageViewModel.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; + +namespace LanMountainDesktop.ViewModels; + +public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase +{ + private readonly ISettingsFacadeService _settingsFacade; + private readonly LocalizationService _localizationService = new(); + private readonly string _languageCode; + private bool _isInitializing; + + public StatusBarSettingsPageViewModel(ISettingsFacadeService settingsFacade) + { + _settingsFacade = settingsFacade; + _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); + + ClockFormats = CreateClockFormats(); + SpacingModes = CreateSpacingModes(); + RefreshLocalizedText(); + + _isInitializing = true; + Load(); + _isInitializing = false; + } + + public IReadOnlyList ClockFormats { get; } + + public IReadOnlyList SpacingModes { get; } + + [ObservableProperty] + private bool _showClock = true; + + [ObservableProperty] + private SelectionOption _selectedClockFormat = new("HourMinuteSecond", "Hour:Minute:Second"); + + [ObservableProperty] + private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed"); + + [ObservableProperty] + private int _customSpacingPercent = 12; + + [ObservableProperty] + private bool _isCustomSpacingVisible; + + [ObservableProperty] + private string _componentsHeader = string.Empty; + + [ObservableProperty] + private string _pageTitle = string.Empty; + + [ObservableProperty] + private string _pageDescription = string.Empty; + + [ObservableProperty] + private string _clockHeader = string.Empty; + + [ObservableProperty] + private string _clockDescription = string.Empty; + + [ObservableProperty] + private string _clockFormatLabel = string.Empty; + + [ObservableProperty] + private string _spacingHeader = string.Empty; + + [ObservableProperty] + private string _spacingDescription = string.Empty; + + [ObservableProperty] + private string _customSpacingLabel = string.Empty; + + public void Load() + { + var state = _settingsFacade.StatusBar.Get(); + + ShowClock = state.TopStatusComponentIds.Any(id => + string.Equals(id, BuiltInComponentIds.Clock, StringComparison.OrdinalIgnoreCase)); + + var clockFormat = string.IsNullOrWhiteSpace(state.ClockDisplayFormat) + ? "HourMinuteSecond" + : state.ClockDisplayFormat; + SelectedClockFormat = ClockFormats.FirstOrDefault(option => + string.Equals(option.Value, clockFormat, StringComparison.OrdinalIgnoreCase)) + ?? ClockFormats[1]; + + var spacingMode = NormalizeSpacingMode(state.SpacingMode); + SelectedSpacingMode = SpacingModes.FirstOrDefault(option => + string.Equals(option.Value, spacingMode, StringComparison.OrdinalIgnoreCase)) + ?? SpacingModes[1]; + CustomSpacingPercent = Math.Clamp(state.CustomSpacingPercent, 0, 30); + IsCustomSpacingVisible = string.Equals(SelectedSpacingMode.Value, "Custom", StringComparison.OrdinalIgnoreCase); + } + + partial void OnShowClockChanged(bool value) + { + if (_isInitializing) + { + return; + } + + Save(); + } + + partial void OnSelectedClockFormatChanged(SelectionOption value) + { + if (_isInitializing || value is null) + { + return; + } + + Save(); + } + + partial void OnSelectedSpacingModeChanged(SelectionOption value) + { + IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase); + if (_isInitializing || value is null) + { + return; + } + + Save(); + } + + partial void OnCustomSpacingPercentChanged(int value) + { + var normalized = Math.Clamp(value, 0, 30); + if (normalized != value) + { + CustomSpacingPercent = normalized; + return; + } + + if (_isInitializing || !IsCustomSpacingVisible) + { + return; + } + + Save(); + } + + private void Save() + { + var state = _settingsFacade.StatusBar.Get(); + var topComponents = state.TopStatusComponentIds + .Where(id => !string.Equals(id, BuiltInComponentIds.Clock, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (ShowClock) + { + topComponents.Add(BuiltInComponentIds.Clock); + } + + _settingsFacade.StatusBar.Save(new StatusBarSettingsState( + topComponents, + state.PinnedTaskbarActions, + state.EnableDynamicTaskbarActions, + state.TaskbarLayoutMode, + SelectedClockFormat.Value, + NormalizeSpacingMode(SelectedSpacingMode.Value), + Math.Clamp(CustomSpacingPercent, 0, 30))); + } + + private IReadOnlyList CreateClockFormats() + { + return + [ + new SelectionOption("HourMinute", L("settings.status_bar.clock_format.hm", "Hour:Minute")), + new SelectionOption("HourMinuteSecond", L("settings.status_bar.clock_format.hms", "Hour:Minute:Second")) + ]; + } + + private IReadOnlyList CreateSpacingModes() + { + return + [ + new SelectionOption("Compact", L("settings.status_bar.spacing_mode_compact", "Compact")), + new SelectionOption("Relaxed", L("settings.status_bar.spacing_mode_relaxed", "Relaxed")), + new SelectionOption("Custom", L("settings.status_bar.spacing_mode_custom", "Custom")) + ]; + } + + private void RefreshLocalizedText() + { + PageTitle = L("settings.status_bar.title", "Status Bar"); + PageDescription = L("settings.status_bar.description", "Choose which single-height components appear on the top status bar."); + ComponentsHeader = L("settings.status_bar.title", "Status Bar"); + ClockHeader = L("settings.status_bar.clock_header", "Clock Component"); + ClockDescription = L("settings.status_bar.clock_description", "Display a clock on the top status bar."); + ClockFormatLabel = L("settings.status_bar.clock_format_label", "Clock format"); + SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing"); + SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components."); + CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)"); + } + + private string NormalizeSpacingMode(string? value) + { + return value switch + { + _ when string.Equals(value, "Compact", StringComparison.OrdinalIgnoreCase) => "Compact", + _ when string.Equals(value, "Custom", StringComparison.OrdinalIgnoreCase) => "Custom", + _ => "Relaxed" + }; + } + + private string L(string key, string fallback) + => _localizationService.GetString(_languageCode, key, fallback); +} diff --git a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs index 398c6cc..4581d37 100644 --- a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs +++ b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs @@ -198,11 +198,16 @@ public partial class MainWindow private ThemeColorContext BuildAdaptiveThemeContext() { + var palette = _themeSettingsService.BuildPalette(_isNightMode, _wallpaperPath, _selectedThemeColor.ToString()); + var accentColor = palette.MonetColors is { Count: > 0 } + ? palette.MonetColors[0] + : _selectedThemeColor; return new ThemeColorContext( - _selectedThemeColor, + accentColor, IsLightBackground: !_isNightMode, IsLightNavBackground: !_isNightMode, - IsNightMode: _isNightMode); + IsNightMode: _isNightMode, + MonetColors: palette.MonetColors); } private void ApplyAdaptiveThemeResources() @@ -386,7 +391,7 @@ public partial class MainWindow return; } - var palette = _themeSettingsService.BuildPalette(enabled, _wallpaperPath); + var palette = _themeSettingsService.BuildPalette(enabled, _wallpaperPath, _selectedThemeColor.ToString()); _recommendedColors = palette.RecommendedColors; _monetColors = palette.MonetColors; } @@ -406,6 +411,32 @@ public partial class MainWindow private void TriggerAutoUpdateCheckIfEnabled() { + var versionText = _settingsFacade.ApplicationInfo.GetAppVersionText(); + if (!Version.TryParse(versionText, out var currentVersion)) + { + currentVersion = new Version(0, 0, 0); + } + + var normalizedVersion = new Version( + Math.Max(0, currentVersion.Major), + Math.Max(0, currentVersion.Minor), + Math.Max(0, currentVersion.Build)); + + DispatcherTimer.RunOnce( + async () => + { + try + { + await HostUpdateWorkflowServiceProvider + .GetOrCreate() + .AutoCheckIfEnabledAsync(normalizedVersion); + } + catch (Exception ex) + { + AppLogger.Warn("UpdateWorkflow", "Automatic update check failed after startup.", ex); + } + }, + TimeSpan.FromSeconds(3)); } private void PersistSettings() @@ -489,6 +520,7 @@ public partial class MainWindow private AppSettingsSnapshot BuildAppSettingsSnapshot() { var latestWeatherState = _weatherSettingsService.Get(); + var latestUpdateState = _updateSettingsService.Get(); return new AppSettingsSnapshot { GridShortSideCells = _targetShortSideCells, @@ -513,6 +545,16 @@ public partial class MainWindow WeatherNoTlsRequests = latestWeatherState.NoTlsRequests, AutoStartWithWindows = _autoStartWithWindows, AppRenderMode = _selectedAppRenderMode, + AutoCheckUpdates = latestUpdateState.AutoCheckUpdates, + IncludePrereleaseUpdates = latestUpdateState.IncludePrereleaseUpdates, + UpdateChannel = latestUpdateState.UpdateChannel, + UpdateMode = latestUpdateState.UpdateMode, + UpdateDownloadSource = latestUpdateState.UpdateDownloadSource, + UpdateDownloadThreads = latestUpdateState.UpdateDownloadThreads, + PendingUpdateInstallerPath = latestUpdateState.PendingUpdateInstallerPath, + PendingUpdateVersion = latestUpdateState.PendingUpdateVersion, + PendingUpdatePublishedAtUtcMs = latestUpdateState.PendingUpdatePublishedAtUtcMs, + LastUpdateCheckUtcMs = latestUpdateState.LastUpdateCheckUtcMs, TopStatusComponentIds = [.. _topStatusComponentIds], PinnedTaskbarActions = [.. _pinnedTaskbarActions.Select(v => v.ToString())], EnableDynamicTaskbarActions = _enableDynamicTaskbarActions, diff --git a/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml index 75e9117..5ccc7c9 100644 --- a/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml @@ -6,96 +6,71 @@ xmlns:fi="using:FluentIcons.Avalonia.Fluent" x:Class="LanMountainDesktop.Views.SettingsPages.AboutSettingsPage" x:DataType="vm:AboutSettingsPageViewModel"> - - + + - - + - - + + + + + + + + + + + + + - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -