Add WinUI and core source
build-winui / winui (push) Has been cancelled
build-winui / winui (push) Has been cancelled
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}" +
|
||||
"¤t=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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user