Add WinUI and core source
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:27:13 +08:00
parent f59190251d
commit 7ecc6a8923
262 changed files with 137492 additions and 0 deletions
@@ -0,0 +1,505 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Text.Json;
using Windows.Management.Deployment;
using YMhut.Box.Core;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Net;
using YMhut.Box.Core.Updates;
namespace YMhut.Box.WinUI.Services;
public sealed class AppInstallerUpdateService(
ILogService logService,
IHttpService httpService) : IAppInstallerUpdateService
{
private static readonly IReadOnlyList<Uri> DefaultUpdateInfoUris = UpdateInfoEndpointPolicy.BuildDefaultUris();
private static readonly Uri DefaultAppInstallerUri = new("https://update.ymhut.cn/update-info/winui.appinstaller");
private static readonly Uri DefaultUpdateHost = new("https://update.ymhut.cn/");
private static readonly Guid DownloadsKnownFolderId = new("374DE290-123F-4565-9164-39C4925E467B");
public async Task<RemoteUpdateInfo?> GetUpdateInfoAsync(CancellationToken cancellationToken = default)
{
var failures = new List<string>();
foreach (var endpoint in DefaultUpdateInfoUris)
{
try
{
var response = await httpService.GetAsync(endpoint, cancellationToken).ConfigureAwait(false);
var parsed = ParseUpdateInfo(response.Content);
if (parsed is not null)
{
await logService.WriteAsync("Information", "update", "读取远程更新配置", endpoint.AbsolutePath, cancellationToken);
return parsed;
}
failures.Add($"{endpoint.AbsolutePath}: invalid payload");
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException)
{
failures.Add($"{endpoint.AbsolutePath}: {SensitiveText.Sanitize(exception.Message)}");
}
}
await logService.WriteAsync(
"Warning",
"update",
"读取更新配置失败",
string.Join(" | ", failures),
cancellationToken);
return null;
}
public async Task<string?> DownloadUpdateAsync(
RemoteUpdateInfo info,
IProgress<UpdateDownloadProgress>? progress = null,
CancellationToken cancellationToken = default)
{
var url = FirstNonEmpty(info.DownloadUrl, info.AppInstallerUrl);
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return null;
}
var downloadDirectory = GetDownloadsDirectory();
Directory.CreateDirectory(downloadDirectory);
var fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName))
{
fileName = $"YMhutBox_Update_{info.DisplayVersion}.bin";
}
var targetPath = GetAvailableDownloadPath(downloadDirectory, SanitizeFileName(fileName));
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(30) };
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength > 0 ? response.Content.Headers.ContentLength : info.SizeBytes > 0 ? info.SizeBytes : null;
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using var target = File.Create(targetPath);
var buffer = new byte[128 * 1024];
long received = 0;
var started = Stopwatch.StartNew();
int read;
while ((read = await source.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
{
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
received += read;
progress?.Report(new UpdateDownloadProgress(received, total, received / Math.Max(0.001, started.Elapsed.TotalSeconds)));
}
await logService.WriteAsync("Information", "update", "更新包下载完成", Path.GetFileName(targetPath), cancellationToken);
return targetPath;
}
public async Task<string> CheckForUpdatesAsync(CancellationToken cancellationToken = default)
{
var info = await GetUpdateInfoAsync(cancellationToken).ConfigureAwait(false);
var appInstallerUri = Uri.TryCreate(info?.AppInstallerUrl, UriKind.Absolute, out var manifestUri)
? manifestUri
: DefaultAppInstallerUri;
try
{
var manager = new PackageManager();
await manager.AddPackageByAppInstallerFileAsync(
appInstallerUri,
AddPackageByAppInstallerOptions.ForceTargetAppShutdown,
null).AsTask(cancellationToken);
await logService.WriteAsync("Information", "update", "App Installer 更新检查已发起", cancellationToken: cancellationToken);
return "更新检查已发起,请按系统提示继续完成安装。";
}
catch (Exception exception)
{
var error = SensitiveText.Sanitize(exception.Message);
await logService.WriteAsync("Error", "update", "App Installer 更新检查失败", error, cancellationToken);
return $"更新检查失败:{error}";
}
}
private static RemoteUpdateInfo? ParseUpdateInfo(string content)
{
try
{
using var document = JsonDocument.Parse(content);
var root = document.RootElement;
var latest = GetObject(root, "latest") ?? GetObject(root, "winui") ?? GetObject(root, "release") ?? root;
var installer = GetInstallerObject(root, latest);
var package = GetLatestPackage(root);
var version = FirstNonEmpty(
GetString(root, "latestVersion"),
GetString(latest, "version"),
GetString(installer, "version"),
GetString(latest, "app_version"),
GetString(latest, "appVersion"),
GetString(root, "version"),
GetString(root, "app_version"));
if (string.IsNullOrWhiteSpace(version))
{
return null;
}
var build = FirstNonEmpty(GetString(latest, "build"), GetString(latest, "build_number"), GetString(root, "build"));
var download = FirstNonEmpty(
GetString(installer, "url"),
GetString(installer, "downloadUrl"),
GetString(installer, "download_url"),
GetMirrorDownloadUrl(installer),
GetMirrorDownloadUrl(root),
GetString(latest, "download_url"),
GetString(latest, "downloadUrl"),
GetString(latest, "installer_url"),
GetString(latest, "msix_url"),
GetString(root, "download_url"),
GetString(root, "downloadUrl"),
GetPackageDownloadUrl(package));
var appInstaller = FirstNonEmpty(
GetString(latest, "appinstaller_url"),
GetString(latest, "appInstallerUrl"),
GetString(latest, "app_installer_url"),
GetString(root, "appinstaller_url"),
DefaultAppInstallerUri.ToString());
var releaseNotes = FirstNonEmpty(
GetString(latest, "release_notes"),
GetString(latest, "releaseNotes"),
GetString(latest, "changelog"),
GetString(root, "release_notes"),
GetNotes(root, "update_notes", "last_update_notes"));
var messageMarkdown = FirstNonEmpty(
GetString(latest, "message_md"),
GetString(latest, "messageMarkdown"),
GetString(latest, "description_md"),
GetString(latest, "home_notes_md"),
GetString(root, "message_md"),
GetString(root, "messageMarkdown"),
GetString(root, "description_md"),
GetString(root, "home_notes_md"));
var releaseNotesMarkdown = FirstNonEmpty(
GetString(latest, "release_notes_md"),
GetString(latest, "releaseNotesMarkdown"),
GetString(latest, "changelog_md"),
GetString(latest, "latestNotesMarkdown"),
GetString(latest, "latest_notes_md"),
GetString(root, "release_notes_md"),
GetString(root, "releaseNotesMarkdown"),
GetString(root, "changelog_md"),
GetString(root, "latestNotesMarkdown"),
GetString(root, "latest_notes_md"));
return new RemoteUpdateInfo(
version,
build,
FirstNonEmpty(GetString(latest, "channel"), GetString(root, "channel"), "stable"),
FirstNonEmpty(GetString(latest, "title"), GetString(root, "title"), "发现新版本"),
FirstNonEmpty(GetString(latest, "message"), GetString(latest, "description"), GetString(root, "message"), GetString(root, "home_notes")),
releaseNotes,
NormalizeRemoteUrl(download),
appInstaller,
GetBoolean(latest, "mandatory") || GetBoolean(latest, "force_update"),
GetInt64(installer, "size") is var installerSize and > 0
? installerSize
: GetInt64(latest, "size") is var size and > 0
? size
: FirstPositive(GetInt64(latest, "file_size"), GetInt64(package, "sizeBytes")),
TryDate(FirstNonEmpty(
GetString(latest, "published_at"),
GetString(latest, "release_date"),
GetString(root, "published_at"),
GetString(root, "last_updated"),
GetString(package, "updateTime"),
GetString(package, "updateDate"))),
messageMarkdown,
releaseNotesMarkdown);
}
catch
{
return null;
}
}
private static JsonElement GetInstallerObject(JsonElement root, JsonElement latest)
{
if (GetObject(latest, "fullInstaller") is { } latestFullInstaller)
{
return latestFullInstaller;
}
if (GetObject(latest, "files") is { } latestFiles &&
GetObject(latestFiles, "fullInstaller") is { } latestFilesFullInstaller)
{
return latestFilesFullInstaller;
}
if (GetObject(root, "fullInstaller") is { } rootFullInstaller)
{
return rootFullInstaller;
}
if (GetObject(root, "files") is { } rootFiles &&
GetObject(rootFiles, "fullInstaller") is { } rootFilesFullInstaller)
{
return rootFilesFullInstaller;
}
return default;
}
private static JsonElement? GetObject(JsonElement root, string name)
{
return root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty(name, out var value) &&
value.ValueKind == JsonValueKind.Object
? value
: null;
}
private static JsonElement GetLatestPackage(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object ||
!root.TryGetProperty("detected_packages", out var packages) ||
packages.ValueKind != JsonValueKind.Object)
{
return default;
}
var preferred = GetString(root, "detected_product");
if (!string.IsNullOrWhiteSpace(preferred) &&
packages.TryGetProperty(preferred, out var preferredList) &&
preferredList.ValueKind == JsonValueKind.Array &&
preferredList.GetArrayLength() > 0)
{
return preferredList[0];
}
foreach (var product in packages.EnumerateObject())
{
if (product.Value.ValueKind == JsonValueKind.Array && product.Value.GetArrayLength() > 0)
{
return product.Value[0];
}
}
return default;
}
private static string GetMirrorDownloadUrl(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object ||
!root.TryGetProperty("download_mirrors", out var mirrors) ||
mirrors.ValueKind != JsonValueKind.Array)
{
return string.Empty;
}
foreach (var mirror in mirrors.EnumerateArray())
{
if (!GetBoolean(mirror, "enabled"))
{
continue;
}
var url = GetString(mirror, "url");
if (!string.IsNullOrWhiteSpace(url))
{
return url;
}
}
return string.Empty;
}
private static string GetPackageDownloadUrl(JsonElement package)
{
if (package.ValueKind != JsonValueKind.Object)
{
return string.Empty;
}
return FirstNonEmpty(GetString(package, "downloadUrl"), GetString(package, "download_url"), GetString(package, "downloadPath"));
}
private static string NormalizeRemoteUrl(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return Uri.TryCreate(value, UriKind.Absolute, out var absolute)
? absolute.ToString()
: new Uri(DefaultUpdateHost, value.TrimStart('/')).ToString();
}
private static string GetNotes(JsonElement root, params string[] names)
{
var lines = new List<string>();
foreach (var name in names)
{
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty(name, out var notes))
{
continue;
}
if (notes.ValueKind == JsonValueKind.Object)
{
lines.AddRange(notes.EnumerateObject()
.Select(item => $"{item.Name}: {ElementToText(item.Value)}")
.Where(line => !string.IsNullOrWhiteSpace(line)));
}
else if (notes.ValueKind == JsonValueKind.Array)
{
lines.AddRange(notes.EnumerateArray()
.Select(ElementToText)
.Where(line => !string.IsNullOrWhiteSpace(line)));
}
else
{
var text = ElementToText(notes);
if (!string.IsNullOrWhiteSpace(text))
{
lines.Add(text);
}
}
}
return string.Join(Environment.NewLine, lines.Distinct());
}
private static string ElementToText(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString() ?? string.Empty,
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => element.ToString(),
_ => string.Empty
};
}
private static string GetString(JsonElement root, string name)
{
return root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty(name, out var value)
? value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Number => value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => string.Empty
}
: string.Empty;
}
private static bool GetBoolean(JsonElement root, string name)
{
return root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty(name, out var value) &&
value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => bool.TryParse(value.GetString(), out var parsed) && parsed,
_ => false
};
}
private static long GetInt64(JsonElement root, string name)
{
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty(name, out var value))
{
return 0;
}
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var number))
{
return number;
}
return value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out var parsed)
? parsed
: 0;
}
private static DateTimeOffset? TryDate(string value)
{
return DateTimeOffset.TryParse(value, out var date) ? date : null;
}
private static string FirstNonEmpty(params string[] values)
{
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty;
}
private static long FirstPositive(params long[] values)
{
return values.FirstOrDefault(value => value > 0);
}
private static string GetDownloadsDirectory()
{
try
{
var result = SHGetKnownFolderPath(DownloadsKnownFolderId, 0, IntPtr.Zero, out var pathPointer);
if (result == 0 && pathPointer != IntPtr.Zero)
{
try
{
var path = Marshal.PtrToStringUni(pathPointer);
if (!string.IsNullOrWhiteSpace(path))
{
return path;
}
}
finally
{
Marshal.FreeCoTaskMem(pathPointer);
}
}
}
catch
{
}
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Downloads");
}
private static string GetAvailableDownloadPath(string directory, string fileName)
{
var candidate = Path.Combine(directory, fileName);
if (!File.Exists(candidate))
{
return candidate;
}
var name = Path.GetFileNameWithoutExtension(fileName);
var extension = Path.GetExtension(fileName);
for (var index = 1; index < 1000; index++)
{
candidate = Path.Combine(directory, $"{name} ({index}){extension}");
if (!File.Exists(candidate))
{
return candidate;
}
}
return Path.Combine(directory, $"{name}-{DateTimeOffset.Now:yyyyMMddHHmmss}{extension}");
}
private static string SanitizeFileName(string fileName)
{
var invalidCharacters = Path.GetInvalidFileNameChars();
var sanitized = new string(fileName
.Select(character => invalidCharacters.Contains(character) ? '_' : character)
.ToArray());
return string.IsNullOrWhiteSpace(sanitized) ? "YMhutBox_Update.bin" : sanitized;
}
[DllImport("shell32.dll")]
private static extern int SHGetKnownFolderPath(
[MarshalAs(UnmanagedType.LPStruct)] Guid rfid,
uint dwFlags,
IntPtr hToken,
out IntPtr ppszPath);
}
+140
View File
@@ -0,0 +1,140 @@
using Microsoft.Extensions.DependencyInjection;
using YMhut.Box.Core.Api;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Data;
using YMhut.Box.Core.DevEnvironments;
using YMhut.Box.Core.Downloads;
using YMhut.Box.Core.Feedback;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Media;
using YMhut.Box.Core.Net;
using YMhut.Box.Core.Plugins;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.SolarSystem;
using YMhut.Box.Core.Startup;
using YMhut.Box.Core.System;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.WinUI.Services;
public static class AppServices
{
private static IServiceProvider? _provider;
public static IServiceProvider Provider => _provider ?? throw new InvalidOperationException("Services have not been configured.");
public static void Configure(string? root = null)
{
var services = new ServiceCollection();
var paths = AppPaths.ForCurrentUser(root, InstallLayoutPaths.ResolveAssetsRoot());
services.AddSingleton(paths);
services.AddSingleton(new AppSettingsStore(paths.Root));
services.AddSingleton<ISettingsService, AppSettingsService>();
services.AddSingleton<IAgreementAcceptanceStore, AgreementAcceptanceStore>();
services.AddSingleton<ILogService>(provider => new ResilientLogService(
new SqliteLogService(provider.GetRequiredService<AppPaths>()),
provider.GetRequiredService<AppPaths>()));
services.AddSingleton<IStartupCheckStore, StartupCheckStore>();
services.AddSingleton<IInstallIntegrityCheckService>(provider => new InstallIntegrityCheckService(
provider.GetRequiredService<AppPaths>(),
provider.GetRequiredService<IStartupCheckStore>(),
provider.GetService<ILogService>()));
services.AddSingleton<IPluginStateStore, PluginStateStore>();
services.AddSingleton<PluginLogService>();
services.AddSingleton<IBuiltInPluginInstallerService>(provider => new BuiltInPluginInstallerService(
provider.GetRequiredService<AppPaths>(),
provider.GetService<ILogService>(),
provider.GetRequiredService<ISettingsService>()));
services.AddSingleton<IPluginHostProcessService>(provider => new PluginHostProcessService(provider.GetService<ILogService>()));
services.AddSingleton<IPluginRegistryService>(provider => new PluginRegistryService(
provider.GetRequiredService<AppPaths>(),
provider.GetRequiredService<IPluginStateStore>(),
provider.GetService<ILogService>(),
provider.GetRequiredService<ISettingsService>(),
provider.GetRequiredService<IBuiltInPluginInstallerService>()));
services.AddSingleton<IHttpService>(provider => new HttpService(
logService: provider.GetService<ILogService>(),
settingsService: provider.GetService<ISettingsService>()));
services.AddSingleton<IDownloadQueueStore, DownloadQueueStore>();
services.AddSingleton<IDirectDownloadValidator, DirectDownloadValidator>();
services.AddSingleton<IDownloadHostProcessService>(provider => new DownloadHostProcessService(provider.GetService<ILogService>()));
services.AddSingleton<IDownloadManagerService>(provider => new DownloadManagerService(
provider.GetRequiredService<AppPaths>(),
provider.GetRequiredService<IDownloadQueueStore>(),
provider.GetRequiredService<IDownloadHostProcessService>(),
provider.GetService<ILogService>()));
services.AddSingleton<IDevEnvironmentDetectionService>(provider => new DevEnvironmentDetectionService(provider.GetService<ILogService>()));
services.AddSingleton<IDevEnvironmentCatalogService>(provider => new DevEnvironmentCatalogService(
provider.GetRequiredService<AppPaths>(),
provider.GetRequiredService<IHttpService>(),
provider.GetService<ILogService>()));
services.AddSingleton<IApiManager>(provider => new ApiManager(provider.GetRequiredService<IHttpService>(), provider.GetService<ILogService>()));
services.AddSingleton<IRemoteMediaCatalogService>(provider => new RemoteMediaCatalogService(
provider.GetRequiredService<AppPaths>(),
provider.GetRequiredService<IApiManager>(),
provider.GetService<ILogService>()));
services.AddSingleton<IRemoteMediaResolver, RemoteMediaResolver>();
services.AddSingleton<IIndependentWindowHostLauncher>(provider => new IndependentWindowHostLauncher(provider.GetService<ILogService>()));
services.AddSingleton<IToolLinkNavigationService>(provider => new ToolLinkNavigationService(
provider.GetRequiredService<AppPaths>(),
provider.GetRequiredService<IIndependentWindowHostLauncher>(),
provider.GetService<ILogService>()));
services.AddSingleton<IOpenSourceReferenceService>(provider => new OpenSourceReferenceService(
provider.GetRequiredService<AppPaths>(),
provider.GetService<ILogService>()));
services.AddSingleton<IReferenceDataService, ReferenceDataService>();
services.AddSingleton<ISystemMetricsService, SystemMetricsService>();
services.AddSingleton<ITitleWeatherService>(provider => new TitleWeatherService(
provider.GetRequiredService<IHttpService>(),
provider.GetService<ILogService>()));
services.AddSingleton<IHardwareInfoService>(provider => new HardwareInfoService(provider.GetService<ILogService>()));
services.AddSingleton<IRiskConfirmationStore>(provider => new RiskConfirmationStore(
provider.GetRequiredService<AppPaths>(),
provider.GetService<ILogService>()));
services.AddSingleton<IExternalToolCatalogService>(provider => new ExternalToolCatalogService(
provider.GetRequiredService<AppPaths>(),
provider.GetService<ILogService>()));
services.AddSingleton<IExternalToolLaunchService>(provider => new ExternalToolLaunchService(
provider.GetService<ILogService>(),
provider.GetService<ISettingsService>()));
services.AddSingleton<IBuiltinReferenceToolCatalog, BuiltinReferenceToolCatalog>();
services.AddSingleton<IBuiltinReferenceToolService>(provider => new BuiltinReferenceToolService(
provider.GetService<ILogService>(),
provider.GetService<ISettingsService>(),
provider.GetService<IHardwareInfoService>()));
services.AddSingleton<IToolWorkerService>(provider => new ProcessToolWorkerService(
provider.GetService<IApiManager>(),
provider.GetService<IReferenceDataService>(),
provider.GetService<ILogService>()));
services.AddSingleton(_ => new ToolCatalog(ToolCatalog.DefaultModules()));
services.AddSingleton<IToolResultExperienceCatalog, ToolResultExperienceCatalog>();
services.AddSingleton<ToolResultWebBridge>();
services.AddSingleton<ToolPageWebBridge>();
services.AddSingleton<IFeedbackPackageService>(provider => new FeedbackPackageService(
provider.GetRequiredService<ILogService>(),
provider.GetRequiredService<ISystemMetricsService>(),
provider.GetRequiredService<ToolCatalog>()));
services.AddSingleton<IFeedbackSubmissionService>(_ => new FeedbackSubmissionService());
services.AddSingleton<IFeedbackRecordStore>(_ => new FeedbackRecordStore());
services.AddSingleton<IStartupService, WindowsStartupService>();
services.AddSingleton<ITrayService, WindowsTrayService>();
services.AddSingleton<IAppInstallerUpdateService, AppInstallerUpdateService>();
services.AddSingleton<IAppVersionService, AppVersionService>();
services.AddSingleton<ISolarEphemerisService>(provider => new SolarEphemerisService(
provider.GetRequiredService<AppPaths>(),
provider.GetRequiredService<IHttpService>(),
provider.GetService<ILogService>()));
services.AddSingleton<WebView2EnvironmentFactory>();
services.AddSingleton<IUiPerformanceCoordinator, UiPerformanceCoordinator>();
services.AddSingleton<WindowStateService>();
services.AddSingleton<IStartupInitializationService, StartupInitializationService>();
_provider = services.BuildServiceProvider();
}
public static T GetRequiredService<T>() where T : notnull
{
return Provider.GetRequiredService<T>();
}
}
+126
View File
@@ -0,0 +1,126 @@
using System.Reflection;
using System.Text.Json;
using Windows.ApplicationModel;
using YMhut.Box.Core.Updates;
namespace YMhut.Box.WinUI.Services;
public sealed class AppVersionService : IAppVersionService
{
public AppVersionInfo GetCurrent()
{
try
{
var version = Package.Current.Id.Version;
return new AppVersionInfo(
Package.Current.DisplayName,
$"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}",
true);
}
catch
{
if (TryReadVersionJson(out var jsonVersion))
{
return new AppVersionInfo("YMhut Box", jsonVersion, false);
}
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
return new AppVersionInfo(
"YMhut Box",
informationalVersion ?? assembly.GetName().Version?.ToString() ?? "0.0.0.0",
false);
}
}
private static bool TryReadVersionJson(out string version)
{
if (TryReadEmbeddedVersionJson(out version))
{
return true;
}
foreach (var path in VersionFileCandidates())
{
try
{
if (!File.Exists(path))
{
continue;
}
if (TryParseVersionJson(File.ReadAllText(path), out version))
{
return true;
}
}
catch
{
}
}
version = string.Empty;
return false;
}
private static IEnumerable<string> VersionFileCandidates()
{
yield return Path.Combine(AppContext.BaseDirectory, "version.json");
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
yield return Path.Combine(directory.FullName, "version.json");
directory = directory.Parent;
}
}
private static bool TryReadEmbeddedVersionJson(out string version)
{
version = string.Empty;
try
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream("YMhut.Box.WinUI.version.json");
if (stream is null)
{
return false;
}
using var reader = new StreamReader(stream);
return TryParseVersionJson(reader.ReadToEnd(), out version);
}
catch
{
version = string.Empty;
return false;
}
}
private static bool TryParseVersionJson(string json, out string version)
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
var baseVersion = root.TryGetProperty("version", out var versionElement)
? versionElement.GetString()
: null;
var build = root.TryGetProperty("build", out var buildElement)
? buildElement.GetString()
: null;
if (string.IsNullOrWhiteSpace(baseVersion))
{
version = string.Empty;
return false;
}
version = UpdateVersionComparer.NormalizeVersion(baseVersion, build);
if (string.IsNullOrWhiteSpace(version))
{
version = baseVersion.Trim();
}
return true;
}
}
@@ -0,0 +1,237 @@
using System.Diagnostics;
using System.IO.Pipes;
using System.Text;
using YMhut.Box.Core.Downloads;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.WinUI.Services;
public sealed class DownloadHostProcessService(ILogService? logService = null) : IDownloadHostProcessService
{
private static readonly TimeSpan HostStartTimeout = TimeSpan.FromSeconds(8);
private readonly SemaphoreSlim _writeGate = new(1, 1);
private readonly object _sync = new();
private Process? _process;
private NamedPipeServerStream? _pipe;
private StreamReader? _reader;
private StreamWriter? _writer;
private CancellationTokenSource? _readerCancellation;
private bool _disposed;
public event EventHandler<DownloadProgressSnapshot>? ProgressChanged;
public async Task StartAsync(DownloadItem item, CancellationToken cancellationToken = default)
{
await SendAsync(new DownloadHostMessage(DownloadHostProtocol.Start, item.Id, Item: item), cancellationToken)
.ConfigureAwait(false);
}
public async Task PauseAsync(string id, CancellationToken cancellationToken = default)
{
await SendAsync(new DownloadHostMessage(DownloadHostProtocol.Pause, id), cancellationToken).ConfigureAwait(false);
}
public async Task ResumeAsync(DownloadItem item, CancellationToken cancellationToken = default)
{
await SendAsync(new DownloadHostMessage(DownloadHostProtocol.Resume, item.Id, Item: item), cancellationToken)
.ConfigureAwait(false);
}
public async Task CancelAsync(string id, CancellationToken cancellationToken = default)
{
await SendAsync(new DownloadHostMessage(DownloadHostProtocol.Cancel, id), cancellationToken).ConfigureAwait(false);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
try
{
_writer?.WriteLine(DownloadHostProtocol.Serialize(new DownloadHostMessage(DownloadHostProtocol.Shutdown)));
_writer?.Flush();
}
catch
{
}
DisposeProcess(killProcess: false);
_writeGate.Dispose();
}
private async Task SendAsync(DownloadHostMessage message, CancellationToken cancellationToken)
{
await EnsureHostAsync(cancellationToken).ConfigureAwait(false);
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
private async Task EnsureHostAsync(CancellationToken cancellationToken)
{
if (_process is { HasExited: false } && _pipe?.IsConnected == true && _writer is not null)
{
return;
}
DisposeProcess(killProcess: true);
var executable = ResolveDownloadHostExecutable()
?? throw new FileNotFoundException("YMhut.Box.DownloadHost.exe was not found in the application output.");
var pipeName = $"YMhutBoxDownloadHost-{Environment.ProcessId}-{Guid.NewGuid():N}";
_pipe = new NamedPipeServerStream(
pipeName,
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
_process = Process.Start(new ProcessStartInfo
{
FileName = executable,
Arguments = $"--pipe {pipeName}",
WorkingDirectory = Path.GetDirectoryName(executable) ?? AppContext.BaseDirectory,
UseShellExecute = false,
CreateNoWindow = true
}) ?? throw new InvalidOperationException("Unable to start the YMhut download host process.");
await _pipe.WaitForConnectionAsync(cancellationToken).WaitAsync(HostStartTimeout, cancellationToken)
.ConfigureAwait(false);
_reader = new StreamReader(_pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
_writer = new StreamWriter(_pipe, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), bufferSize: 4096, leaveOpen: true)
{
AutoFlush = true
};
var readyLine = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
var ready = readyLine is null ? null : DownloadHostProtocol.Deserialize(readyLine);
if (!string.Equals(ready?.Type, DownloadHostProtocol.Ready, StringComparison.Ordinal) ||
!string.Equals(ready?.Version, DownloadHostProtocol.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException("Download host did not complete the protocol handshake.");
}
_readerCancellation = new CancellationTokenSource();
_ = Task.Run(() => ReadLoopAsync(_readerCancellation.Token));
await WriteLogAsync("Information", "download", "Download host process started", Path.GetFileName(executable), cancellationToken)
.ConfigureAwait(false);
}
private async Task ReadLoopAsync(CancellationToken cancellationToken)
{
try
{
while (_reader is not null && !cancellationToken.IsCancellationRequested)
{
var line = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
break;
}
var message = DownloadHostProtocol.Deserialize(line);
if (message?.Progress is not null)
{
ProgressChanged?.Invoke(this, message.Progress);
}
}
}
catch (OperationCanceledException)
{
}
catch (Exception exception)
{
await WriteLogAsync("Warning", "download", "Download host read loop stopped", exception.Message, CancellationToken.None)
.ConfigureAwait(false);
}
}
private async Task WriteMessageAsync(DownloadHostMessage message, CancellationToken cancellationToken)
{
var writer = _writer ?? throw new InvalidOperationException("Download host pipe is not connected.");
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await writer.WriteLineAsync(DownloadHostProtocol.Serialize(message).AsMemory(), cancellationToken).ConfigureAwait(false);
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_writeGate.Release();
}
}
private void DisposeProcess(bool killProcess)
{
lock (_sync)
{
try
{
_readerCancellation?.Cancel();
_readerCancellation?.Dispose();
_reader?.Dispose();
_writer?.Dispose();
_pipe?.Dispose();
}
catch
{
}
finally
{
_readerCancellation = null;
_reader = null;
_writer = null;
_pipe = null;
}
try
{
if (_process is not null && !_process.HasExited && killProcess)
{
_process.Kill(entireProcessTree: true);
}
}
catch
{
}
finally
{
_process?.Dispose();
_process = null;
}
}
}
private async Task WriteLogAsync(string level, string category, string message, string? detail, CancellationToken cancellationToken)
{
if (logService is not null)
{
await logService.WriteAsync(level, category, message, detail, cancellationToken).ConfigureAwait(false);
}
}
private static string? ResolveDownloadHostExecutable()
{
var baseDirectory = AppContext.BaseDirectory;
var candidates = new List<string>
{
Path.Combine(baseDirectory, "download-host", "win-x64", "YMhut.Box.DownloadHost.exe"),
Path.Combine(baseDirectory, "download-host", "YMhut.Box.DownloadHost.exe"),
Path.Combine(baseDirectory, "YMhut.Box.DownloadHost.exe")
};
var directory = new DirectoryInfo(baseDirectory);
while (directory is not null)
{
candidates.Add(Path.Combine(directory.FullName, "src", "YMhut.Box.DownloadHost", "bin", "Debug", "net10.0", "YMhut.Box.DownloadHost.exe"));
candidates.Add(Path.Combine(directory.FullName, "src", "YMhut.Box.DownloadHost", "bin", "Release", "net10.0", "YMhut.Box.DownloadHost.exe"));
directory = directory.Parent;
}
return candidates.FirstOrDefault(File.Exists);
}
}
@@ -0,0 +1,645 @@
using System.Diagnostics;
using System.Security.Cryptography;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Downloads;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.WinUI.Services;
public sealed class DownloadManagerService : IDownloadManagerService, IDisposable
{
private readonly AppPaths _paths;
private readonly IDownloadQueueStore _store;
private readonly IDownloadHostProcessService _host;
private readonly ILogService? _logService;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly List<DownloadItem> _items = [];
private DownloadSettings _settings;
private bool _initialized;
private bool _disposed;
public DownloadManagerService(
AppPaths paths,
IDownloadQueueStore store,
IDownloadHostProcessService host,
ILogService? logService = null)
{
_paths = paths;
_store = store;
_host = host;
_logService = logService;
_settings = new DownloadSettings(Path.Combine(paths.Data, "Downloads"), 5);
_host.ProgressChanged += Host_ProgressChanged;
}
public event EventHandler? ItemsChanged;
public IReadOnlyList<DownloadItem> Items
{
get
{
lock (_items)
{
return _items.ToArray();
}
}
}
public DownloadSettings Settings => _settings;
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_initialized)
{
return;
}
_settings = await _store.LoadSettingsAsync(cancellationToken).ConfigureAwait(false);
Directory.CreateDirectory(_settings.DefaultDirectory);
var loaded = await _store.LoadAsync(cancellationToken).ConfigureAwait(false);
lock (_items)
{
_items.Clear();
_items.AddRange(loaded.Select(NormalizeLoadedItem));
}
_initialized = true;
}
finally
{
_gate.Release();
}
RaiseChanged();
await PumpQueueAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<DownloadItem> EnqueueAsync(
DownloadSource source,
string? targetDirectory = null,
string? installCommand = null,
string? installArguments = null,
CancellationToken cancellationToken = default)
{
return await EnqueueAsync(
source,
new DownloadOptions(
TargetDirectory: targetDirectory,
InstallCommand: installCommand,
InstallArguments: installArguments,
IsInstaller: !string.IsNullOrWhiteSpace(installCommand)),
cancellationToken)
.ConfigureAwait(false);
}
public async Task<DownloadItem> EnqueueAsync(
DownloadSource source,
DownloadOptions options,
CancellationToken cancellationToken = default)
{
await InitializeAsync(cancellationToken).ConfigureAwait(false);
var targetPath = ResolveTargetPath(source, options);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? _settings.DefaultDirectory);
var fileName = SanitizeFileName(Path.GetFileName(targetPath));
var normalizedSource = source with
{
FileName = string.IsNullOrWhiteSpace(fileName) ? SanitizeFileName(source.FileName) : fileName
};
var item = DownloadItem.Create(
normalizedSource,
targetPath,
options.InstallCommand,
options.InstallArguments,
options.IsInstaller,
options.DeleteAfterInstall);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
lock (_items)
{
_items.Insert(0, item);
}
await PersistAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
RaiseChanged();
await PumpQueueAsync(cancellationToken).ConfigureAwait(false);
return item;
}
public async Task UpdateSettingsAsync(DownloadSettings settings, CancellationToken cancellationToken = default)
{
await InitializeAsync(cancellationToken).ConfigureAwait(false);
var next = settings with
{
DefaultDirectory = string.IsNullOrWhiteSpace(settings.DefaultDirectory)
? _settings.DefaultDirectory
: settings.DefaultDirectory,
MaxConcurrentDownloads = settings.EffectiveMaxConcurrentDownloads
};
Directory.CreateDirectory(next.DefaultDirectory);
_settings = next;
await _store.SaveSettingsAsync(next, cancellationToken).ConfigureAwait(false);
RaiseChanged();
await PumpQueueAsync(cancellationToken).ConfigureAwait(false);
}
public async Task StartAsync(string id, CancellationToken cancellationToken = default)
{
await InitializeAsync(cancellationToken).ConfigureAwait(false);
var item = Find(id);
if (item is null)
{
return;
}
if (item.State is DownloadState.Running)
{
return;
}
await UpdateAsync(item.WithProgress(DownloadState.Queued, item.ReceivedBytes, item.TotalBytes, 0, null), cancellationToken)
.ConfigureAwait(false);
await PumpQueueAsync(cancellationToken).ConfigureAwait(false);
}
public async Task PauseAsync(string id, CancellationToken cancellationToken = default)
{
var item = Find(id);
if (item is null)
{
return;
}
if (item.State == DownloadState.Running)
{
await _host.PauseAsync(id, cancellationToken).ConfigureAwait(false);
}
await UpdateAsync(item.WithProgress(DownloadState.Paused, item.ReceivedBytes, item.TotalBytes, 0), cancellationToken)
.ConfigureAwait(false);
await PumpQueueAsync(cancellationToken).ConfigureAwait(false);
}
public async Task ResumeAsync(string id, CancellationToken cancellationToken = default)
{
var item = Find(id);
if (item is null)
{
return;
}
await UpdateAsync(item.WithProgress(DownloadState.Queued, item.ReceivedBytes, item.TotalBytes, 0), cancellationToken)
.ConfigureAwait(false);
await PumpQueueAsync(cancellationToken).ConfigureAwait(false);
}
public async Task CancelAsync(string id, CancellationToken cancellationToken = default)
{
var item = Find(id);
if (item is null)
{
return;
}
if (item.State == DownloadState.Running)
{
await _host.CancelAsync(id, cancellationToken).ConfigureAwait(false);
}
else
{
TryDeleteFile(item.EffectivePartialPath);
}
await UpdateAsync(item.WithProgress(DownloadState.Canceled, item.ReceivedBytes, item.TotalBytes, 0), cancellationToken)
.ConfigureAwait(false);
await PumpQueueAsync(cancellationToken).ConfigureAwait(false);
}
public async Task RetryAsync(string id, CancellationToken cancellationToken = default)
{
var item = Find(id);
if (item is null)
{
return;
}
TryDeleteFile(item.EffectivePartialPath);
var reset = item with
{
ETag = string.Empty,
LastModified = string.Empty,
AcceptRanges = string.Empty,
ContentLength = null,
FinalUrl = string.Empty,
ResumeSupported = false
};
await UpdateAsync(reset.WithProgress(DownloadState.Queued, 0, null, 0, null), cancellationToken)
.ConfigureAwait(false);
await PumpQueueAsync(cancellationToken).ConfigureAwait(false);
}
public async Task ClearCompletedAsync(CancellationToken cancellationToken = default)
{
await InitializeAsync(cancellationToken).ConfigureAwait(false);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
lock (_items)
{
_items.RemoveAll(item => item.State is DownloadState.Completed or DownloadState.Canceled);
}
await PersistAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
RaiseChanged();
}
public async Task MarkInstallLaunchedAsync(string id, bool cleanupCompleted = false, CancellationToken cancellationToken = default)
{
var item = Find(id);
if (item is null)
{
return;
}
await UpdateAsync(item.WithInstallState(true, cleanupCompleted), cancellationToken).ConfigureAwait(false);
}
public async Task TryCleanupAfterInstallAsync(string id, CancellationToken cancellationToken = default)
{
var item = Find(id);
if (item is null ||
!item.DeleteAfterInstall ||
item.InstallCleanupCompleted ||
item.State != DownloadState.Completed ||
string.IsNullOrWhiteSpace(item.TargetPath) ||
!File.Exists(item.TargetPath))
{
return;
}
try
{
File.Delete(item.TargetPath);
await UpdateAsync(item.WithInstallState(item.InstallLaunched, cleanupCompleted: true), cancellationToken)
.ConfigureAwait(false);
await WriteLogAsync("Information", "download", "Installer package cleaned", item.TargetPath, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception)
{
await WriteLogAsync("Warning", "download", "Installer cleanup failed", exception.Message, cancellationToken)
.ConfigureAwait(false);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_host.ProgressChanged -= Host_ProgressChanged;
if (_host is IDisposable disposable)
{
disposable.Dispose();
}
_gate.Dispose();
}
public static Process? LaunchInstaller(DownloadItem item)
{
if (string.IsNullOrWhiteSpace(item.TargetPath) || !File.Exists(item.TargetPath))
{
return null;
}
var command = item.InstallCommand;
var fileName = string.IsNullOrWhiteSpace(command) || string.Equals(command, "installer", StringComparison.OrdinalIgnoreCase)
? item.TargetPath
: command;
var arguments = string.IsNullOrWhiteSpace(command) || string.Equals(command, "installer", StringComparison.OrdinalIgnoreCase)
? item.InstallArguments ?? string.Empty
: BuildArguments(item);
return Process.Start(new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = Path.GetDirectoryName(item.TargetPath) ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
UseShellExecute = true
});
}
private async Task PumpQueueAsync(CancellationToken cancellationToken)
{
await InitializeAsync(cancellationToken).ConfigureAwait(false);
while (true)
{
DownloadItem? next;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
lock (_items)
{
var running = _items.Count(item => item.State == DownloadState.Running);
if (running >= _settings.EffectiveMaxConcurrentDownloads)
{
return;
}
var index = _items.FindLastIndex(item => item.State == DownloadState.Queued);
if (index < 0)
{
return;
}
var current = _items[index];
var partialBytes = FileLength(current.EffectivePartialPath);
var startBytes = partialBytes > 0 ? partialBytes : 0;
var startTotal = partialBytes > 0 ? current.TotalBytes ?? current.ContentLength : current.TotalBytes;
next = current.WithResumeMetadata(
contentLength: current.ContentLength,
resumeSupported: partialBytes > 0 && current.ResumeSupported)
.WithProgress(
DownloadState.Running,
startBytes,
startTotal,
0,
null);
_items[index] = next;
}
await PersistAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
RaiseChanged();
try
{
if (FileLength(next.EffectivePartialPath) > 0)
{
await _host.ResumeAsync(next, cancellationToken).ConfigureAwait(false);
}
else
{
await _host.StartAsync(next, cancellationToken).ConfigureAwait(false);
}
await WriteLogAsync("Information", "download", "Download started", next.Source.DisplayName, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception)
{
await UpdateAsync(next.WithProgress(DownloadState.Failed, next.ReceivedBytes, next.TotalBytes, 0, exception.Message), cancellationToken)
.ConfigureAwait(false);
}
}
}
private void Host_ProgressChanged(object? sender, DownloadProgressSnapshot progress)
{
_ = Task.Run(async () =>
{
var item = Find(progress.Id);
if (item is null)
{
return;
}
var resumeSupported = IsResumeSupported(progress);
var next = item
.WithResumeMetadata(
progress.ETag,
progress.LastModified,
progress.AcceptRanges,
progress.ContentLength,
progress.FinalUrl,
resumeSupported)
.WithProgress(progress.State, progress.ReceivedBytes, progress.TotalBytes, progress.BytesPerSecond, progress.Error);
if (progress.State == DownloadState.Completed)
{
next = await VerifyCompletedAsync(next).ConfigureAwait(false);
}
await UpdateAsync(next, CancellationToken.None).ConfigureAwait(false);
if (progress.State is DownloadState.Completed or DownloadState.Failed or DownloadState.Canceled or DownloadState.Paused)
{
await PumpQueueAsync(CancellationToken.None).ConfigureAwait(false);
}
});
}
private async Task<DownloadItem> VerifyCompletedAsync(DownloadItem item)
{
if (string.IsNullOrWhiteSpace(item.Source.Sha256))
{
await WriteLogAsync("Information", "download", "Download completed", item.TargetPath, CancellationToken.None)
.ConfigureAwait(false);
return item;
}
try
{
await using var stream = File.OpenRead(item.TargetPath);
var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream).ConfigureAwait(false)).ToLowerInvariant();
if (!string.Equals(hash, item.Source.Sha256, StringComparison.OrdinalIgnoreCase))
{
return item.WithProgress(
DownloadState.Failed,
item.ReceivedBytes,
item.TotalBytes,
0,
$"SHA256 mismatch. Expected {item.Source.Sha256}, got {hash}.");
}
}
catch (Exception exception)
{
return item.WithProgress(DownloadState.Failed, item.ReceivedBytes, item.TotalBytes, 0, exception.Message);
}
await WriteLogAsync("Information", "download", "Download completed and verified", item.TargetPath, CancellationToken.None)
.ConfigureAwait(false);
return item;
}
private DownloadItem? Find(string id)
{
lock (_items)
{
return _items.FirstOrDefault(item => string.Equals(item.Id, id, StringComparison.OrdinalIgnoreCase));
}
}
private async Task UpdateAsync(DownloadItem next, CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
lock (_items)
{
var index = _items.FindIndex(item => string.Equals(item.Id, next.Id, StringComparison.OrdinalIgnoreCase));
if (index >= 0)
{
_items[index] = next;
}
}
await PersistAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
RaiseChanged();
}
private async Task PersistAsync(CancellationToken cancellationToken)
{
DownloadItem[] snapshot;
lock (_items)
{
snapshot = _items.ToArray();
}
await _store.SaveAsync(snapshot, cancellationToken).ConfigureAwait(false);
}
private void RaiseChanged()
{
ItemsChanged?.Invoke(this, EventArgs.Empty);
}
private async Task WriteLogAsync(string level, string category, string message, string? detail, CancellationToken cancellationToken)
{
if (_logService is not null)
{
await _logService.WriteAsync(level, category, message, detail, cancellationToken).ConfigureAwait(false);
}
}
private string ResolveTargetPath(DownloadSource source, DownloadOptions options)
{
if (!string.IsNullOrWhiteSpace(options.TargetPath))
{
return NextAvailablePath(options.TargetPath);
}
var directory = string.IsNullOrWhiteSpace(options.TargetDirectory)
? _settings.DefaultDirectory
: options.TargetDirectory;
var fileName = SanitizeFileName(string.IsNullOrWhiteSpace(source.FileName) ? "download.bin" : source.FileName);
return NextAvailablePath(Path.Combine(directory, fileName));
}
private static string BuildArguments(DownloadItem item)
{
var args = item.InstallArguments ?? string.Empty;
return args.Contains("{file}", StringComparison.OrdinalIgnoreCase)
? args.Replace("{file}", Quote(item.TargetPath), StringComparison.OrdinalIgnoreCase)
: $"{Quote(item.TargetPath)} {args}".Trim();
}
private static string Quote(string value)
{
return "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\"";
}
private static string SanitizeFileName(string fileName)
{
var invalid = Path.GetInvalidFileNameChars();
var clean = new string(fileName.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray());
return string.IsNullOrWhiteSpace(clean) ? "download.bin" : clean;
}
private static string NextAvailablePath(string path)
{
if (!File.Exists(path) && !File.Exists(path + ".partial"))
{
return path;
}
var directory = Path.GetDirectoryName(path) ?? Environment.CurrentDirectory;
var name = Path.GetFileNameWithoutExtension(path);
var extension = Path.GetExtension(path);
for (var index = 2; index < 10_000; index++)
{
var candidate = Path.Combine(directory, $"{name} ({index}){extension}");
if (!File.Exists(candidate) && !File.Exists(candidate + ".partial"))
{
return candidate;
}
}
return Path.Combine(directory, $"{name}-{Guid.NewGuid():N}{extension}");
}
private static DownloadItem NormalizeLoadedItem(DownloadItem item)
{
var withPartialPath = string.IsNullOrWhiteSpace(item.PartialPath)
? item with { PartialPath = item.TargetPath + ".partial" }
: item;
var partialBytes = FileLength(withPartialPath.EffectivePartialPath);
return withPartialPath.State == DownloadState.Running
? withPartialPath.WithProgress(DownloadState.Queued, partialBytes, withPartialPath.TotalBytes ?? withPartialPath.ContentLength, 0)
: withPartialPath;
}
private static bool IsResumeSupported(DownloadProgressSnapshot progress)
{
return progress.AcceptRanges.Contains("bytes", StringComparison.OrdinalIgnoreCase) ||
!string.IsNullOrWhiteSpace(progress.ETag) ||
!string.IsNullOrWhiteSpace(progress.LastModified);
}
private static long FileLength(string path)
{
try
{
return File.Exists(path) ? new FileInfo(path).Length : 0;
}
catch
{
return 0;
}
}
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
}
}
}
@@ -0,0 +1,50 @@
using YMhut.Box.Core.Updates;
namespace YMhut.Box.WinUI.Services;
public interface IAppInstallerUpdateService
{
Task<RemoteUpdateInfo?> GetUpdateInfoAsync(CancellationToken cancellationToken = default);
Task<string?> DownloadUpdateAsync(
RemoteUpdateInfo info,
IProgress<UpdateDownloadProgress>? progress = null,
CancellationToken cancellationToken = default);
Task<string> CheckForUpdatesAsync(CancellationToken cancellationToken = default);
}
public sealed record RemoteUpdateInfo(
string Version,
string Build,
string Channel,
string Title,
string Message,
string ReleaseNotes,
string DownloadUrl,
string AppInstallerUrl,
bool Mandatory,
long SizeBytes,
DateTimeOffset? PublishedAt,
string MessageMarkdown = "",
string ReleaseNotesMarkdown = "")
{
public string EffectiveVersion => UpdateVersionComparer.NormalizeVersion(Version, Build);
public string NormalizedVersion => EffectiveVersion;
public string DisplayVersion => string.IsNullOrWhiteSpace(EffectiveVersion) ? Version : EffectiveVersion;
public bool HasMarkdownNotes => !string.IsNullOrWhiteSpace(MessageMarkdown) || !string.IsNullOrWhiteSpace(ReleaseNotesMarkdown);
public string DisplayMessage => string.IsNullOrWhiteSpace(MessageMarkdown) ? Message : MessageMarkdown;
public string DisplayReleaseNotes => string.IsNullOrWhiteSpace(ReleaseNotesMarkdown) ? ReleaseNotes : ReleaseNotesMarkdown;
public int CompareToCurrent(string currentVersion) => UpdateVersionComparer.Compare(Version, Build, currentVersion);
public bool IsNewerThan(string currentVersion) => CompareToCurrent(currentVersion) > 0;
}
public sealed record UpdateDownloadProgress(
long BytesReceived,
long? TotalBytes,
double BytesPerSecond)
{
public double? Percentage => TotalBytes is > 0 ? BytesReceived * 100d / TotalBytes.Value : null;
}
@@ -0,0 +1,11 @@
namespace YMhut.Box.WinUI.Services;
public sealed record AppVersionInfo(
string ProductName,
string Version,
bool IsPackaged);
public interface IAppVersionService
{
AppVersionInfo GetCurrent();
}
@@ -0,0 +1,8 @@
namespace YMhut.Box.WinUI.Services;
public interface IStartupService
{
bool IsEnabled();
void SetEnabled(bool enabled);
}
+16
View File
@@ -0,0 +1,16 @@
using Microsoft.UI.Xaml;
namespace YMhut.Box.WinUI.Services;
public interface ITrayService : IDisposable
{
event EventHandler? ExitRequested;
bool IsAvailable { get; }
void Initialize(Window window);
void ShowMainWindow();
void HideMainWindow();
}
@@ -0,0 +1,39 @@
using System.Diagnostics;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.WinUI.Services;
public interface IIndependentWindowHostLauncher
{
Task LaunchAsync(IndependentWindowHostOptions options, CancellationToken cancellationToken = default);
}
public sealed class IndependentWindowHostLauncher(ILogService? logService = null) : IIndependentWindowHostLauncher
{
public Task LaunchAsync(IndependentWindowHostOptions options, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var executable = RuntimeLayoutBootstrapper.InstalledExecutablePath;
var startInfo = new ProcessStartInfo
{
FileName = executable,
WorkingDirectory = RuntimeLayoutBootstrapper.InstalledWorkingDirectory,
UseShellExecute = false
};
foreach (var argument in options.ToArguments())
{
startInfo.ArgumentList.Add(argument);
}
Process.Start(startInfo);
_ = logService?.WriteAsync(
"Information",
"window-host",
$"Independent window host launched: {options.Kind}",
options.Title,
cancellationToken);
return Task.CompletedTask;
}
}
@@ -0,0 +1,228 @@
using System.Runtime.InteropServices;
namespace YMhut.Box.WinUI.Services;
internal static class LanguagePackService
{
private const string ProjectionMarkerFileName = ".ymhut-transient-lang";
private static readonly (string SourceCulture, string RootCulture)[] WinUiMuiProjectionCultures =
[
("zh-CN", "zh-CN"),
("en-US", "en-us")
];
public static bool EnsureLanguagePacksExtracted()
{
try
{
if (HasLegacyCompressedLayout() && !HasRootMuiProjection())
{
ShowStartupError(
"YMhut Box 启动失败",
"检测到旧版压缩语言资源布局,且当前安装目录没有可用的 WinUI 语言资源。\n\n" +
"请使用新版安装包覆盖安装,安装引导程序会清理旧布局并重新铺设程序文件。\n\n" +
"Legacy compressed language resources were detected. Please reinstall or repair YMhut Box with the latest setup package.");
return false;
}
return EnsureTransientWinUiMuiProjection();
}
catch (Exception exception)
{
CrashLog.Write(exception);
ShowStartupError(
"YMhut Box 启动失败",
"YMhut Box 无法准备 WinUI 语言资源。\n\n" +
"安装目录可能不可写,或 lang\\zh-CN / lang\\en-US 语言文件缺失。请使用安装器修复,或安装到当前用户可写的目录。\n\n" +
"YMhut Box could not prepare WinUI language resources. Repair the installation or install to a writable directory.");
return false;
}
}
public static void ScheduleTransientProjectionCleanup()
{
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
CleanupTransientProjection();
}
catch (Exception exception)
{
CrashLog.Write($"清理临时语言资源映射失败: {exception.Message}");
}
});
}
private static bool HasLegacyCompressedLayout()
{
var legacyLanguageRoot = Path.Combine(AppContext.BaseDirectory, "resources", "lang");
return Directory.Exists(legacyLanguageRoot) &&
Directory.EnumerateFiles(legacyLanguageRoot, "*.bin", SearchOption.TopDirectoryOnly).Any();
}
private static bool EnsureTransientWinUiMuiProjection()
{
var baseDirectory = Path.GetFullPath(AppContext.BaseDirectory);
var langRoot = Path.Combine(baseDirectory, "lang");
if (!Directory.Exists(langRoot))
{
return true;
}
if (HasRootMuiProjection())
{
return true;
}
if (!CanWriteDirectory(baseDirectory))
{
ShowStartupError(
"YMhut Box 启动失败",
"YMhut Box 无法准备 WinUI 语言资源。\n\n" +
$"安装目录不可写:{baseDirectory}\n\n" +
"请使用安装器修复,或安装到当前用户可写的目录。");
return false;
}
foreach (var (sourceCulture, rootCulture) in WinUiMuiProjectionCultures)
{
var source = Path.Combine(langRoot, sourceCulture);
if (!Directory.Exists(source))
{
continue;
}
var target = Path.Combine(baseDirectory, rootCulture);
if (!IsPathInsideBase(baseDirectory, target) || HasMuiFiles(target))
{
continue;
}
Directory.CreateDirectory(target);
CopyDirectory(source, target);
File.WriteAllText(Path.Combine(target, ProjectionMarkerFileName), DateTimeOffset.Now.ToString("O"));
TrySetHidden(target);
}
return HasRootMuiProjection();
}
private static bool CanWriteDirectory(string directory)
{
try
{
Directory.CreateDirectory(directory);
var probe = Path.Combine(directory, $".ymhut-write-test-{Guid.NewGuid():N}.tmp");
File.WriteAllText(probe, string.Empty);
File.Delete(probe);
return true;
}
catch (Exception exception)
{
CrashLog.Write($"Install directory is not writable: {directory}. {exception.Message}");
return false;
}
}
private static void CleanupTransientProjection()
{
var baseDirectory = Path.GetFullPath(AppContext.BaseDirectory);
foreach (var (_, rootCulture) in WinUiMuiProjectionCultures)
{
var target = Path.Combine(baseDirectory, rootCulture);
if (!IsPathInsideBase(baseDirectory, target))
{
continue;
}
var marker = Path.Combine(target, ProjectionMarkerFileName);
if (!File.Exists(marker))
{
continue;
}
TryNormalizeAttributes(target);
Directory.Delete(target, recursive: true);
}
}
private static void CopyDirectory(string source, string target)
{
foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(source, directory);
Directory.CreateDirectory(Path.Combine(target, relative));
}
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(source, file);
var destination = Path.Combine(target, relative);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(file, destination, overwrite: true);
}
}
private static bool HasRootMuiProjection()
{
var baseDirectory = Path.GetFullPath(AppContext.BaseDirectory);
return WinUiMuiProjectionCultures.Any(culture => HasMuiFiles(Path.Combine(baseDirectory, culture.RootCulture)));
}
private static bool HasMuiFiles(string directory)
{
return Directory.Exists(directory) &&
Directory.EnumerateFiles(directory, "*.mui", SearchOption.TopDirectoryOnly).Any();
}
private static bool IsPathInsideBase(string baseDirectory, string candidate)
{
var full = Path.GetFullPath(candidate);
return full.StartsWith(baseDirectory, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(full, baseDirectory, StringComparison.OrdinalIgnoreCase);
}
private static void TrySetHidden(string directory)
{
try
{
File.SetAttributes(directory, File.GetAttributes(directory) | FileAttributes.Hidden);
}
catch
{
}
}
private static void TryNormalizeAttributes(string directory)
{
try
{
foreach (var path in Directory.EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories))
{
File.SetAttributes(path, FileAttributes.Normal);
}
File.SetAttributes(directory, FileAttributes.Normal);
}
catch
{
}
}
private static void ShowStartupError(string title, string message)
{
CrashLog.Write(message);
try
{
_ = MessageBoxW(IntPtr.Zero, message, title, 0x00000010);
}
catch
{
}
}
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
}
@@ -0,0 +1,452 @@
using System.Diagnostics;
using System.IO.Pipes;
using System.Text;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Plugins;
namespace YMhut.Box.WinUI.Services;
public interface IPluginHostProcessService : IDisposable
{
PluginHostStatus Status { get; }
PluginSnapshot? CurrentSnapshot { get; }
event EventHandler<PluginSnapshot>? SnapshotChanged;
event EventHandler<PluginHostStatus>? StatusChanged;
Task<PluginSnapshot> GetSnapshotAsync(CancellationToken cancellationToken = default);
Task<PluginSnapshot> ReloadAsync(CancellationToken cancellationToken = default);
Task<PluginSnapshot> SetPluginEnabledAsync(string pluginId, bool enabled, CancellationToken cancellationToken = default);
Task<PluginSnapshot> SetPermissionAsync(string pluginId, PluginPermission permission, bool granted, CancellationToken cancellationToken = default);
Task<PluginSnapshot> SetSurfaceMountedAsync(string pluginId, string surfaceId, bool mounted, CancellationToken cancellationToken = default);
Task<PluginBridgeResponse> BridgeCallAsync(PluginBridgeRequest request, CancellationToken cancellationToken = default);
void ResetFailedState();
void Stop();
}
public sealed class PluginHostProcessService(ILogService? logService = null) : IPluginHostProcessService
{
private static readonly TimeSpan HostStartTimeout = TimeSpan.FromSeconds(12);
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(60);
private readonly SemaphoreSlim _startGate = new(1, 1);
private readonly SemaphoreSlim _writeGate = new(1, 1);
private readonly Dictionary<string, TaskCompletionSource<PluginHostMessage>> _pending = new(StringComparer.Ordinal);
private Process? _process;
private NamedPipeServerStream? _pipe;
private StreamReader? _reader;
private StreamWriter? _writer;
private CancellationTokenSource? _readerCancellation;
private bool _disposed;
private bool _stopping;
public event EventHandler<PluginSnapshot>? SnapshotChanged;
public event EventHandler<PluginHostStatus>? StatusChanged;
public PluginHostStatus Status { get; private set; } = PluginHostStatus.Stopped;
public PluginSnapshot? CurrentSnapshot { get; private set; }
public async Task<PluginSnapshot> GetSnapshotAsync(CancellationToken cancellationToken = default)
{
if (CurrentSnapshot is not null && Status == PluginHostStatus.Failed)
{
return CurrentSnapshot;
}
var response = await SendRequestAsync(new PluginHostMessage(PluginHostProtocol.GetSnapshot), cancellationToken).ConfigureAwait(false);
return ApplySnapshot(response.Snapshot);
}
public async Task<PluginSnapshot> ReloadAsync(CancellationToken cancellationToken = default)
{
ResetFailedState();
var response = await SendRequestAsync(new PluginHostMessage(PluginHostProtocol.Reload), cancellationToken).ConfigureAwait(false);
return ApplySnapshot(response.Snapshot);
}
public async Task<PluginSnapshot> SetPluginEnabledAsync(string pluginId, bool enabled, CancellationToken cancellationToken = default)
{
var response = await SendRequestAsync(new PluginHostMessage(PluginHostProtocol.SetPluginEnabled, PluginId: pluginId, Enabled: enabled), cancellationToken).ConfigureAwait(false);
return ApplySnapshot(response.Snapshot);
}
public async Task<PluginSnapshot> SetPermissionAsync(string pluginId, PluginPermission permission, bool granted, CancellationToken cancellationToken = default)
{
var response = await SendRequestAsync(new PluginHostMessage(PluginHostProtocol.SetPermission, PluginId: pluginId, Permission: permission, Granted: granted), cancellationToken).ConfigureAwait(false);
return ApplySnapshot(response.Snapshot);
}
public async Task<PluginSnapshot> SetSurfaceMountedAsync(string pluginId, string surfaceId, bool mounted, CancellationToken cancellationToken = default)
{
var response = await SendRequestAsync(new PluginHostMessage(PluginHostProtocol.SetSurfaceMounted, PluginId: pluginId, SurfaceId: surfaceId, Mounted: mounted), cancellationToken).ConfigureAwait(false);
return ApplySnapshot(response.Snapshot);
}
public async Task<PluginBridgeResponse> BridgeCallAsync(PluginBridgeRequest request, CancellationToken cancellationToken = default)
{
var response = await SendRequestAsync(new PluginHostMessage(PluginHostProtocol.BridgeCall, BridgeRequest: request), cancellationToken).ConfigureAwait(false);
return response.BridgeResponse ?? new PluginBridgeResponse(false, Error: response.Error ?? "Plugin host returned no bridge response.");
}
public void Stop()
{
_stopping = true;
SetStatus(PluginHostStatus.Stopped);
try
{
if (_writer is not null)
{
_writer.WriteLine(PluginHostProtocol.Serialize(new PluginHostMessage(PluginHostProtocol.Shutdown)));
_writer.Flush();
}
}
catch
{
}
DisposeProcess(killProcess: false);
FailPending("Plugin host was stopped.");
CurrentSnapshot = null;
SetStatus(PluginHostStatus.Stopped);
}
public void ResetFailedState()
{
if (Status != PluginHostStatus.Failed)
{
return;
}
FailPending("Plugin host failed and was reset.");
DisposeProcess(killProcess: true);
CurrentSnapshot = null;
_stopping = false;
SetStatus(PluginHostStatus.Stopped);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Stop();
_startGate.Dispose();
_writeGate.Dispose();
}
private async Task<PluginHostMessage> SendRequestAsync(PluginHostMessage message, CancellationToken cancellationToken)
{
await EnsureHostAsync(cancellationToken).ConfigureAwait(false);
var requestId = Guid.NewGuid().ToString("N");
var request = message with { RequestId = requestId };
var completion = new TaskCompletionSource<PluginHostMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
lock (_pending)
{
_pending[requestId] = completion;
}
try
{
await WriteMessageAsync(request, cancellationToken).ConfigureAwait(false);
using var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
return await completion.Task.WaitAsync(RequestTimeout, cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException exception)
{
RemovePending(requestId);
if (!_stopping && !_disposed)
{
DisposeProcess(killProcess: true);
FailPending("Plugin host request timed out.");
SetStatus(PluginHostStatus.Failed);
}
var detail = $"{PluginHostRequestLabel(message.Type)} exceeded {RequestTimeout.TotalSeconds:0} seconds.";
await WriteLogAsync("Error", "Plugin host request timed out", detail).ConfigureAwait(false);
throw new TimeoutException($"插件宿主请求超时:{PluginHostRequestLabel(message.Type)} 在 {RequestTimeout.TotalSeconds:0} 秒内没有响应。", exception);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
RemovePending(requestId);
throw;
}
catch (Exception exception)
{
RemovePending(requestId);
if (!_stopping && !_disposed)
{
DisposeProcess(killProcess: true);
FailPending("Plugin host request failed.");
SetStatus(PluginHostStatus.Failed);
}
await WriteLogAsync("Error", "Plugin host request failed", exception.Message).ConfigureAwait(false);
throw;
}
}
private async Task EnsureHostAsync(CancellationToken cancellationToken)
{
if (_process is { HasExited: false } && _pipe?.IsConnected == true && _reader is not null && _writer is not null)
{
return;
}
await _startGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_process is { HasExited: false } && _pipe?.IsConnected == true && _reader is not null && _writer is not null)
{
return;
}
DisposeProcess(killProcess: true);
_stopping = false;
SetStatus(PluginHostStatus.Starting);
var executable = ResolvePluginHostExecutable()
?? throw new FileNotFoundException("YMhut.Box.PluginHost.exe was not found in the application output.");
var pipeName = $"YMhutBoxPluginHost-{Environment.ProcessId}-{Guid.NewGuid():N}";
_pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
_process = Process.Start(new ProcessStartInfo
{
FileName = executable,
Arguments = $"--pipe {pipeName}",
WorkingDirectory = Path.GetDirectoryName(executable) ?? AppContext.BaseDirectory,
UseShellExecute = false,
CreateNoWindow = true
}) ?? throw new InvalidOperationException("Unable to start the YMhut plugin host process.");
await _pipe.WaitForConnectionAsync(cancellationToken).WaitAsync(HostStartTimeout, cancellationToken).ConfigureAwait(false);
_reader = new StreamReader(_pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
_writer = new StreamWriter(_pipe, new UTF8Encoding(false), bufferSize: 4096, leaveOpen: true) { AutoFlush = true };
var readyLine = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)
?? throw new IOException("Plugin host pipe closed before ready.");
var ready = PluginHostProtocol.Deserialize(readyLine)
?? throw new InvalidDataException("Plugin host returned an invalid ready message.");
if (!string.Equals(ready.Type, PluginHostProtocol.Ready, StringComparison.Ordinal) ||
!string.Equals(ready.Version, PluginHostProtocol.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException("Plugin host did not complete the protocol handshake.");
}
_readerCancellation = new CancellationTokenSource();
_ = Task.Run(() => ReadLoopAsync(_readerCancellation.Token));
SetStatus(PluginHostStatus.Ready);
await WriteLogAsync("Information", "Plugin host process started", Path.GetFileName(executable)).ConfigureAwait(false);
}
catch
{
SetStatus(PluginHostStatus.Failed);
FailPending("Plugin host failed to start.");
DisposeProcess(killProcess: true);
throw;
}
finally
{
_startGate.Release();
}
}
private async Task ReadLoopAsync(CancellationToken cancellationToken)
{
var reader = _reader;
try
{
while (reader is not null && !cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
break;
}
var message = PluginHostProtocol.Deserialize(line);
if (message is null)
{
continue;
}
if (string.Equals(message.Type, PluginHostProtocol.SnapshotChanged, StringComparison.Ordinal) && message.Snapshot is not null)
{
ApplySnapshot(message.Snapshot);
continue;
}
if (!string.IsNullOrWhiteSpace(message.RequestId))
{
TaskCompletionSource<PluginHostMessage>? completion;
lock (_pending)
{
completion = _pending.Remove(message.RequestId, out var found) ? found : null;
}
completion?.TrySetResult(message);
}
}
}
catch (OperationCanceledException)
{
}
catch (Exception exception)
{
await WriteLogAsync("Warning", "Plugin host pipe failed", exception.Message).ConfigureAwait(false);
}
if (!ReferenceEquals(reader, _reader))
{
return;
}
FailPending("Plugin host pipe closed unexpectedly.");
DisposeProcess(killProcess: true);
SetStatus(_disposed || _stopping ? PluginHostStatus.Stopped : PluginHostStatus.Failed);
}
private PluginSnapshot ApplySnapshot(PluginSnapshot? snapshot)
{
if (snapshot is null)
{
throw new InvalidOperationException("Plugin host did not return a snapshot.");
}
CurrentSnapshot = snapshot;
SnapshotChanged?.Invoke(this, snapshot);
return snapshot;
}
private async Task WriteMessageAsync(PluginHostMessage message, CancellationToken cancellationToken)
{
var writer = _writer ?? throw new InvalidOperationException("Plugin host pipe is not connected.");
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await writer.WriteLineAsync(PluginHostProtocol.Serialize(message).AsMemory(), cancellationToken).ConfigureAwait(false);
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_writeGate.Release();
}
}
private void DisposeProcess(bool killProcess)
{
try
{
_readerCancellation?.Cancel();
_readerCancellation?.Dispose();
_readerCancellation = null;
_reader?.Dispose();
_writer?.Dispose();
_pipe?.Dispose();
}
catch
{
}
finally
{
_reader = null;
_writer = null;
_pipe = null;
}
try
{
if (_process is not null && !_process.HasExited && killProcess)
{
_process.Kill(entireProcessTree: true);
}
}
catch
{
}
finally
{
_process?.Dispose();
_process = null;
}
}
private void FailPending(string error)
{
lock (_pending)
{
foreach (var pending in _pending.Values)
{
pending.TrySetException(new IOException(error));
}
_pending.Clear();
}
}
private void RemovePending(string requestId)
{
lock (_pending)
{
_pending.Remove(requestId);
}
}
private void SetStatus(PluginHostStatus status)
{
if (Status == status)
{
return;
}
Status = status;
StatusChanged?.Invoke(this, status);
}
private async Task WriteLogAsync(string level, string message, string? detail)
{
if (logService is not null)
{
await logService.WriteAsync(level, "plugin-host", message, detail).ConfigureAwait(false);
}
}
private static string PluginHostRequestLabel(string type)
{
return type switch
{
PluginHostProtocol.GetSnapshot => "获取插件快照",
PluginHostProtocol.Reload => "扫描插件目录",
PluginHostProtocol.SetPluginEnabled => "切换插件启用状态",
PluginHostProtocol.SetPermission => "更新插件权限",
PluginHostProtocol.SetSurfaceMounted => "更新插件挂载项",
PluginHostProtocol.BridgeCall => "插件 Bridge 调用",
_ => type
};
}
private static string? ResolvePluginHostExecutable()
{
var baseDirectory = AppContext.BaseDirectory;
var candidates = new List<string>
{
Path.Combine(baseDirectory, "plugin-host", "win-x64", "YMhut.Box.PluginHost.exe"),
Path.Combine(baseDirectory, "plugin-host", "YMhut.Box.PluginHost.exe"),
Path.Combine(baseDirectory, "YMhut.Box.PluginHost.exe")
};
return candidates.FirstOrDefault(File.Exists);
}
}
@@ -0,0 +1,307 @@
using System.Diagnostics;
using System.IO.Pipes;
using System.Text;
using YMhut.Box.Core.Api;
using YMhut.Box.Core.Data;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.WinUI.Services;
public sealed class ProcessToolWorkerService : IToolWorkerService, IDisposable
{
private readonly IApiManager? _apiManager;
private readonly IReferenceDataService? _referenceDataService;
private readonly ILogService? _logService;
private readonly SemaphoreSlim _requestGate = new(1, 1);
private readonly SemaphoreSlim _writeGate = new(1, 1);
private Process? _process;
private NamedPipeServerStream? _pipe;
private StreamReader? _reader;
private StreamWriter? _writer;
private bool _disposed;
public ProcessToolWorkerService(
IApiManager? apiManager = null,
IReferenceDataService? referenceDataService = null,
ILogService? logService = null)
{
_apiManager = apiManager;
_referenceDataService = referenceDataService;
_logService = logService;
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
}
public Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)
{
return Task.Run(() => work(cancellationToken), cancellationToken);
}
public async Task<ToolExecutionResult> ExecuteToolAsync(
IToolModule module,
string input,
IApiManager? apiManager = null,
IReferenceDataService? referenceDataService = null,
CancellationToken cancellationToken = default,
string language = "zh-CN")
{
await _requestGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await ExecuteThroughWorkerAsync(module, input, language, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
await WriteWorkerLogAsync("Warning", "worker", "Tool worker unavailable; using in-process fallback", exception.Message, CancellationToken.None)
.ConfigureAwait(false);
return await ToolExecutor.ExecuteAsync(
module,
input,
cancellationToken,
apiManager ?? _apiManager,
referenceDataService ?? _referenceDataService,
language).ConfigureAwait(false);
}
finally
{
_requestGate.Release();
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
try
{
if (_writer is not null)
{
_writer.WriteLine(ToolWorkerProtocol.Serialize(new ToolWorkerMessage(ToolWorkerProtocol.Shutdown)));
_writer.Flush();
}
}
catch
{
}
DisposeWorker(killProcess: false);
_requestGate.Dispose();
_writeGate.Dispose();
}
private async Task<ToolExecutionResult> ExecuteThroughWorkerAsync(
IToolModule module,
string input,
string language,
CancellationToken cancellationToken)
{
await EnsureWorkerAsync(cancellationToken).ConfigureAwait(false);
var requestId = Guid.NewGuid().ToString("N");
var request = new ToolWorkerMessage(
ToolWorkerProtocol.ExecuteTool,
requestId,
ToolId: module.Id,
Input: input,
TimeoutMs: 120_000,
Language: language);
await WriteMessageAsync(request, cancellationToken).ConfigureAwait(false);
await WriteWorkerLogAsync("Information", "worker", $"Queued tool in worker: {module.Id}", null, cancellationToken)
.ConfigureAwait(false);
using var cancellation = cancellationToken.Register(() =>
{
_ = Task.Run(async () =>
{
try
{
await WriteMessageAsync(
new ToolWorkerMessage(ToolWorkerProtocol.Cancel, requestId),
CancellationToken.None).ConfigureAwait(false);
}
catch
{
}
});
});
while (true)
{
var message = await ReadMessageAsync(cancellationToken).ConfigureAwait(false);
if (!string.Equals(message.RequestId, requestId, StringComparison.Ordinal))
{
continue;
}
if (string.Equals(message.Type, ToolWorkerProtocol.Result, StringComparison.Ordinal))
{
return new ToolExecutionResult(message.Ok, message.Output ?? string.Empty, message.Error, message.Document);
}
if (string.Equals(message.Type, ToolWorkerProtocol.Error, StringComparison.Ordinal))
{
return ToolExecutionResult.Fail(message.Error ?? "Worker failed to execute the tool.");
}
}
}
private async Task EnsureWorkerAsync(CancellationToken cancellationToken)
{
if (_process is { HasExited: false } && _pipe?.IsConnected == true && _reader is not null && _writer is not null)
{
return;
}
DisposeWorker(killProcess: true);
var executable = ResolveWorkerExecutable()
?? throw new FileNotFoundException("YMhut.Box.Worker.exe was not found in the application output.");
var pipeName = $"YMhutBoxWorker-{Environment.ProcessId}-{Guid.NewGuid():N}";
_pipe = new NamedPipeServerStream(
pipeName,
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
var startInfo = new ProcessStartInfo
{
FileName = executable,
Arguments = $"--pipe {pipeName}",
WorkingDirectory = Path.GetDirectoryName(executable) ?? AppContext.BaseDirectory,
UseShellExecute = false,
CreateNoWindow = true
};
_process = Process.Start(startInfo)
?? throw new InvalidOperationException("Unable to start the YMhut tool worker process.");
await _pipe.WaitForConnectionAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(8), cancellationToken)
.ConfigureAwait(false);
_reader = new StreamReader(_pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
_writer = new StreamWriter(_pipe, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), bufferSize: 4096, leaveOpen: true)
{
AutoFlush = true
};
var ready = await ReadMessageAsync(cancellationToken).ConfigureAwait(false);
if (!string.Equals(ready.Type, ToolWorkerProtocol.Ready, StringComparison.Ordinal) ||
!string.Equals(ready.Version, ToolWorkerProtocol.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException("Tool worker did not complete the protocol handshake.");
}
await WriteWorkerLogAsync("Information", "worker", "Tool worker process started", Path.GetFileName(executable), cancellationToken)
.ConfigureAwait(false);
}
private async Task WriteMessageAsync(ToolWorkerMessage message, CancellationToken cancellationToken)
{
var writer = _writer ?? throw new InvalidOperationException("Tool worker pipe is not connected.");
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await writer.WriteLineAsync(ToolWorkerProtocol.Serialize(message).AsMemory(), cancellationToken).ConfigureAwait(false);
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_writeGate.Release();
}
}
private async Task<ToolWorkerMessage> ReadMessageAsync(CancellationToken cancellationToken)
{
var reader = _reader ?? throw new InvalidOperationException("Tool worker pipe is not connected.");
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
DisposeWorker(killProcess: true);
throw new IOException("Tool worker pipe closed unexpectedly.");
}
return ToolWorkerProtocol.Deserialize(line)
?? throw new InvalidDataException("Tool worker returned an invalid message.");
}
private void DisposeWorker(bool killProcess)
{
try
{
_reader?.Dispose();
_writer?.Dispose();
_pipe?.Dispose();
}
catch
{
}
finally
{
_reader = null;
_writer = null;
_pipe = null;
}
try
{
if (_process is not null && !_process.HasExited && killProcess)
{
_process.Kill(entireProcessTree: true);
}
}
catch
{
}
finally
{
_process?.Dispose();
_process = null;
}
}
private async Task WriteWorkerLogAsync(
string level,
string category,
string message,
string? detail,
CancellationToken cancellationToken)
{
if (_logService is null)
{
return;
}
await _logService.WriteAsync(level, category, message, detail, cancellationToken).ConfigureAwait(false);
}
private static string? ResolveWorkerExecutable()
{
var baseDirectory = AppContext.BaseDirectory;
var candidates = new List<string>
{
Path.Combine(baseDirectory, "worker", "win-x64", "YMhut.Box.Worker.exe"),
Path.Combine(baseDirectory, "worker", "YMhut.Box.Worker.exe"),
Path.Combine(baseDirectory, "YMhut.Box.Worker.exe")
};
var directory = new DirectoryInfo(baseDirectory);
while (directory is not null)
{
candidates.Add(Path.Combine(directory.FullName, "src", "YMhut.Box.Worker", "bin", "Debug", "net10.0", "YMhut.Box.Worker.exe"));
candidates.Add(Path.Combine(directory.FullName, "src", "YMhut.Box.Worker", "bin", "Release", "net10.0", "YMhut.Box.Worker.exe"));
directory = directory.Parent;
}
return candidates.FirstOrDefault(File.Exists);
}
}
@@ -0,0 +1,133 @@
using System.Runtime.InteropServices;
using Windows.ApplicationModel;
using YMhut.Box.Core.App;
namespace YMhut.Box.WinUI.Services;
internal static class RuntimeLayoutBootstrapper
{
public static bool TryRelaunchFromWritableLayoutIfNeeded()
{
try
{
CleanupLegacyPersistentRuntime();
if (IsPackaged() || !RequiresLegacyCompressedLayout(AppContext.BaseDirectory))
{
return false;
}
const string title = "YMhut Box 启动失败";
var message =
"检测到旧版压缩语言资源布局,YMhut Box 不再把运行时复制到用户目录。\n\n" +
"请使用新版安装包覆盖安装。安装引导程序会清理旧布局并重新铺设程序文件。\n\n" +
"This installation still uses the legacy compressed language layout. YMhut Box no longer creates a persistent runtime copy under LocalAppData. Please reinstall with the latest setup package.";
CrashLog.Write(message);
ShowStartupError(title, message);
return true;
}
catch (Exception exception)
{
CrashLog.Write(exception);
return false;
}
}
public static string InstalledExecutablePath => InstallLayoutPaths.ResolveInstalledExecutablePath();
public static string InstalledWorkingDirectory => Path.GetDirectoryName(InstalledExecutablePath) ?? AppContext.BaseDirectory;
private static bool RequiresLegacyCompressedLayout(string directory)
{
var legacyLangRoot = Path.Combine(directory, "resources", "lang");
return Directory.Exists(legacyLangRoot) &&
Directory.EnumerateFiles(legacyLangRoot, "*.bin", SearchOption.TopDirectoryOnly).Any() &&
!HasPureLangSatelliteResources(directory);
}
private static bool HasPureLangSatelliteResources(string directory)
{
var langRoot = Path.Combine(directory, "lang");
if (!Directory.Exists(langRoot))
{
return false;
}
foreach (var culture in new[] { "zh-CN", "en-US" })
{
var cultureRoot = Path.Combine(langRoot, culture);
if (!Directory.Exists(cultureRoot))
{
return false;
}
if (!Directory.EnumerateFiles(cultureRoot, "*.mui", SearchOption.AllDirectories).Any() &&
!Directory.EnumerateFiles(cultureRoot, "*.resources.dll", SearchOption.AllDirectories).Any())
{
return false;
}
}
return true;
}
private static void CleanupLegacyPersistentRuntime()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var legacyRoots = new[]
{
Path.Combine(localAppData, "YMhut Box", "WinUI"),
Path.Combine(localAppData, "YMhut Box"),
Path.Combine(localAppData, "ymhut_box")
};
foreach (var root in legacyRoots)
{
foreach (var name in AppPaths.RuntimePayloadDirectoryNames)
{
TryDeleteDirectory(Path.Combine(root, name));
}
}
}
private static void TryDeleteDirectory(string directory)
{
try
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
catch (Exception exception)
{
CrashLog.Write($"Unable to remove legacy runtime directory '{directory}': {exception.Message}");
}
}
private static bool IsPackaged()
{
try
{
_ = Package.Current.Id.FullName;
return true;
}
catch
{
return false;
}
}
private static void ShowStartupError(string title, string message)
{
try
{
_ = MessageBoxW(IntPtr.Zero, message, title, 0x00000010);
}
catch
{
}
}
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
}
@@ -0,0 +1,272 @@
using YMhut.Box.Core.App;
using YMhut.Box.Core.Downloads;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Plugins;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.Startup;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.WinUI.Services;
public interface IStartupInitializationService
{
StartupInitializationSnapshot? CurrentSnapshot { get; }
Task<StartupInitializationSnapshot> InitializeAsync(CancellationToken cancellationToken = default);
Task<StartupInitializationSnapshot> InitializeAsync(
IProgress<StartupInitializationProgress>? progress,
CancellationToken cancellationToken = default);
}
public sealed record StartupInitializationProgress(
string Status,
double Progress,
bool IsIndeterminate = false,
string? Theme = null,
string StageId = "",
string StageName = "",
int StepIndex = 0,
int StepCount = 0,
string? Detail = null,
StartupCheckSeverity Severity = StartupCheckSeverity.Info,
bool IsRepairStep = false);
public sealed class StartupInitializationService(
AppPaths paths,
ISettingsService settingsService,
ILogService logService,
IPluginStateStore pluginStateStore,
IDownloadManagerService downloadManager,
IExternalToolCatalogService externalToolCatalog,
IBuiltinReferenceToolCatalog builtinToolCatalog,
IInstallIntegrityCheckService integrityCheckService) : IStartupInitializationService
{
private readonly SemaphoreSlim _gate = new(1, 1);
public StartupInitializationSnapshot? CurrentSnapshot { get; private set; }
public Task<StartupInitializationSnapshot> InitializeAsync(CancellationToken cancellationToken = default)
=> InitializeAsync(null, cancellationToken);
public async Task<StartupInitializationSnapshot> InitializeAsync(
IProgress<StartupInitializationProgress>? progress,
CancellationToken cancellationToken = default)
{
if (CurrentSnapshot is not null)
{
ReportCompleted(progress, CurrentSnapshot);
return CurrentSnapshot;
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (CurrentSnapshot is not null)
{
ReportCompleted(progress, CurrentSnapshot);
return CurrentSnapshot;
}
var startedAt = DateTimeOffset.Now;
var settings = settingsService.Current;
StartupCheckReport? preflight = null;
int toolCount = 0;
StartupTrace.Write("StartupInitialization:start");
var pipeline = new StartupInitializationPipeline(CreateStages(
startedAt,
() => settings,
value => settings = value,
value => preflight = value,
value => toolCount = value));
var pipelineProgress = new Progress<StartupInitializationStageProgress>(stage =>
{
progress?.Report(new StartupInitializationProgress(
string.IsNullOrWhiteSpace(stage.Detail) ? stage.StageName : stage.Detail,
stage.Progress,
stage.IsIndeterminate,
settings.Theme,
stage.StageId,
stage.StageName,
stage.StepIndex,
stage.StepCount,
stage.Detail,
stage.Severity,
stage.IsRepairStep));
});
await pipeline.RunAsync(pipelineProgress, cancellationToken).ConfigureAwait(false);
if (preflight is null)
{
throw new InvalidOperationException("Startup preflight did not produce a report.");
}
CurrentSnapshot = new StartupInitializationSnapshot(
startedAt,
DateTimeOffset.Now,
preflight,
toolCount,
preflight.InstallRoot);
await logService.WriteAsync(
preflight.HasCriticalIssues ? "Error" : preflight.HasVisibleIssues ? "Warning" : "Information",
"startup",
"启动预热完成",
$"tools={toolCount}; critical={preflight.CriticalIssueCount}; issues={preflight.IssueCount}; visible={preflight.VisibleIssueCount}",
cancellationToken).ConfigureAwait(false);
StartupTrace.Write($"StartupInitialization:ready:tools={toolCount}:critical={preflight.CriticalIssueCount}");
ReportCompleted(progress, CurrentSnapshot);
return CurrentSnapshot;
}
catch (Exception exception)
{
StartupTrace.Write($"StartupInitialization:failed:{exception.GetType().Name}");
CrashLog.Write(exception);
throw;
}
finally
{
_gate.Release();
}
}
private IEnumerable<StartupInitializationStage> CreateStages(
DateTimeOffset startedAt,
Func<AppSettings> getSettings,
Action<AppSettings> setSettings,
Action<StartupCheckReport> setPreflight,
Action<int> setToolCount)
{
yield return Stage("prepare-user-data", T("准备用户目录", "Preparing user folders"), 8, true, (context, token) =>
{
context.Report(0.35, T("正在准备用户数据、缓存和日志目录...", "Preparing data, cache, and log folders..."));
paths.EnsureCreated();
context.Report(1, T("用户目录已准备完成。", "User folders are ready."));
return Task.CompletedTask;
});
yield return Stage("settings", T("读取设置", "Reading settings"), 10, true, async (context, token) =>
{
context.Report(0.25, T("正在读取本地设置...", "Reading local settings..."));
var loaded = await settingsService.LoadAsync(token).ConfigureAwait(false);
setSettings(loaded);
context.Report(1, T("本地设置已载入。", "Local settings loaded."));
});
yield return Stage("database", T("初始化主 SQLite", "Initializing main SQLite"), 10, true, async (context, token) =>
{
context.Report(0.25, T("正在初始化日志与主数据库...", "Initializing logs and main database..."));
await logService.WriteAsync(
"Information",
"startup",
"启动预热开始",
$"language={getSettings().Language}; started={startedAt:O}",
token).ConfigureAwait(false);
context.Report(1, T("主 SQLite 可写。", "Main SQLite is writable."));
});
yield return Stage("language-layout", T("检查语言资源", "Checking language resources"), 8, true, (context, token) =>
{
var installRoot = InstallLayoutPaths.ResolveInstallRoot();
var zh = Path.Combine(installRoot, "lang", "zh-CN");
var en = Path.Combine(installRoot, "lang", "en-US");
var ok = Directory.Exists(zh) && Directory.Exists(en);
context.Report(
1,
ok
? T("语言资源布局已准备。", "Language layout is ready.")
: T("语言资源布局将在快速预检中复核。", "Language layout will be verified by fast preflight."),
ok ? StartupCheckSeverity.Info : StartupCheckSeverity.Warning);
return Task.CompletedTask;
});
yield return Stage("payload-cleanup", T("清理用户目录残留", "Cleaning user data remnants"), 10, false, (context, token) =>
{
context.Report(0.2, T("正在清理旧版 runtime、Tools 和 Metadata 残留...", "Cleaning legacy runtime, Tools, and Metadata remnants..."), isRepairStep: true);
var removed = paths.CleanupUserPayloadDirectories();
context.Report(
1,
removed.Count == 0
? T("未发现用户目录中的大型程序布局副本。", "No payload copies were found in the user data folder.")
: T($"已清理 {removed.Count} 个用户目录残留。", $"{removed.Count} user data remnants cleaned."),
isRepairStep: true);
return Task.CompletedTask;
});
yield return Stage("plugin-state", T("恢复插件状态", "Restoring plugin state"), 8, false, async (context, token) =>
{
context.Report(0.35, T("正在恢复插件状态...", "Restoring plugin state..."));
_ = await pluginStateStore.GetStateAsync("__startup__", token).ConfigureAwait(false);
context.Report(1, T("插件状态已就绪。", "Plugin state is ready."));
});
yield return Stage("download-queue", T("初始化下载队列", "Initializing download queue"), 8, true, async (context, token) =>
{
context.Report(0.3, T("正在初始化下载队列...", "Initializing download queue..."));
await downloadManager.InitializeAsync(token).ConfigureAwait(false);
context.Report(1, T("下载队列已就绪。", "Download queue is ready."));
});
yield return Stage("fast-preflight", T("执行快速预检", "Running fast preflight"), 20, true, async (context, token) =>
{
context.Report(0.15, T("正在复核安装目录、语言资源和关键文件...", "Verifying install root, language resources, and critical files..."));
var report = await integrityCheckService.RunFastPreflightAsync(token).ConfigureAwait(false);
setPreflight(report);
context.Report(
1,
report.HasVisibleIssues
? T("快速预检完成:发现问题,详情已写入结果页。", "Fast preflight complete: issues found and written to results.")
: T("快速预检完成:未发现问题。", "Fast preflight complete: no issues found."),
report.HasVisibleIssues ? StartupCheckSeverity.Warning : StartupCheckSeverity.Info);
});
yield return Stage("tool-catalog", T("扫描工具目录", "Scanning tool catalog"), 12, false, async (context, token) =>
{
context.Report(0.25, T("正在扫描内置与随包工具...", "Scanning built-in and bundled tools..."));
var externalTools = await externalToolCatalog.GetModulesAsync(token).ConfigureAwait(false);
var builtinTools = builtinToolCatalog.GetModules();
var count = ToolCatalog.DefaultModules().Count() + builtinTools.Count + externalTools.Count;
setToolCount(count);
context.Report(1, T($"工具目录已就绪:{count} 个工具。", $"Tool catalog ready: {count} tools."));
});
yield return Stage("snapshot", T("生成启动快照", "Preparing startup snapshot"), 6, true, (context, token) =>
{
context.Report(1, T("启动快照已生成。", "Startup snapshot prepared."));
return Task.CompletedTask;
});
}
private static StartupInitializationStage Stage(
string id,
string displayName,
double weight,
bool critical,
Func<StartupInitializationStageContext, CancellationToken, Task> executeAsync)
=> new(id, displayName, weight, critical, executeAsync);
private static void ReportCompleted(
IProgress<StartupInitializationProgress>? progress,
StartupInitializationSnapshot snapshot)
{
progress?.Report(new StartupInitializationProgress(
snapshot.HasVisibleStartupIssue
? T("启动自检完成:发现问题,详情可在结果页查看。", "Startup check complete: issues found. Details are available on the results page.")
: T("启动自检完成:未发现问题。", "Startup check complete: no issues found."),
100,
false,
null,
"complete",
T("启动完成", "Startup complete"),
10,
10,
null,
snapshot.HasVisibleStartupIssue ? StartupCheckSeverity.Warning : StartupCheckSeverity.Info));
}
private static string T(string zh, string en) => AppLocalizer.T(zh, en);
}
@@ -0,0 +1,832 @@
using System.Globalization;
using System.Text.Json;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Net;
namespace YMhut.Box.WinUI.Services;
public sealed record TitleWeatherSnapshot(
bool IsAvailable,
string Location,
string Condition,
string TemperatureText,
string FeelsLikeText,
string HumidityText,
string WindText,
string RangeText,
string UpdatedText,
string IconGlyph,
int? WeatherCode,
WeatherVisualKind VisualKind,
WeatherIntensity Intensity,
string QueryLevel,
string? ErrorMessage = null)
{
public static TitleWeatherSnapshot Loading { get; } = new(
false,
AppLocalizer.T("定位中", "Locating"),
AppLocalizer.T("正在获取天气", "Loading weather"),
"--",
"--",
"--",
"--",
"--",
"--",
"\uE753",
null,
WeatherVisualKind.Unknown,
WeatherIntensity.None,
AppLocalizer.T("定位中", "Locating"));
public static TitleWeatherSnapshot Offline(string? error = null) => new(
false,
AppLocalizer.T("天气", "Weather"),
AppLocalizer.T("暂不可用", "Unavailable"),
"--",
"--",
"--",
"--",
"--",
AppLocalizer.T("离线", "Offline"),
"\uE783",
null,
WeatherVisualKind.Unknown,
WeatherIntensity.None,
AppLocalizer.T("离线", "Offline"),
error);
}
public enum WeatherVisualKind
{
Unknown,
Clear,
PartlyCloudy,
Cloudy,
Fog,
Drizzle,
Rain,
FreezingRain,
Snow,
SnowGrains,
Showers,
SnowShowers,
Thunderstorm
}
public enum WeatherIntensity
{
None,
Light,
Moderate,
Heavy
}
public interface ITitleWeatherService
{
Task<TitleWeatherSnapshot> GetCurrentAsync(CancellationToken cancellationToken = default);
}
public sealed class TitleWeatherService(
IHttpService httpService,
ILogService? logService = null) : ITitleWeatherService
{
private const double DistrictSearchMaxDistanceKm = 180;
private const double CitySearchMaxDistanceKm = 500;
private static readonly Uri IpApiLocationUri = new("https://ipapi.co/json/");
private static readonly Uri ClientLocationZhUri = BuildClientLocationUri("zh-Hans");
private static readonly Uri ClientLocationEnUri = BuildClientLocationUri("en");
private static readonly WeatherLocation DefaultLocation = new(
DisplayDistrict: string.Empty,
QueryDistrict: string.Empty,
DistrictGeoNameId: null,
DisplayCity: "上海市",
QueryCity: "Shanghai",
CityGeoNameId: 1796236,
DisplayRegion: "上海市",
QueryRegion: "Shanghai Municipality",
DisplayCountry: "中国",
QueryCountry: "China",
CountryCode: "CN",
Latitude: 31.2304,
Longitude: 121.4737);
public async Task<TitleWeatherSnapshot> GetCurrentAsync(CancellationToken cancellationToken = default)
{
try
{
var location = await ResolveLocationAsync(cancellationToken).ConfigureAwait(false);
var weatherPlace = await ResolveWeatherPlaceAsync(location, cancellationToken).ConfigureAwait(false);
var uri = BuildForecastUri(weatherPlace);
var forecast = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
var snapshot = ParseForecast(location, weatherPlace, forecast);
await WriteLogAsync(
"Information",
"weather",
"Title weather updated",
$"{snapshot.Location}; {snapshot.Condition}; query={weatherPlace.QueryLevel}").ConfigureAwait(false);
return snapshot;
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
{
var safe = AppLocalizer.SanitizeSensitiveText(exception.Message, 180);
await WriteLogAsync("Warning", "weather", "Title weather unavailable", safe).ConfigureAwait(false);
return TitleWeatherSnapshot.Offline(safe);
}
}
private async Task<WeatherLocation> ResolveLocationAsync(CancellationToken cancellationToken)
{
var ipLocation = await TryResolveIpApiLocationAsync(cancellationToken).ConfigureAwait(false);
if (ipLocation is not null)
{
var zhFromIpTask = TryReadClientLocationAsync(
BuildClientLocationUri("zh-Hans", ipLocation.Latitude, ipLocation.Longitude),
cancellationToken);
var enFromIpTask = TryReadClientLocationAsync(
BuildClientLocationUri("en", ipLocation.Latitude, ipLocation.Longitude),
cancellationToken);
await Task.WhenAll(zhFromIpTask, enFromIpTask).ConfigureAwait(false);
var zhFromIp = zhFromIpTask.Result;
var enFromIp = enFromIpTask.Result;
if (zhFromIp is not null || enFromIp is not null)
{
return MergeClientLocations(zhFromIp, enFromIp, ipLocation);
}
return FromIpLocation(ipLocation);
}
var zhTask = TryReadClientLocationAsync(ClientLocationZhUri, cancellationToken);
var enTask = TryReadClientLocationAsync(ClientLocationEnUri, cancellationToken);
await Task.WhenAll(zhTask, enTask).ConfigureAwait(false);
var zh = zhTask.Result;
var en = enTask.Result;
if (zh is not null || en is not null)
{
return MergeClientLocations(zh, en);
}
return DefaultLocation;
}
private async Task<ClientLocation?> TryReadClientLocationAsync(Uri uri, CancellationToken cancellationToken)
{
try
{
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
return ParseClientLocation(content);
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
{
return null;
}
}
private async Task<IpLocation?> TryResolveIpApiLocationAsync(CancellationToken cancellationToken)
{
try
{
var content = await httpService.GetStringAsync(IpApiLocationUri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
var root = document.RootElement;
var latitude = GetDouble(root, "latitude");
var longitude = GetDouble(root, "longitude");
if (latitude is null || longitude is null)
{
return null;
}
var city = FirstNonEmpty(GetString(root, "city"));
var region = FirstNonEmpty(GetString(root, "region"), GetString(root, "region_name"));
var country = FirstNonEmpty(GetString(root, "country_name"), GetString(root, "country"));
var countryCode = FirstNonEmpty(GetString(root, "country_code"), DefaultLocation.CountryCode).ToUpperInvariant();
return new IpLocation(
city,
region,
country,
countryCode,
latitude.Value,
longitude.Value);
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
{
return null;
}
}
private async Task<WeatherPlace> ResolveWeatherPlaceAsync(WeatherLocation location, CancellationToken cancellationToken)
{
if (location.DistrictGeoNameId is long districtId &&
await TryGetGeocodedPlaceByIdAsync(districtId, cancellationToken).ConfigureAwait(false) is { } districtPlace &&
IsNearExpectedLocation(districtPlace, location, DistrictSearchMaxDistanceKm))
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: true),
AppLocalizer.T("区/县", "District"),
districtPlace.Latitude,
districtPlace.Longitude);
}
if (location.CityGeoNameId is long cityId &&
await TryGetGeocodedPlaceByIdAsync(cityId, cancellationToken).ConfigureAwait(false) is { } cityPlace &&
IsNearExpectedLocation(cityPlace, location, CitySearchMaxDistanceKm))
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: false),
AppLocalizer.T("市级", "City"),
cityPlace.Latitude,
cityPlace.Longitude);
}
foreach (var query in BuildNameSearchQueries(location.QueryDistrict))
{
if (await TrySearchGeocodedPlaceAsync(query, location, DistrictSearchMaxDistanceKm, cancellationToken).ConfigureAwait(false) is { } place)
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: true),
AppLocalizer.T("区/县", "District"),
place.Latitude,
place.Longitude);
}
}
foreach (var query in BuildNameSearchQueries(location.QueryCity))
{
if (await TrySearchGeocodedPlaceAsync(query, location, CitySearchMaxDistanceKm, cancellationToken).ConfigureAwait(false) is { } place)
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: false),
AppLocalizer.T("市级", "City"),
place.Latitude,
place.Longitude);
}
}
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: true),
AppLocalizer.T("经纬度", "Coordinates"),
location.Latitude,
location.Longitude);
}
private async Task<GeocodedPlace?> TryGetGeocodedPlaceByIdAsync(long id, CancellationToken cancellationToken)
{
try
{
var uri = new Uri($"https://geocoding-api.open-meteo.com/v1/get?id={id}&language=en&format=json");
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
return TryParseGeocodedPlace(document.RootElement, out var place) ? place : null;
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
{
return null;
}
}
private async Task<GeocodedPlace?> TrySearchGeocodedPlaceAsync(
string query,
WeatherLocation expected,
double maxDistanceKm,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(query))
{
return null;
}
try
{
var uri = new Uri("https://geocoding-api.open-meteo.com/v1/search" +
$"?name={Uri.EscapeDataString(query)}&count=10&language=en&format=json");
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
if (!document.RootElement.TryGetProperty("results", out var results) ||
results.ValueKind != JsonValueKind.Array)
{
return null;
}
return results
.EnumerateArray()
.Select(item => TryParseGeocodedPlace(item, out var place) ? place : null)
.Where(place => place is not null)
.Select(place => place!)
.Where(place => CountryMatches(place, expected))
.Select(place => new
{
Place = place,
Distance = DistanceKm(expected.Latitude, expected.Longitude, place.Latitude, place.Longitude)
})
.Where(item => item.Distance <= maxDistanceKm)
.OrderBy(item => item.Distance)
.Select(item => item.Place)
.FirstOrDefault();
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
{
return null;
}
}
private static Uri BuildForecastUri(WeatherPlace place)
{
var latitude = place.Latitude.ToString("0.####", CultureInfo.InvariantCulture);
var longitude = place.Longitude.ToString("0.####", CultureInfo.InvariantCulture);
return new Uri(
"https://api.open-meteo.com/v1/forecast" +
$"?latitude={latitude}&longitude={longitude}" +
"&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m" +
"&daily=temperature_2m_max,temperature_2m_min" +
"&timezone=auto&forecast_days=1");
}
private static Uri BuildClientLocationUri(string language, double? latitude = null, double? longitude = null)
{
var query = $"localityLanguage={Uri.EscapeDataString(language)}";
if (latitude is not null && longitude is not null)
{
query += $"&latitude={latitude.Value.ToString("0.######", CultureInfo.InvariantCulture)}" +
$"&longitude={longitude.Value.ToString("0.######", CultureInfo.InvariantCulture)}";
}
return new Uri($"https://api.bigdatacloud.net/data/reverse-geocode-client?{query}");
}
private static TitleWeatherSnapshot ParseForecast(WeatherLocation location, WeatherPlace place, string content)
{
using var document = JsonDocument.Parse(content);
var root = document.RootElement;
var current = root.GetProperty("current");
var daily = root.TryGetProperty("daily", out var dailyElement) ? dailyElement : default;
var code = (int)(GetDouble(current, "weather_code") ?? 0);
var temp = GetDouble(current, "temperature_2m");
var apparent = GetDouble(current, "apparent_temperature");
var humidity = GetDouble(current, "relative_humidity_2m");
var wind = GetDouble(current, "wind_speed_10m");
var max = GetFirstArrayDouble(daily, "temperature_2m_max");
var min = GetFirstArrayDouble(daily, "temperature_2m_min");
var updated = FirstNonEmpty(GetString(current, "time"), DateTimeOffset.Now.ToString("HH:mm", CultureInfo.CurrentCulture));
var condition = ConditionText(code);
var visualKind = VisualKind(code);
var intensity = Intensity(code);
return new TitleWeatherSnapshot(
true,
place.DisplayName,
condition,
FormatTemperature(temp),
apparent is null ? "--" : AppLocalizer.T($"体感 {apparent.Value:0.#}°", $"Feels {apparent.Value:0.#}°"),
humidity is null ? "--" : $"{humidity.Value:0}%",
wind is null ? "--" : $"{wind.Value:0.#} km/h",
max is null || min is null ? "--" : $"{min.Value:0.#}° / {max.Value:0.#}°",
FormatUpdated(updated),
WeatherGlyph(code),
code,
visualKind,
intensity,
place.QueryLevel);
}
private static ClientLocation ParseClientLocation(string content)
{
using var document = JsonDocument.Parse(content);
var root = document.RootElement;
var administrative = new List<AdministrativeArea>();
if (root.TryGetProperty("localityInfo", out var localityInfo) &&
localityInfo.ValueKind == JsonValueKind.Object &&
localityInfo.TryGetProperty("administrative", out var adminArray) &&
adminArray.ValueKind == JsonValueKind.Array)
{
foreach (var item in adminArray.EnumerateArray())
{
var name = GetString(item, "name");
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
administrative.Add(new AdministrativeArea(
name,
GetString(item, "isoName"),
GetInt(item, "adminLevel"),
GetInt(item, "order"),
GetLong(item, "geonameId"),
GetString(item, "isoCode")));
}
}
return new ClientLocation(
GetString(root, "locality"),
GetString(root, "city"),
GetString(root, "principalSubdivision"),
GetString(root, "countryName"),
GetString(root, "countryCode"),
GetDouble(root, "latitude"),
GetDouble(root, "longitude"),
administrative);
}
private static WeatherLocation MergeClientLocations(ClientLocation? displaySource, ClientLocation? querySource, IpLocation? fallback = null)
{
var zh = displaySource ?? querySource ?? ClientLocation.Empty;
var en = querySource ?? displaySource ?? ClientLocation.Empty;
var displayDistrictArea = PickDistrictArea(zh);
var queryDistrictArea = PickDistrictArea(en);
var displayCityArea = PickCityArea(zh);
var queryCityArea = PickCityArea(en);
var displayDistrict = FirstNonEmpty(zh.Locality, displayDistrictArea?.Name);
var queryDistrict = FirstNonEmpty(en.Locality, queryDistrictArea?.Name, queryDistrictArea?.IsoName);
var displayCity = FirstNonEmpty(zh.City, displayCityArea?.Name, zh.PrincipalSubdivision, fallback?.City);
var queryCity = FirstNonEmpty(en.City, queryCityArea?.Name, queryCityArea?.IsoName, en.PrincipalSubdivision, fallback?.City);
var displayRegion = FirstNonEmpty(zh.PrincipalSubdivision, displayCity, fallback?.Region, DefaultLocation.DisplayRegion);
var queryRegion = FirstNonEmpty(en.PrincipalSubdivision, queryCity, fallback?.Region, DefaultLocation.QueryRegion);
var displayCountry = FirstNonEmpty(zh.CountryName, fallback?.Country, DefaultLocation.DisplayCountry);
var queryCountry = FirstNonEmpty(en.CountryName, fallback?.Country, DefaultLocation.QueryCountry);
var countryCode = FirstNonEmpty(en.CountryCode, zh.CountryCode, fallback?.CountryCode, DefaultLocation.CountryCode).ToUpperInvariant();
var latitude = zh.Latitude ?? en.Latitude ?? fallback?.Latitude ?? DefaultLocation.Latitude;
var longitude = zh.Longitude ?? en.Longitude ?? fallback?.Longitude ?? DefaultLocation.Longitude;
return new WeatherLocation(
displayDistrict,
queryDistrict,
queryDistrictArea?.GeoNameId ?? displayDistrictArea?.GeoNameId,
displayCity,
queryCity,
queryCityArea?.GeoNameId ?? displayCityArea?.GeoNameId,
displayRegion,
queryRegion,
displayCountry,
queryCountry,
countryCode,
latitude,
longitude);
}
private static WeatherLocation FromIpLocation(IpLocation location)
{
var city = FirstNonEmpty(location.City, DefaultLocation.QueryCity);
var region = FirstNonEmpty(location.Region, city, DefaultLocation.QueryRegion);
var country = FirstNonEmpty(location.Country, DefaultLocation.QueryCountry);
return new WeatherLocation(
DisplayDistrict: string.Empty,
QueryDistrict: string.Empty,
DistrictGeoNameId: null,
DisplayCity: city,
QueryCity: city,
CityGeoNameId: null,
DisplayRegion: region,
QueryRegion: region,
DisplayCountry: country,
QueryCountry: country,
CountryCode: FirstNonEmpty(location.CountryCode, DefaultLocation.CountryCode),
Latitude: location.Latitude,
Longitude: location.Longitude);
}
private static AdministrativeArea? PickDistrictArea(ClientLocation location)
{
var localityArea = FindAreaByName(location.Administrative, location.Locality);
if (localityArea is not null && localityArea.GeoNameId is not null)
{
return localityArea;
}
return location.Administrative
.Where(area => area.GeoNameId is not null && area.AdminLevel is >= 6 and <= 7)
.OrderBy(area => area.Order ?? int.MinValue)
.LastOrDefault();
}
private static AdministrativeArea? PickCityArea(ClientLocation location)
{
var cityArea = FindAreaByName(location.Administrative, location.City);
if (cityArea is not null && cityArea.GeoNameId is not null)
{
return cityArea;
}
return location.Administrative
.Where(area => area.GeoNameId is not null && area.AdminLevel is >= 4 and <= 5)
.OrderBy(area => area.Order ?? int.MinValue)
.LastOrDefault();
}
private static AdministrativeArea? FindAreaByName(IEnumerable<AdministrativeArea> areas, string? name)
{
var normalized = NormalizeLocationName(name);
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
return areas.FirstOrDefault(area =>
NormalizeLocationName(area.Name) == normalized ||
NormalizeLocationName(area.IsoName) == normalized);
}
private static IEnumerable<string> BuildNameSearchQueries(string name)
{
var normalized = FirstNonEmpty(name);
if (string.IsNullOrWhiteSpace(normalized))
{
yield break;
}
yield return normalized;
var suffixes = new[]
{
" District",
" County",
" Municipality",
" Prefecture",
" City",
" Shi",
" Qu",
" Xian"
};
foreach (var suffix in suffixes)
{
if (normalized.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
yield return normalized[..^suffix.Length].Trim();
yield break;
}
}
}
private static bool TryParseGeocodedPlace(JsonElement element, out GeocodedPlace place)
{
var latitude = GetDouble(element, "latitude");
var longitude = GetDouble(element, "longitude");
if (latitude is null || longitude is null)
{
place = default!;
return false;
}
place = new GeocodedPlace(
FirstNonEmpty(GetString(element, "name"), GetString(element, "admin3"), GetString(element, "admin2"), GetString(element, "admin1")),
FirstNonEmpty(GetString(element, "country_code"), GetString(element, "country")),
latitude.Value,
longitude.Value);
return true;
}
private static bool IsNearExpectedLocation(GeocodedPlace place, WeatherLocation expected, double maxDistanceKm)
{
return CountryMatches(place, expected) &&
DistanceKm(expected.Latitude, expected.Longitude, place.Latitude, place.Longitude) <= maxDistanceKm;
}
private static bool CountryMatches(GeocodedPlace place, WeatherLocation expected)
{
return string.IsNullOrWhiteSpace(expected.CountryCode) ||
string.IsNullOrWhiteSpace(place.CountryCode) ||
string.Equals(place.CountryCode, expected.CountryCode, StringComparison.OrdinalIgnoreCase);
}
private static string FormatDisplayLocation(WeatherLocation location, bool preferDistrict)
{
if (AppLocalizer.IsEnglish)
{
return preferDistrict
? FirstNonEmpty(location.QueryDistrict, location.QueryCity, location.QueryRegion, location.QueryCountry, "Weather")
: FirstNonEmpty(location.QueryCity, location.QueryRegion, location.QueryCountry, "Weather");
}
return preferDistrict
? FirstNonEmpty(location.DisplayDistrict, location.DisplayCity, location.DisplayRegion, location.DisplayCountry, "天气")
: FirstNonEmpty(location.DisplayCity, location.DisplayRegion, location.DisplayCountry, "天气");
}
private static string FormatTemperature(double? value) => value is null ? "--" : $"{value.Value:0.#}°";
private static string FormatUpdated(string value)
{
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed))
{
return parsed.LocalDateTime.ToString("HH:mm", CultureInfo.CurrentCulture);
}
return value.Length > 5 ? value[^5..] : value;
}
private static string ConditionText(int code) => code switch
{
0 => AppLocalizer.T("晴", "Clear"),
1 or 2 => AppLocalizer.T("少云", "Partly cloudy"),
3 => AppLocalizer.T("阴", "Cloudy"),
45 or 48 => AppLocalizer.T("雾", "Fog"),
51 or 53 or 55 => AppLocalizer.T("毛毛雨", "Drizzle"),
56 or 57 => AppLocalizer.T("冻雨", "Freezing drizzle"),
61 => AppLocalizer.T("小雨", "Light rain"),
63 => AppLocalizer.T("中雨", "Moderate rain"),
65 => AppLocalizer.T("大雨", "Heavy rain"),
66 or 67 => AppLocalizer.T("冻雨", "Freezing rain"),
71 => AppLocalizer.T("小雪", "Light snow"),
73 => AppLocalizer.T("中雪", "Moderate snow"),
75 => AppLocalizer.T("大雪", "Heavy snow"),
77 => AppLocalizer.T("雪粒", "Snow grains"),
80 => AppLocalizer.T("小阵雨", "Light showers"),
81 => AppLocalizer.T("中阵雨", "Moderate showers"),
82 => AppLocalizer.T("强阵雨", "Heavy showers"),
85 => AppLocalizer.T("小阵雪", "Light snow showers"),
86 => AppLocalizer.T("强阵雪", "Heavy snow showers"),
95 => AppLocalizer.T("雷雨", "Thunderstorm"),
96 or 99 => AppLocalizer.T("强雷雨", "Thunderstorm with hail"),
_ => AppLocalizer.T("多云", "Weather")
};
private static string WeatherGlyph(int code) => code switch
{
0 => "\uE706",
1 or 2 or 3 => "\uE753",
45 or 48 => "\uE9D2",
>= 71 and <= 77 => "\uE9CC",
85 or 86 => "\uE9CC",
>= 51 and <= 67 => "\uE814",
>= 80 and <= 82 => "\uE814",
>= 95 and <= 99 => "\uE945",
_ => "\uE753"
};
private static WeatherVisualKind VisualKind(int code) => code switch
{
0 => WeatherVisualKind.Clear,
1 or 2 => WeatherVisualKind.PartlyCloudy,
3 => WeatherVisualKind.Cloudy,
45 or 48 => WeatherVisualKind.Fog,
51 or 53 or 55 => WeatherVisualKind.Drizzle,
56 or 57 or 66 or 67 => WeatherVisualKind.FreezingRain,
61 or 63 or 65 => WeatherVisualKind.Rain,
71 or 73 or 75 => WeatherVisualKind.Snow,
77 => WeatherVisualKind.SnowGrains,
80 or 81 or 82 => WeatherVisualKind.Showers,
85 or 86 => WeatherVisualKind.SnowShowers,
95 or 96 or 99 => WeatherVisualKind.Thunderstorm,
_ => WeatherVisualKind.Unknown
};
private static WeatherIntensity Intensity(int code) => code switch
{
51 or 56 or 61 or 66 or 71 or 80 or 85 => WeatherIntensity.Light,
53 or 63 or 73 or 81 or 95 => WeatherIntensity.Moderate,
55 or 57 or 65 or 67 or 75 or 77 or 82 or 86 or 96 or 99 => WeatherIntensity.Heavy,
_ => WeatherIntensity.None
};
private static double? GetDouble(JsonElement element, string name)
{
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(name, out var property))
{
return null;
}
return property.ValueKind switch
{
JsonValueKind.Number when property.TryGetDouble(out var value) => value,
JsonValueKind.String when double.TryParse(property.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) => value,
_ => null
};
}
private static double? GetFirstArrayDouble(JsonElement element, string name)
{
if (element.ValueKind != JsonValueKind.Object ||
!element.TryGetProperty(name, out var property) ||
property.ValueKind != JsonValueKind.Array ||
property.GetArrayLength() == 0)
{
return null;
}
var first = property[0];
return first.ValueKind == JsonValueKind.Number && first.TryGetDouble(out var value) ? value : null;
}
private static int? GetInt(JsonElement element, string name)
{
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(name, out var property))
{
return null;
}
return property.ValueKind switch
{
JsonValueKind.Number when property.TryGetInt32(out var value) => value,
JsonValueKind.String when int.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) => value,
_ => null
};
}
private static long? GetLong(JsonElement element, string name)
{
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(name, out var property))
{
return null;
}
return property.ValueKind switch
{
JsonValueKind.Number when property.TryGetInt64(out var value) => value,
JsonValueKind.String when long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) => value,
_ => null
};
}
private static string? GetString(JsonElement element, string name)
{
return element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static string FirstNonEmpty(params string?[] values)
{
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
}
private static string NormalizeLocationName(string? value)
{
return string.IsNullOrWhiteSpace(value)
? string.Empty
: value.Trim().Replace(" ", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
}
private static double DistanceKm(double lat1, double lon1, double lat2, double lon2)
{
const double radiusKm = 6371.0;
var dLat = DegreesToRadians(lat2 - lat1);
var dLon = DegreesToRadians(lon2 - lon1);
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(DegreesToRadians(lat1)) * Math.Cos(DegreesToRadians(lat2)) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
return radiusKm * c;
}
private static double DegreesToRadians(double degrees) => degrees * Math.PI / 180;
private Task WriteLogAsync(string level, string category, string message, string? detail = null)
{
return logService?.WriteAsync(level, category, message, detail) ?? Task.CompletedTask;
}
private sealed record WeatherLocation(
string DisplayDistrict,
string QueryDistrict,
long? DistrictGeoNameId,
string DisplayCity,
string QueryCity,
long? CityGeoNameId,
string DisplayRegion,
string QueryRegion,
string DisplayCountry,
string QueryCountry,
string CountryCode,
double Latitude,
double Longitude);
private sealed record WeatherPlace(string DisplayName, string QueryLevel, double Latitude, double Longitude);
private sealed record GeocodedPlace(string Name, string CountryCode, double Latitude, double Longitude);
private sealed record IpLocation(
string City,
string Region,
string Country,
string CountryCode,
double Latitude,
double Longitude);
private sealed record ClientLocation(
string? Locality,
string? City,
string? PrincipalSubdivision,
string? CountryName,
string? CountryCode,
double? Latitude,
double? Longitude,
IReadOnlyList<AdministrativeArea> Administrative)
{
public static ClientLocation Empty { get; } = new(null, null, null, null, null, null, null, []);
}
private sealed record AdministrativeArea(
string Name,
string? IsoName,
int? AdminLevel,
int? Order,
long? GeoNameId,
string? IsoCode);
}
+119
View File
@@ -0,0 +1,119 @@
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Animation;
using Microsoft.UI.Xaml.Media;
namespace YMhut.Box.WinUI.Services;
public enum ToastKind
{
Success,
Info,
Warning,
Error
}
public static class ToastService
{
private static StackPanel? _host;
private static XamlRoot? _xamlRoot;
public static void Attach(StackPanel host)
{
_host = host;
_xamlRoot = host.XamlRoot;
}
public static void Show(string message, ToastKind kind = ToastKind.Success, TimeSpan? duration = null)
{
if (_host is null)
{
return;
}
_host.DispatcherQueue.TryEnqueue(async () =>
{
var toast = BuildToast(message, kind);
_host.Children.Add(toast);
Animate(toast, show: true);
await Task.Delay(duration ?? TimeSpan.FromSeconds(2));
Animate(toast, show: false);
await Task.Delay(180);
_host.Children.Remove(toast);
});
}
public static XamlRoot? CurrentXamlRoot => _xamlRoot;
private static Border BuildToast(string message, ToastKind kind)
{
var (glyph, color) = kind switch
{
ToastKind.Warning => ("\uE7BA", ModernUi.Bronze),
ToastKind.Error => ("\uEA39", ModernUi.Danger),
ToastKind.Info => ("\uE946", ModernUi.Info),
_ => ("\uE73E", ModernUi.Success)
};
var transform = new TranslateTransform { X = 24 };
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
new FontIcon { Glyph = glyph, FontSize = 16, Foreground = color, VerticalAlignment = VerticalAlignment.Center },
ModernUi.Text(message, 14, FontWeights.SemiBold, ModernUi.TextPrimary, maxLines: 2)
}
};
return new Border
{
MinWidth = 220,
MaxWidth = 380,
Padding = new Thickness(14, 10, 16, 11),
CornerRadius = new CornerRadius(8),
Background = ModernUi.Surface,
BorderBrush = ModernUi.StrokeStrong,
BorderThickness = new Thickness(1),
Opacity = 0,
RenderTransform = transform,
Shadow = new ThemeShadow(),
Child = panel
};
}
private static void Animate(Border toast, bool show)
{
var storyboard = new Storyboard();
var opacity = new DoubleAnimation
{
To = show ? 1 : 0,
Duration = TimeSpan.FromMilliseconds(160),
EnableDependentAnimation = true
};
Storyboard.SetTarget(opacity, toast);
Storyboard.SetTargetProperty(opacity, "Opacity");
storyboard.Children.Add(opacity);
if (toast.RenderTransform is TranslateTransform transform)
{
var slide = new DoubleAnimation
{
To = show ? 0 : 24,
Duration = TimeSpan.FromMilliseconds(180),
EnableDependentAnimation = true,
EasingFunction = new CubicEase { EasingMode = show ? EasingMode.EaseOut : EasingMode.EaseIn }
};
Storyboard.SetTarget(slide, transform);
Storyboard.SetTargetProperty(slide, "X");
storyboard.Children.Add(slide);
}
storyboard.Begin();
}
}
@@ -0,0 +1,63 @@
using Windows.System;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.WinUI.Services;
public enum ToolLinkTarget
{
SafeBrowser,
SystemBrowser
}
public interface IToolLinkNavigationService
{
Task<bool> OpenAsync(string? value, ToolLinkTarget target = ToolLinkTarget.SafeBrowser, string? title = null, CancellationToken cancellationToken = default);
}
public sealed class ToolLinkNavigationService(
AppPaths paths,
IIndependentWindowHostLauncher windowHostLauncher,
ILogService? logService = null) : IToolLinkNavigationService
{
public async Task<bool> OpenAsync(
string? value,
ToolLinkTarget target = ToolLinkTarget.SafeBrowser,
string? title = null,
CancellationToken cancellationToken = default)
{
if (!TryHttpUri(value, out var uri))
{
return false;
}
cancellationToken.ThrowIfCancellationRequested();
if (target == ToolLinkTarget.SystemBrowser)
{
await Launcher.LaunchUriAsync(uri).AsTask().ConfigureAwait(true);
return true;
}
await windowHostLauncher.LaunchAsync(
IndependentWindowHostOptions.Browser(
string.IsNullOrWhiteSpace(title) ? AppLocalizer.T("安全浏览器", "Safe Browser") : title,
uri.AbsoluteUri,
paths),
cancellationToken).ConfigureAwait(true);
_ = logService?.WriteAsync(
"Information",
"navigation",
"Opened link in safe browser",
uri.GetLeftPart(UriPartial.Authority),
cancellationToken);
return true;
}
private static bool TryHttpUri(string? value, out Uri uri)
{
uri = null!;
return Uri.TryCreate(value, UriKind.Absolute, out var candidate) &&
candidate.Scheme is "http" or "https" &&
(uri = candidate) is not null;
}
}
+157
View File
@@ -0,0 +1,157 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Web.WebView2.Core;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage.Pickers;
using Windows.System;
using WinRT.Interop;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.WinUI.Services;
public sealed record ToolPageWebMessage(string Action, string? Value = null, JsonElement? Payload = null);
public sealed class ToolPageWebBridge(
IToolResultExperienceCatalog experienceCatalog,
ISettingsService settingsService,
IToolLinkNavigationService linkNavigationService)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
static ToolPageWebBridge()
{
JsonOptions.Converters.Add(new JsonStringEnumConverter());
}
public ToolPageWebPayload CreatePayload(
IToolModule module,
ToolPageSpec spec,
ToolInputState input,
ToolResultDocument? result = null,
ToolResultRunState? runState = null)
{
var experience = experienceCatalog.GetRequiredPage(module.Id);
return new ToolPageWebPayload(
module.Id,
ToolText.Name(module),
ToolText.Description(module),
spec,
experience,
input,
settingsService.Current.Theme,
settingsService.Current.Language,
new ToolResultPrivacyPolicy(ToolResultPrivacySanitizer.DefaultRedactedHostHints),
new ToolPageRuntimeMetadata(
DateTimeOffset.Now,
spec.AutoRunOnOpen,
module.Metadata.OfflineCapable,
module.Metadata.Category.ToString(),
module.Metadata.IconGlyph),
experience.Actions,
result,
runState);
}
public string Serialize(ToolPageWebPayload payload)
=> JsonSerializer.Serialize(new { type = "toolPagePayload", payload }, JsonOptions);
public string SerializeResult(ToolResultDocument document, ToolResultRunState runState, long durationMs)
=> JsonSerializer.Serialize(new { type = "toolResult", document, runState, durationMs }, JsonOptions);
public string SerializeState(ToolResultRunState runState)
=> JsonSerializer.Serialize(new { type = "toolRunState", runState }, JsonOptions);
public string SerializeTheme()
=> JsonSerializer.Serialize(new
{
type = "settingsChanged",
theme = settingsService.Current.Theme,
language = settingsService.Current.Language
}, JsonOptions);
public static ToolPageWebMessage ParseMessage(CoreWebView2WebMessageReceivedEventArgs args)
{
using var message = JsonDocument.Parse(args.WebMessageAsJson);
var root = message.RootElement.Clone();
var action = root.TryGetProperty("action", out var actionElement) ? actionElement.GetString() ?? string.Empty : string.Empty;
var value = root.TryGetProperty("value", out var valueElement) ? valueElement.GetString() : null;
var payload = root.TryGetProperty("payload", out var payloadElement) ? payloadElement.Clone() : (JsonElement?)null;
return new ToolPageWebMessage(action, value, payload);
}
public async Task<string?> HandleSystemActionAsync(
ToolPageWebMessage message,
IToolModule module,
string rawResult,
IReadOnlyList<string> fileTypeFilters)
{
switch (message.Action)
{
case "copy":
CopyText(ToolResultPrivacySanitizer.Redact(
message.Value ?? rawResult,
settingsService.Current.Language));
ToastService.Show(AppLocalizer.T("已复制", "Copied"), ToastKind.Success);
return null;
case "openFile":
if (!string.IsNullOrWhiteSpace(message.Value))
{
await Launcher.LaunchFileAsync(await Windows.Storage.StorageFile.GetFileFromPathAsync(message.Value));
}
return null;
case "openFolder":
if (!string.IsNullOrWhiteSpace(message.Value))
{
var folder = Directory.Exists(message.Value) ? message.Value : Path.GetDirectoryName(message.Value);
if (!string.IsNullOrWhiteSpace(folder))
{
await Launcher.LaunchFolderPathAsync(folder);
}
}
return null;
case "openLink":
await linkNavigationService.OpenAsync(message.Value);
return null;
case "openSystemBrowser":
await linkNavigationService.OpenAsync(message.Value, ToolLinkTarget.SystemBrowser);
return null;
case "chooseFile":
return await PickFileAsync(fileTypeFilters);
case "copyResult":
CopyText(ToolResultPrivacySanitizer.Redact(rawResult, settingsService.Current.Language));
ToastService.Show(AppLocalizer.T("结果已复制", "Result copied"), ToastKind.Success);
return null;
default:
return null;
}
}
private static async Task<string?> PickFileAsync(IReadOnlyList<string> fileTypeFilters)
{
if (App.CurrentWindow is null)
{
return null;
}
var picker = new FileOpenPicker();
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.CurrentWindow));
foreach (var extension in fileTypeFilters.Count == 0 ? ["*"] : fileTypeFilters)
{
picker.FileTypeFilter.Add(extension);
}
var file = await picker.PickSingleFileAsync();
return file?.Path;
}
private static void CopyText(string text)
{
var data = new DataPackage();
data.SetText(text);
Clipboard.SetContent(data);
}
}
@@ -0,0 +1,103 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Web.WebView2.Core;
using Windows.ApplicationModel.DataTransfer;
using Windows.System;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.WinUI.Services;
public sealed class ToolResultWebBridge(
IToolResultExperienceCatalog experienceCatalog,
ISettingsService settingsService,
IToolLinkNavigationService linkNavigationService)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
static ToolResultWebBridge()
{
JsonOptions.Converters.Add(new JsonStringEnumConverter());
}
public ToolResultWebPayload CreatePayload(
IToolModule module,
ToolResultDocument document,
long durationMs,
bool cached = false,
string source = "tool-run")
{
var experience = experienceCatalog.GetRequired(module.Id);
return new ToolResultWebPayload(
module.Id,
ToolText.Name(module),
document,
experience.ExperienceId,
settingsService.Current.Theme,
settingsService.Current.Language,
new ToolResultPrivacyPolicy(ToolResultPrivacySanitizer.DefaultRedactedHostHints),
new ToolResultRuntimeMetadata(
durationMs,
DateTimeOffset.Now,
cached,
source,
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["category"] = module.Metadata.Category.ToString(),
["offline"] = module.Metadata.OfflineCapable.ToString(),
["icon"] = module.Metadata.IconGlyph
}),
experience);
}
public string Serialize(ToolResultWebPayload payload)
=> JsonSerializer.Serialize(payload, JsonOptions);
public async Task HandleMessageAsync(CoreWebView2WebMessageReceivedEventArgs args)
{
using var message = JsonDocument.Parse(args.WebMessageAsJson);
var root = message.RootElement;
var action = root.TryGetProperty("action", out var actionElement) ? actionElement.GetString() : string.Empty;
var value = root.TryGetProperty("value", out var valueElement) ? valueElement.GetString() : string.Empty;
switch (action)
{
case "copy":
CopyText(ToolResultPrivacySanitizer.Redact(value, settingsService.Current.Language));
ToastService.Show(AppLocalizer.T("已复制", "Copied"), ToastKind.Success);
break;
case "openFile":
if (!string.IsNullOrWhiteSpace(value))
{
await Launcher.LaunchFileAsync(await Windows.Storage.StorageFile.GetFileFromPathAsync(value));
}
break;
case "openFolder":
if (!string.IsNullOrWhiteSpace(value))
{
var folder = Directory.Exists(value) ? value : Path.GetDirectoryName(value);
if (!string.IsNullOrWhiteSpace(folder))
{
await Launcher.LaunchFolderPathAsync(folder);
}
}
break;
case "openLink":
await linkNavigationService.OpenAsync(value);
break;
case "openSystemBrowser":
await linkNavigationService.OpenAsync(value, ToolLinkTarget.SystemBrowser);
break;
}
}
private static void CopyText(string text)
{
var data = new DataPackage();
data.SetText(text);
Clipboard.SetContent(data);
}
}
@@ -0,0 +1,98 @@
namespace YMhut.Box.WinUI.Services;
public sealed class UiPerformanceModeChangedEventArgs(bool isLightMode, string reason) : EventArgs
{
public bool IsLightMode { get; } = isLightMode;
public string Reason { get; } = reason;
}
public interface IUiPerformanceCoordinator
{
bool IsLightMode { get; }
event EventHandler<UiPerformanceModeChangedEventArgs>? LightModeChanged;
IDisposable EnterLightMode(string reason);
}
public sealed class UiPerformanceCoordinator : IUiPerformanceCoordinator
{
private readonly object _gate = new();
private int _scopeCount;
private bool _isLightMode;
private string _lastReason = string.Empty;
public bool IsLightMode
{
get
{
lock (_gate)
{
return _isLightMode;
}
}
}
public event EventHandler<UiPerformanceModeChangedEventArgs>? LightModeChanged;
public IDisposable EnterLightMode(string reason)
{
var changed = false;
lock (_gate)
{
_scopeCount++;
_lastReason = string.IsNullOrWhiteSpace(reason) ? "light-mode" : reason.Trim();
if (!_isLightMode)
{
_isLightMode = true;
changed = true;
}
}
if (changed)
{
LightModeChanged?.Invoke(this, new UiPerformanceModeChangedEventArgs(true, _lastReason));
}
return new Scope(this);
}
private void ExitLightMode()
{
string reason;
var changed = false;
lock (_gate)
{
if (_scopeCount > 0)
{
_scopeCount--;
}
reason = _lastReason;
if (_scopeCount == 0 && _isLightMode)
{
_isLightMode = false;
changed = true;
}
}
if (changed)
{
LightModeChanged?.Invoke(this, new UiPerformanceModeChangedEventArgs(false, reason));
}
}
private sealed class Scope(UiPerformanceCoordinator owner) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
owner.ExitLightMode();
}
}
}
}
@@ -0,0 +1,92 @@
using Microsoft.Web.WebView2.Core;
using System.Collections.Concurrent;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Settings;
namespace YMhut.Box.WinUI.Services;
public sealed class WebView2EnvironmentFactory(
AppPaths paths,
ISettingsService settingsService)
{
private readonly ConcurrentDictionary<string, Lazy<Task<CoreWebView2Environment>>> _environmentCache = new(StringComparer.OrdinalIgnoreCase);
public async Task<CoreWebView2Environment> CreateAsync(
string profileName,
bool? hardwareAccelerationEnabled = null,
string? userDataFolder = null)
{
var resolvedUserDataFolder = string.IsNullOrWhiteSpace(userDataFolder)
? GetUserDataFolder(profileName)
: Path.GetFullPath(userDataFolder);
var useHardwareAcceleration = hardwareAccelerationEnabled ?? settingsService.Current.HardwareAccelerationEnabled;
var cacheKey = $"{Path.GetFullPath(resolvedUserDataFolder)}|gpu={useHardwareAcceleration}";
var lazy = _environmentCache.GetOrAdd(
cacheKey,
_ => new Lazy<Task<CoreWebView2Environment>>(
() => CreateEnvironmentCoreAsync(resolvedUserDataFolder, useHardwareAcceleration),
LazyThreadSafetyMode.ExecutionAndPublication));
try
{
return await lazy.Value.ConfigureAwait(false);
}
catch
{
_environmentCache.TryRemove(cacheKey, out _);
throw;
}
}
public async Task PrewarmAsync(params string[] profileNames)
{
var profiles = profileNames
.Where(profile => !string.IsNullOrWhiteSpace(profile))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (profiles.Length == 0)
{
return;
}
await Task.WhenAll(profiles.Select(profile => CreateAsync(profile))).ConfigureAwait(false);
}
private static async Task<CoreWebView2Environment> CreateEnvironmentCoreAsync(
string resolvedUserDataFolder,
bool useHardwareAcceleration)
{
Directory.CreateDirectory(resolvedUserDataFolder);
var options = new CoreWebView2EnvironmentOptions
{
AdditionalBrowserArguments = useHardwareAcceleration ? string.Empty : "--disable-gpu --disable-gpu-compositing"
};
return await CoreWebView2Environment.CreateWithOptionsAsync(
browserExecutableFolder: null,
userDataFolder: resolvedUserDataFolder,
options: options);
}
public string GetUserDataFolder(string profileName)
{
var segments = profileName
.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(SanitizeProfileSegment)
.Where(segment => !string.IsNullOrWhiteSpace(segment))
.ToArray();
if (segments.Length == 0)
{
segments = ["Default"];
}
return Path.Combine([paths.Cache, "WebView2", .. segments]);
}
private static string SanitizeProfileSegment(string profileName)
{
var invalid = Path.GetInvalidFileNameChars();
var value = new string(profileName.Select(character => invalid.Contains(character) ? '_' : character).ToArray());
return string.IsNullOrWhiteSpace(value) ? "Default" : value;
}
}
@@ -0,0 +1,52 @@
using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using WinRT.Interop;
namespace YMhut.Box.WinUI.Services;
public static class WindowIconService
{
public static void ApplyAppIcon(Window? window)
{
if (window is null)
{
return;
}
try
{
var iconPath = ResolveIconPath();
if (iconPath is null)
{
return;
}
var hwnd = WindowNative.GetWindowHandle(window);
var id = Win32Interop.GetWindowIdFromWindow(hwnd);
AppWindow.GetFromWindowId(id).SetIcon(iconPath);
}
catch (Exception exception)
{
CrashLog.Write(exception);
}
}
public static string? ResolveIconPath()
{
foreach (var path in new[]
{
Path.Combine(AppContext.BaseDirectory, "Assets", "app_icon.ico"),
Path.Combine(AppContext.BaseDirectory, "Assets", "icons", "app_icon.ico"),
Path.Combine(AppContext.BaseDirectory, "app_icon.ico")
})
{
if (File.Exists(path))
{
return path;
}
}
return null;
}
}
@@ -0,0 +1,151 @@
using System.Runtime.InteropServices;
using Microsoft.UI.Xaml;
using WinRT.Interop;
namespace YMhut.Box.WinUI.Services;
public sealed class WindowMovePerformanceHelper : IDisposable
{
private const uint WmEnterSizeMove = 0x0231;
private const uint WmExitSizeMove = 0x0232;
private const uint WmNcDestroy = 0x0082;
private static int s_nextSubclassId;
private readonly Window _window;
private readonly Func<string?> _backdropProvider;
private readonly Action<bool>? _moveStateChanged;
private readonly SubclassProc _subclassProc;
private readonly nuint _subclassId;
private readonly nint _hwnd;
private bool _subclassInstalled;
private bool _disposed;
private bool _inMoveLoop;
private WindowMovePerformanceHelper(
Window window,
Func<string?>? backdropProvider,
Action<bool>? moveStateChanged)
{
_window = window;
_backdropProvider = backdropProvider ?? (() => "mica");
_moveStateChanged = moveStateChanged;
_subclassProc = WindowSubclassProc;
_subclassId = (nuint)Interlocked.Increment(ref s_nextSubclassId);
_hwnd = WindowNative.GetWindowHandle(window);
if (_hwnd != 0)
{
_subclassInstalled = SetWindowSubclass(_hwnd, _subclassProc, _subclassId, 0);
}
window.Closed += Window_Closed;
}
public static WindowMovePerformanceHelper Attach(
Window window,
Func<string?>? backdropProvider = null,
Action<bool>? moveStateChanged = null)
{
return new WindowMovePerformanceHelper(window, backdropProvider, moveStateChanged);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_window.Closed -= Window_Closed;
if (_inMoveLoop)
{
EndMoveLoop();
}
if (_subclassInstalled && _hwnd != 0)
{
RemoveWindowSubclass(_hwnd, _subclassProc, _subclassId);
_subclassInstalled = false;
}
}
private nint WindowSubclassProc(nint hwnd, uint msg, nint wParam, nint lParam, nuint subclassId, nint refData)
{
try
{
switch (msg)
{
case WmEnterSizeMove:
BeginMoveLoop();
break;
case WmExitSizeMove:
EndMoveLoop();
break;
case WmNcDestroy:
Dispose();
break;
}
}
catch (Exception exception)
{
CrashLog.Write(exception);
}
return DefSubclassProc(hwnd, msg, wParam, lParam);
}
private void BeginMoveLoop()
{
if (_disposed || _inMoveLoop)
{
return;
}
_inMoveLoop = true;
_moveStateChanged?.Invoke(true);
if (UsesCompositionBackdrop(_backdropProvider()))
{
ThemeService.ApplyWindowBackdrop(_window, "solid");
}
}
private void EndMoveLoop()
{
if (!_inMoveLoop)
{
return;
}
_inMoveLoop = false;
var backdrop = _backdropProvider();
if (UsesCompositionBackdrop(backdrop))
{
ThemeService.ApplyWindowBackdrop(_window, backdrop ?? "mica");
}
_moveStateChanged?.Invoke(false);
}
private void Window_Closed(object sender, WindowEventArgs args)
{
Dispose();
}
private static bool UsesCompositionBackdrop(string? value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() is not ("solid" or "none");
}
private delegate nint SubclassProc(nint hWnd, uint msg, nint wParam, nint lParam, nuint uIdSubclass, nint dwRefData);
[DllImport("comctl32.dll", SetLastError = true)]
private static extern bool SetWindowSubclass(nint hWnd, SubclassProc pfnSubclass, nuint uIdSubclass, nint dwRefData);
[DllImport("comctl32.dll", SetLastError = true)]
private static extern bool RemoveWindowSubclass(nint hWnd, SubclassProc pfnSubclass, nuint uIdSubclass);
[DllImport("comctl32.dll")]
private static extern nint DefSubclassProc(nint hWnd, uint uMsg, nint wParam, nint lParam);
}
@@ -0,0 +1,433 @@
using Microsoft.UI;
using Microsoft.UI.Text;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using System.Runtime.InteropServices;
using Windows.Graphics;
using WinRT.Interop;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Settings;
namespace YMhut.Box.WinUI.Services;
public sealed class WindowStateService(
ISettingsService settingsService,
ITrayService trayService,
ILogService logService)
{
private Window? _window;
private AppWindow? _appWindow;
private WindowId _windowId;
private bool _allowClose;
public void Attach(Window window)
{
_window = window;
_appWindow = GetAppWindow(window);
if (_appWindow is null)
{
return;
}
_windowId = Win32Interop.GetWindowIdFromWindow(WindowNative.GetWindowHandle(window));
trayService.ExitRequested += TrayService_ExitRequested;
_appWindow.Closing += AppWindow_Closing;
_appWindow.Changed += AppWindow_Changed;
_ = RestoreAsync();
}
private async void TrayService_ExitRequested(object? sender, EventArgs e)
{
await SaveAsync();
await logService.WriteAsync("Information", "window", "托盘菜单请求退出", "source=tray");
_allowClose = true;
_window?.Close();
}
private async Task RestoreAsync()
{
try
{
var settings = await settingsService.LoadAsync();
if (_appWindow is null)
{
return;
}
if (!settings.RestoreWindowPosition || settings.WindowWidth is null || settings.WindowHeight is null)
{
EnsureVisibleOnCurrentDisplay();
return;
}
var x = (int)Math.Round(settings.WindowX ?? 80);
var y = (int)Math.Round(settings.WindowY ?? 80);
var width = Math.Max(960, (int)Math.Round(settings.WindowWidth.Value));
var height = Math.Max(640, (int)Math.Round(settings.WindowHeight.Value));
var bounds = new RectInt32(x, y, width, height);
if (!IsUsableBounds(bounds) || !IntersectsAnyDisplay(bounds))
{
bounds = DefaultWindowBounds(width, height);
await logService.WriteAsync("Warning", "window", "忽略不可见窗口坐标,已恢复到当前屏幕", $"{x},{y},{width},{height}");
}
_appWindow.MoveAndResize(bounds);
_appWindow.Show();
if (settings.WindowMaximized && _appWindow.Presenter is OverlappedPresenter presenter)
{
presenter.Maximize();
}
_window?.Activate();
}
catch (Exception exception)
{
await logService.WriteAsync("Warning", "window", "窗口状态恢复失败", exception.Message);
EnsureVisibleOnCurrentDisplay();
}
}
private async void AppWindow_Changed(AppWindow sender, AppWindowChangedEventArgs args)
{
if (args.DidPositionChange || args.DidSizeChange || args.DidPresenterChange)
{
await SaveAsync();
}
}
private async void AppWindow_Closing(AppWindow sender, AppWindowClosingEventArgs args)
{
if (_allowClose)
{
return;
}
var behavior = NormalizeCloseBehavior(settingsService.Current.CloseBehavior);
if (behavior == "exit_directly")
{
await SaveAsync();
await logService.WriteAsync("Information", "window", "关闭按钮直接退出", "behavior=exit_directly");
_allowClose = true;
return;
}
args.Cancel = true;
await SaveAsync();
await logService.WriteAsync("Information", "window", "关闭按钮请求已拦截", $"behavior={behavior}");
if (behavior == "minimize_to_tray")
{
if (trayService.IsAvailable)
{
trayService.HideMainWindow();
await logService.WriteAsync("Information", "window", "关闭按钮最小化到托盘", "behavior=minimize_to_tray");
}
else
{
ToastService.Show(AppLocalizer.T("托盘不可用,已改为直接退出。", "Tray is unavailable, exiting instead."), ToastKind.Warning);
await logService.WriteAsync("Warning", "window", "托盘不可用,关闭行为降级为退出", "behavior=minimize_to_tray");
_allowClose = true;
_window?.Close();
}
return;
}
await ShowCloseDialogAsync();
}
private static string NormalizeCloseBehavior(string? behavior)
{
return (behavior ?? string.Empty).Trim().ToLowerInvariant() switch
{
"exit" or "exit_directly" or "direct_exit" => "exit_directly",
"minimize" or "minimize_to_tray" or "minimize_then_exit" => "minimize_to_tray",
_ => "ask"
};
}
private async Task ShowCloseDialogAsync()
{
FrameworkElement? root;
try
{
root = _window?.Content as FrameworkElement;
}
catch (COMException)
{
_allowClose = true;
return;
}
if (root?.XamlRoot is null)
{
_allowClose = true;
_window?.Close();
return;
}
var rememberChoice = new CheckBox
{
Content = AppLocalizer.T("记住我的选择,以后不再询问", "Remember my choice and do not ask again"),
Margin = new Thickness(0, 2, 0, 0)
};
AutomationProperties.SetName(
rememberChoice,
AppLocalizer.T("记住我的关闭选择,以后不再询问", "Remember my close choice and do not ask again"));
var dialog = new ContentDialog
{
Title = AppLocalizer.T("关闭 YMhut Box", "Close YMhut Box"),
Content = BuildCloseDialogContent(rememberChoice, trayService.IsAvailable),
PrimaryButtonText = AppLocalizer.T("最小化到托盘", "Minimize to tray"),
SecondaryButtonText = AppLocalizer.T("直接退出", "Exit directly"),
CloseButtonText = AppLocalizer.T("取消", "Cancel"),
IsPrimaryButtonEnabled = trayService.IsAvailable,
DefaultButton = ContentDialogButton.Close,
XamlRoot = root.XamlRoot
};
ContentDialogResult result;
try
{
await logService.WriteAsync("Information", "window", "显示关闭行为询问弹窗", "behavior=ask");
result = await dialog.ShowAsync();
}
catch (COMException)
{
await logService.WriteAsync("Warning", "window", "关闭询问弹窗显示失败,降级退出", "dialog=com_exception");
_allowClose = true;
return;
}
var remember = rememberChoice.IsChecked == true;
if (result == ContentDialogResult.Primary)
{
if (trayService.IsAvailable)
{
await RememberCloseChoiceAsync("minimize_to_tray", remember);
trayService.HideMainWindow();
await logService.WriteAsync("Information", "window", "关闭询问选择最小化到托盘", $"result=primary; remembered={remember}");
}
else
{
ToastService.Show(AppLocalizer.T("托盘不可用,已改为直接退出。", "Tray is unavailable, exiting instead."), ToastKind.Warning);
await logService.WriteAsync("Warning", "window", "关闭询问选择托盘但托盘不可用,降级退出", $"result=primary; remembered={remember}");
_allowClose = true;
_window?.Close();
}
}
else if (result == ContentDialogResult.Secondary)
{
await RememberCloseChoiceAsync("exit_directly", remember);
await logService.WriteAsync("Information", "window", "关闭询问选择直接退出", $"result=secondary; remembered={remember}");
_allowClose = true;
_window?.Close();
}
else
{
await logService.WriteAsync("Information", "window", "关闭询问已取消", "result=cancel");
}
}
private async Task RememberCloseChoiceAsync(string behavior, bool remember)
{
if (!remember)
{
return;
}
await settingsService.UpdateAsync(settings => settings.CloseBehavior = behavior);
ToastService.Show(AppLocalizer.T("关闭行为已保存。", "Close behavior saved."), ToastKind.Success);
}
private static UIElement BuildCloseDialogContent(CheckBox rememberChoice, bool trayAvailable)
{
var panel = new StackPanel
{
Spacing = 14,
MinWidth = 360,
MaxWidth = 460
};
panel.Children.Add(new TextBlock
{
Text = AppLocalizer.T(
"请选择点击关闭按钮后的处理方式。只在本次生效;勾选下方选项后会自动保存为默认关闭行为。",
"Choose what happens when you press the close button. The choice applies once unless you remember it below."),
TextWrapping = TextWrapping.Wrap,
LineHeight = 20
});
var options = new StackPanel { Spacing = 10 };
options.Children.Add(BuildCloseOptionRow(
"\uE75D",
AppLocalizer.T("最小化到托盘", "Minimize to tray"),
trayAvailable
? AppLocalizer.T("窗口隐藏到系统托盘,任务继续保持运行。", "Hide the window in the system tray while tasks keep running.")
: AppLocalizer.T("当前环境未提供托盘图标,请选择直接退出。", "The tray icon is unavailable in this environment. Choose exit directly."),
trayAvailable));
options.Children.Add(BuildCloseOptionRow(
"\uE8BB",
AppLocalizer.T("直接退出", "Exit directly"),
AppLocalizer.T("保存窗口位置后关闭应用。下载、工具执行和后台服务会一起结束。", "Save the window position and close the app. Downloads, tools, and background services will stop."),
enabled: true));
panel.Children.Add(options);
panel.Children.Add(rememberChoice);
return panel;
}
private static UIElement BuildCloseOptionRow(string glyph, string title, string description, bool enabled)
{
var row = new Grid
{
ColumnSpacing = 12,
Opacity = enabled ? 1 : 0.58,
MinHeight = 52
};
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
row.ColumnDefinitions.Add(new ColumnDefinition());
row.Children.Add(new FontIcon
{
Glyph = glyph,
FontSize = 18,
Width = 24,
Height = 24,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 2, 0, 0)
});
var text = new StackPanel
{
Spacing = 3,
Children =
{
new TextBlock
{
Text = title,
FontWeight = FontWeights.SemiBold,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = description,
TextWrapping = TextWrapping.Wrap,
LineHeight = 19,
Foreground = YMhut.Box.WinUI.ModernUi.TextSecondary
}
}
};
Grid.SetColumn(text, 1);
row.Children.Add(text);
return row;
}
private async Task SaveAsync()
{
if (_appWindow is null)
{
return;
}
var maximized = _appWindow.Presenter is OverlappedPresenter presenter && presenter.State == OverlappedPresenterState.Maximized;
if (_appWindow.Presenter is OverlappedPresenter statePresenter &&
statePresenter.State == OverlappedPresenterState.Minimized)
{
return;
}
var bounds = new RectInt32(
_appWindow.Position.X,
_appWindow.Position.Y,
_appWindow.Size.Width,
_appWindow.Size.Height);
if (!_appWindow.IsVisible || !IsUsableBounds(bounds) || !IntersectsAnyDisplay(bounds))
{
return;
}
await settingsService.SaveWindowBoundsAsync(
_appWindow.Position.X,
_appWindow.Position.Y,
_appWindow.Size.Width,
_appWindow.Size.Height,
maximized);
}
private static AppWindow? GetAppWindow(Window window)
{
var hwnd = WindowNative.GetWindowHandle(window);
var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
return AppWindow.GetFromWindowId(windowId);
}
private bool IntersectsAnyDisplay(RectInt32 bounds)
{
try
{
return DisplayArea.FindAll().Any(display => Intersects(bounds, display.WorkArea));
}
catch
{
return bounds.X > -10000 && bounds.Y > -10000;
}
}
private RectInt32 DefaultWindowBounds(int requestedWidth = 1180, int requestedHeight = 760)
{
var width = Math.Clamp(requestedWidth, 960, 1440);
var height = Math.Clamp(requestedHeight, 640, 960);
try
{
var display = DisplayArea.GetFromWindowId(_windowId, DisplayAreaFallback.Primary);
width = Math.Min(width, Math.Max(960, display.WorkArea.Width - 120));
height = Math.Min(height, Math.Max(640, display.WorkArea.Height - 120));
return new RectInt32(
display.WorkArea.X + Math.Max(0, (display.WorkArea.Width - width) / 2),
display.WorkArea.Y + Math.Max(0, (display.WorkArea.Height - height) / 2),
width,
height);
}
catch
{
return new RectInt32(80, 80, width, height);
}
}
private void EnsureVisibleOnCurrentDisplay()
{
if (_appWindow is null)
{
return;
}
var bounds = new RectInt32(_appWindow.Position.X, _appWindow.Position.Y, _appWindow.Size.Width, _appWindow.Size.Height);
if (!IsUsableBounds(bounds) || !IntersectsAnyDisplay(bounds))
{
_appWindow.MoveAndResize(DefaultWindowBounds());
}
_appWindow.Show();
_window?.Activate();
}
private static bool IsUsableBounds(RectInt32 bounds)
{
return bounds.X > -30000
&& bounds.Y > -30000
&& bounds.Width >= 640
&& bounds.Height >= 420;
}
private static bool Intersects(RectInt32 left, RectInt32 right)
{
return left.X < right.X + right.Width
&& left.X + left.Width > right.X
&& left.Y < right.Y + right.Height
&& left.Y + left.Height > right.Y;
}
}
@@ -0,0 +1,76 @@
using System.Runtime.InteropServices.WindowsRuntime;
using Microsoft.Win32;
using Windows.ApplicationModel;
using YMhut.Box.Core.App;
namespace YMhut.Box.WinUI.Services;
public sealed class WindowsStartupService : IStartupService
{
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "YMhut Box";
private const string StartupTaskId = "YMhutBoxStartupTask";
public bool IsEnabled()
{
if (TryGetPackagedStartupTask(out var startupTask))
{
return startupTask.State == StartupTaskState.Enabled;
}
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, false);
return key?.GetValue(ValueName) is string value && value.Contains("YMhutBox", StringComparison.OrdinalIgnoreCase);
}
public void SetEnabled(bool enabled)
{
if (TryGetPackagedStartupTask(out var startupTask))
{
try
{
if (enabled)
{
if (startupTask.State != StartupTaskState.Enabled)
{
_ = startupTask.RequestEnableAsync().AsTask().GetAwaiter().GetResult();
}
}
else if (startupTask.State == StartupTaskState.Enabled)
{
startupTask.Disable();
}
}
catch
{
}
return;
}
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, true) ?? Registry.CurrentUser.CreateSubKey(RunKeyPath, true);
if (enabled)
{
var exe = InstallLayoutPaths.ResolveInstalledExecutablePath();
key.SetValue(ValueName, $"\"{exe}\"");
}
else
{
key.DeleteValue(ValueName, false);
}
}
private static bool TryGetPackagedStartupTask(out StartupTask startupTask)
{
startupTask = null!;
try
{
_ = Package.Current;
startupTask = StartupTask.GetAsync(StartupTaskId).AsTask().GetAwaiter().GetResult();
return startupTask is not null;
}
catch
{
return false;
}
}
}
@@ -0,0 +1,294 @@
using System.Drawing;
using System.Runtime.InteropServices;
using Microsoft.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using WinRT.Interop;
namespace YMhut.Box.WinUI.Services;
public sealed class WindowsTrayService : ITrayService
{
private const int GwlWndProc = -4;
private const uint NifMessage = 0x00000001;
private const uint NifIcon = 0x00000002;
private const uint NifTip = 0x00000004;
private const uint NimAdd = 0x00000000;
private const uint NimDelete = 0x00000002;
private const uint WmApp = 0x8000;
private const uint WmTrayIcon = WmApp + 0x510;
private const uint WmLButtonDblClk = 0x0203;
private const uint WmRButtonUp = 0x0205;
private const uint WmContextMenu = 0x007B;
private const uint WmNull = 0x0000;
private const uint MfString = 0x00000000;
private const uint MfSeparator = 0x00000800;
private const uint TpmRightButton = 0x0002;
private const uint TpmReturnCmd = 0x0100;
private const nuint ShowCommand = 1001;
private const nuint ExitCommand = 1002;
private Window? _window;
private AppWindow? _appWindow;
private DispatcherQueue? _dispatcherQueue;
private nint _hwnd;
private nint _oldWndProc;
private Icon? _icon;
private bool _trayAdded;
private WindowProc? _wndProc;
public event EventHandler? ExitRequested;
public bool IsAvailable => _trayAdded;
public void Initialize(Window window)
{
if (_trayAdded)
{
return;
}
_window = window;
_dispatcherQueue = window.DispatcherQueue;
_hwnd = WindowNative.GetWindowHandle(window);
_appWindow = GetAppWindow(window);
SubclassWindow();
AddTrayIcon();
}
public void ShowMainWindow()
{
Dispatch(() =>
{
_appWindow?.Show();
_window?.Activate();
});
}
public void HideMainWindow()
{
Dispatch(() => _appWindow?.Hide());
}
public void Dispose()
{
RemoveTrayIcon();
if (_hwnd != 0 && _oldWndProc != 0)
{
SetWindowLongPtr(_hwnd, GwlWndProc, _oldWndProc);
_oldWndProc = 0;
}
_icon?.Dispose();
_icon = null;
}
private void SubclassWindow()
{
if (_hwnd == 0 || _oldWndProc != 0)
{
return;
}
_wndProc = WndProc;
_oldWndProc = SetWindowLongPtr(_hwnd, GwlWndProc, Marshal.GetFunctionPointerForDelegate(_wndProc));
}
private void AddTrayIcon()
{
if (_hwnd == 0)
{
return;
}
_icon = LoadIcon();
var data = CreateNotifyIconData();
data.uFlags = NifMessage | NifIcon | NifTip;
data.uCallbackMessage = WmTrayIcon;
data.hIcon = _icon.Handle;
data.szTip = "YMhut Box";
_trayAdded = Shell_NotifyIcon(NimAdd, ref data);
}
private void RemoveTrayIcon()
{
if (!_trayAdded || _hwnd == 0)
{
return;
}
var data = CreateNotifyIconData();
Shell_NotifyIcon(NimDelete, ref data);
_trayAdded = false;
}
private NotifyIconData CreateNotifyIconData()
{
return new NotifyIconData
{
cbSize = (uint)Marshal.SizeOf<NotifyIconData>(),
hWnd = _hwnd,
uID = 1,
szTip = string.Empty,
szInfo = string.Empty,
szInfoTitle = string.Empty
};
}
private nint WndProc(nint hwnd, uint msg, nint wParam, nint lParam)
{
if (msg == WmTrayIcon)
{
var trayMessage = unchecked((uint)lParam.ToInt64());
if (trayMessage == WmLButtonDblClk)
{
ShowMainWindow();
return 0;
}
if (trayMessage is WmRButtonUp or WmContextMenu)
{
ShowContextMenu();
return 0;
}
}
return CallWindowProc(_oldWndProc, hwnd, msg, wParam, lParam);
}
private void ShowContextMenu()
{
if (_hwnd == 0 || !GetCursorPos(out var point))
{
return;
}
var menu = CreatePopupMenu();
if (menu == 0)
{
return;
}
try
{
AppendMenu(menu, MfString, ShowCommand, "显示 YMhut Box");
AppendMenu(menu, MfSeparator, 0, null);
AppendMenu(menu, MfString, ExitCommand, "退出");
SetForegroundWindow(_hwnd);
var command = TrackPopupMenu(menu, TpmReturnCmd | TpmRightButton, point.X, point.Y, 0, _hwnd, 0);
if (command == ShowCommand)
{
ShowMainWindow();
}
else if (command == ExitCommand)
{
Dispatch(() => ExitRequested?.Invoke(this, EventArgs.Empty));
}
PostMessage(_hwnd, WmNull, 0, 0);
}
finally
{
DestroyMenu(menu);
}
}
private static Icon LoadIcon()
{
foreach (var path in new[]
{
Path.Combine(AppContext.BaseDirectory, "Assets", "app_icon.ico"),
Path.Combine(AppContext.BaseDirectory, "Assets", "icons", "app_icon.ico"),
Path.Combine(AppContext.BaseDirectory, "app_icon.ico")
})
{
if (File.Exists(path))
{
return new Icon(path);
}
}
return (Icon)SystemIcons.Application.Clone();
}
private void Dispatch(Action action)
{
if (_dispatcherQueue is not null && !_dispatcherQueue.HasThreadAccess)
{
_dispatcherQueue.TryEnqueue(() => action());
return;
}
action();
}
private static AppWindow? GetAppWindow(Window window)
{
var hwnd = WindowNative.GetWindowHandle(window);
var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
return AppWindow.GetFromWindowId(windowId);
}
private delegate nint WindowProc(nint hwnd, uint msg, nint wParam, nint lParam);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NotifyIconData
{
public uint cbSize;
public nint hWnd;
public uint uID;
public uint uFlags;
public uint uCallbackMessage;
public nint hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string szTip;
public uint dwState;
public uint dwStateMask;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string szInfo;
public uint uVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string szInfoTitle;
public uint dwInfoFlags;
public Guid guidItem;
public nint hBalloonIcon;
}
[StructLayout(LayoutKind.Sequential)]
private struct Point
{
public int X;
public int Y;
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool Shell_NotifyIcon(uint dwMessage, ref NotifyIconData lpData);
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)]
private static extern nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[DllImport("user32.dll", SetLastError = true)]
private static extern nint CallWindowProc(nint lpPrevWndFunc, nint hWnd, uint msg, nint wParam, nint lParam);
[DllImport("user32.dll", SetLastError = true)]
private static extern nint CreatePopupMenu();
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool AppendMenu(nint hMenu, uint uFlags, nuint uIDNewItem, string? lpNewItem);
[DllImport("user32.dll", SetLastError = true)]
private static extern nuint TrackPopupMenu(nint hMenu, uint uFlags, int x, int y, int nReserved, nint hWnd, nint prcRect);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool DestroyMenu(nint hMenu);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool GetCursorPos(out Point lpPoint);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool SetForegroundWindow(nint hWnd);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool PostMessage(nint hWnd, uint msg, nint wParam, nint lParam);
}