settings_re10

This commit is contained in:
lincube
2026-03-15 04:35:34 +08:00
parent 85b70c4a8a
commit c7fb48c8ee
28 changed files with 2294 additions and 349 deletions

View File

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

View File

@@ -172,6 +172,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
public async Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
@@ -193,11 +195,14 @@ public sealed class GitHubReleaseUpdateService : IDisposable
var progressAdapter = progress is null
? null
: new Progress<DownloadProgressInfo>(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;
}
}

View File

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

View File

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

View File

@@ -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<string> 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<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default);
}
@@ -197,6 +209,7 @@ public interface IPluginMarketSettingsService
public interface IApplicationInfoService
{
string GetAppVersionText();
string GetAppCodenameText();
AppRenderBackendInfo GetRenderBackendInfo();
}

View File

@@ -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<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? 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<AssemblyInformationalVersionAttribute>()?
.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()

View File

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

View File

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

View File

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

View File

@@ -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<UpdateCheckResult> 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<UpdateDownloadResult> DownloadReleaseAsync(
UpdateCheckResult checkResult,
IProgress<double>? 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<char> 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]);
}
}