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

This commit is contained in:
QWQLwToo
2026-06-26 13:27:13 +08:00
parent f59190251d
commit 7ecc6a8923
262 changed files with 137492 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
namespace YMhut.Box.Core.Api;
public enum ApiSourceVisibility
{
PublicOfficial,
PublicTrusted,
SensitivePrivate
}
public sealed record ApiEndpoint(
string Id,
string Name,
string UriTemplate,
string SourceName,
string SourceUrl,
bool IsOfficial = true,
ApiSourceVisibility? VisibilityOverride = null)
{
public ApiSourceVisibility SourceVisibility =>
VisibilityOverride ?? (IsOfficial ? ApiSourceVisibility.PublicOfficial : ApiSourceVisibility.PublicTrusted);
public bool ShouldHideEndpoint => SourceVisibility == ApiSourceVisibility.SensitivePrivate ||
Id.Equals("http_diagnostic", StringComparison.OrdinalIgnoreCase) ||
UriTemplate.Contains("update.ymhut.cn", StringComparison.OrdinalIgnoreCase) ||
UriTemplate.Contains("api.pearapi.ai", StringComparison.OrdinalIgnoreCase);
}
public static class ApiEndpoints
{
private const string PearApiBase = "https://api.pearapi.ai";
private const string DailyHotBase = PearApiBase + "/api/dailyhot/";
private const string AiLatestNewsBase = PearApiBase + "/api/latest_ai_consultative";
private const string HistoryTodayBase = PearApiBase + "/api/lsjt/?type=json";
private const string CityRouteBase = PearApiBase + "/api/citytravelroutes/";
private const string MaoyanBase = PearApiBase + "/api/maoyan/";
private static readonly IReadOnlyDictionary<string, string> DailyHotShortcuts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["baidu_hot"] = "百度",
["bili_hot"] = "哔哩哔哩",
["zhihu_hot"] = "知乎"
};
private static readonly IReadOnlyDictionary<string, ApiEndpoint> Endpoints = new Dictionary<string, ApiEndpoint>(StringComparer.OrdinalIgnoreCase)
{
["baidu_hot"] = new("baidu_hot", "百度热搜", DailyHotBase, "PearAPI DailyHot", PearApiBase, true, ApiSourceVisibility.SensitivePrivate),
["bili_hot"] = new("bili_hot", "B 站热榜", DailyHotBase, "PearAPI DailyHot", PearApiBase, true, ApiSourceVisibility.SensitivePrivate),
["zhihu_hot"] = new("zhihu_hot", "知乎热榜", DailyHotBase, "PearAPI DailyHot", PearApiBase, true, ApiSourceVisibility.SensitivePrivate),
["hotboard"] = new("hotboard", "今日热榜", DailyHotBase, "PearAPI DailyHot", PearApiBase, true, ApiSourceVisibility.SensitivePrivate),
["football_news"] = new("football_news", "体育新闻", "https://www.fifa.com/en", "FIFA", "https://www.fifa.com/en"),
["tech_news"] = new("tech_news", "科技新闻", "https://36kr.com/feed", "36Kr RSS", "https://36kr.com/feed"),
["cctv_news"] = new("cctv_news", "央视新闻", "https://news.cctv.com/", "CCTV News", "https://news.cctv.com/"),
["earthquake_info"] = new("earthquake_info", "地震信息", "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson", "USGS Earthquake Hazards Program", "https://earthquake.usgs.gov/earthquakes/feed/v1.0/geojson.php"),
["ip_info"] = new("ip_info", "IP / 域名详情", "https://rdap.org/ip/{value}", "RDAP", "https://www.iana.org/rdap"),
["ip_lookup"] = new("ip_lookup", "IP RDAP 查询", "https://rdap.org/ip/{value}", "RDAP", "https://www.iana.org/rdap"),
["dns_query"] = new("dns_query", "DNS 解析", "https://dns.google/resolve?name={value}&type=A", "Google Public DNS", "https://developers.google.com/speed/public-dns/docs/doh/json"),
["weather"] = new("weather", "天气查询", "https://geocoding-api.open-meteo.com/v1/search?name={value}&count=1&language=zh&format=json", "Open-Meteo", "https://open-meteo.com/", false),
["history_today"] = new("history_today", "历史上的今天", HistoryTodayBase, "PearAPI History Today", PearApiBase, true, ApiSourceVisibility.SensitivePrivate),
["domain_price"] = new("domain_price", "域名官方信息", "https://rdap.org/domain/{value}", "RDAP", "https://www.iana.org/rdap"),
["wx_domain_check"] = new("wx_domain_check", "微信域名检查说明", "https://developers.weixin.qq.com/doc/", "微信公众平台文档", "https://developers.weixin.qq.com/doc/"),
["qq_avatar"] = new("qq_avatar", "QQ 信息", "https://uapis.cn/api/v1/social/qq/userinfo?qq={value}", "Uapis QQ User Info", "https://uapis.cn/docs/api-reference/get-social-qq-userinfo", false, ApiSourceVisibility.SensitivePrivate),
["qq_profile"] = new("qq_profile", "QQ 公开资料", "https://r.qzone.qq.com/fcg-bin/cgi_get_portrait.fcg?uins={value}", "QQ 公开资料源", "https://qzone.qq.com/", false),
["gold_price"] = new("gold_price", "黄金价格", "https://prices.lbma.org.uk/json/gold_pm.json", "LBMA Precious Metal Prices", "https://www.lbma.org.uk/prices-and-data/precious-metal-prices"),
["oil_price"] = new("oil_price", "成品油价格政策", "https://www.ndrc.gov.cn/", "国家发展改革委", "https://www.ndrc.gov.cn/", true),
["movie_box_office"] = new("movie_box_office", "电影票房", MaoyanBase, "PearAPI Maoyan", PearApiBase, true, ApiSourceVisibility.SensitivePrivate),
["media_types"] = new("media_types", "随机放映室远程配置", "https://update.ymhut.cn/media-types.json", "YMhut Remote Config", "https://update.ymhut.cn/media-types.json", false, ApiSourceVisibility.SensitivePrivate),
["train_query"] = new("train_query", "铁路车站数据", "https://kyfw.12306.cn/otn/resources/js/framework/station_name.js", "中国铁路 12306", "https://www.12306.cn/"),
["rdap_domain_lookup"] = new("rdap_domain_lookup", "域名 RDAP", "https://rdap.org/domain/{value}", "RDAP", "https://www.iana.org/rdap"),
["rdap_ip_lookup"] = new("rdap_ip_lookup", "IP/ASN RDAP", "https://rdap.org/ip/{value}", "RDAP", "https://www.iana.org/rdap"),
["http_diagnostic"] = new("http_diagnostic", "HTTP 诊断", "{value}", "目标站点", "about:blank", false),
["ai_latest_news"] = new("ai_latest_news", "AI 全网最新资讯", AiLatestNewsBase, "PearAPI AI News", PearApiBase, true, ApiSourceVisibility.SensitivePrivate),
["city_route_query"] = new("city_route_query", "城际路线查询", CityRouteBase, "PearAPI City Travel Routes", PearApiBase, true, ApiSourceVisibility.SensitivePrivate)
};
public static bool TryResolve(string id, string input, out ApiEndpoint endpoint, out Uri uri)
{
endpoint = default!;
uri = default!;
if (!Endpoints.TryGetValue(id, out var match))
{
return false;
}
switch (id.ToLowerInvariant())
{
case "hotboard":
return ResolveDailyHot(match, input, out endpoint, out uri);
case "baidu_hot":
case "bili_hot":
case "zhihu_hot":
return ResolveDailyHot(match, string.IsNullOrWhiteSpace(input) ? DailyHotShortcuts[id] : input, out endpoint, out uri);
case "history_today":
return ResolveHistoryToday(match, input, out endpoint, out uri);
case "ai_latest_news":
return ResolveAiLatestNews(match, input, out endpoint, out uri);
case "city_route_query":
return ResolveCityRoute(match, input, out endpoint, out uri);
case "movie_box_office":
endpoint = match;
uri = new Uri(match.UriTemplate);
return true;
case "dns_query":
return ResolveDnsQuery(match, input, out endpoint, out uri);
}
var fallback = id switch
{
"weather" => "北京",
"history_today" => DateTime.Today.ToString("MM-dd"),
"dns_query" => "example.com",
"domain_price" or "rdap_domain_lookup" => "example.com",
"rdap_ip_lookup" or "ip_info" or "ip_lookup" => "8.8.8.8",
"wx_domain_check" or "http_diagnostic" => "https://example.com",
"qq_avatar" => "10000",
"qq_profile" => "10000",
_ => string.Empty
};
var value = string.IsNullOrWhiteSpace(input) ? fallback : input.Trim();
if (id.Equals("history_today", StringComparison.OrdinalIgnoreCase))
{
value = NormalizeOnThisDay(value);
}
if (string.IsNullOrWhiteSpace(value) && match.UriTemplate.Contains("{value}", StringComparison.Ordinal))
{
value = "example.com";
}
var encodedValue = id.Equals("http_diagnostic", StringComparison.OrdinalIgnoreCase) ||
id.Equals("history_today", StringComparison.OrdinalIgnoreCase)
? value
: Uri.EscapeDataString(value);
var rawUri = match.UriTemplate.Replace("{value}", encodedValue, StringComparison.Ordinal);
if (id.Equals("http_diagnostic", StringComparison.OrdinalIgnoreCase) &&
!rawUri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!rawUri.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
rawUri = "https://" + rawUri;
}
if (!Uri.TryCreate(rawUri, UriKind.Absolute, out var parsedUri))
{
return false;
}
endpoint = match;
uri = parsedUri;
return true;
}
public static bool TryGet(string id, out ApiEndpoint endpoint)
{
return Endpoints.TryGetValue(id, out endpoint!);
}
public static IReadOnlyList<ApiEndpoint> All => Endpoints.Values.ToList();
private static bool ResolveDailyHot(ApiEndpoint match, string input, out ApiEndpoint endpoint, out Uri uri)
{
endpoint = match;
var title = string.IsNullOrWhiteSpace(input) ? string.Empty : input.Trim();
if (title.StartsWith("title=", StringComparison.OrdinalIgnoreCase))
{
title = title[6..];
}
if (string.IsNullOrWhiteSpace(title))
{
uri = new Uri(DailyHotBase);
return true;
}
var encoded = Uri.EscapeDataString(title);
uri = new Uri($"{DailyHotBase}?title={encoded}");
return true;
}
private static bool ResolveHistoryToday(ApiEndpoint match, string input, out ApiEndpoint endpoint, out Uri uri)
{
endpoint = match;
var value = string.IsNullOrWhiteSpace(input) ? DateTime.Today.ToString("MM-dd") : NormalizeOnThisDay(input);
var encoded = Uri.EscapeDataString(value);
uri = new Uri($"{HistoryTodayBase}&date={encoded}");
return true;
}
private static bool ResolveAiLatestNews(ApiEndpoint match, string input, out ApiEndpoint endpoint, out Uri uri)
{
endpoint = match;
if (string.IsNullOrWhiteSpace(input))
{
uri = new Uri(AiLatestNewsBase + "?key=");
return true;
}
var key = input.Trim();
if (key.StartsWith("key=", StringComparison.OrdinalIgnoreCase))
{
key = key[4..];
}
uri = new Uri($"{AiLatestNewsBase}?key={Uri.EscapeDataString(key)}");
return true;
}
private static bool ResolveCityRoute(ApiEndpoint match, string input, out ApiEndpoint endpoint, out Uri uri)
{
endpoint = match;
var (from, to) = ParseCityRouteInput(input);
var fromPart = Uri.EscapeDataString(from);
var toPart = Uri.EscapeDataString(to);
uri = new Uri($"{CityRouteBase}?from={fromPart}&to={toPart}");
return true;
}
private static bool ResolveDnsQuery(ApiEndpoint match, string input, out ApiEndpoint endpoint, out Uri uri)
{
var parts = input.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var name = parts.ElementAtOrDefault(0);
var type = parts.ElementAtOrDefault(1);
var value = string.IsNullOrWhiteSpace(name) ? "example.com" : name;
type = string.IsNullOrWhiteSpace(type) ? "A" : type.ToUpperInvariant();
endpoint = match;
uri = new Uri($"https://dns.google/resolve?name={Uri.EscapeDataString(value)}&type={Uri.EscapeDataString(type)}");
return true;
}
private static (string From, string To) ParseCityRouteInput(string input)
{
var value = input.Replace("\r\n", "\n").Trim();
if (value.Contains("->", StringComparison.Ordinal))
{
var parts = value.Split("->", 2, StringSplitOptions.TrimEntries);
return (parts.ElementAtOrDefault(0) ?? "广州", parts.ElementAtOrDefault(1) ?? "深圳");
}
var lines = value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (lines.Length >= 2)
{
return (lines[0], lines[1]);
}
var tokens = value.Split(new[] { ' ', ',', ';', '|', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (tokens.Length >= 2)
{
return (tokens[0], tokens[1]);
}
return ("广州", "深圳");
}
private static string NormalizeOnThisDay(string input)
{
if (DateTime.TryParse(input, out var date))
{
return $"{date:MM-dd}";
}
var trimmed = input.Trim().Replace('/', '-');
var parts = trimmed.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 2 &&
int.TryParse(parts[^2], out var month) &&
int.TryParse(parts[^1], out var day))
{
return $"{month:00}-{day:00}";
}
return $"{DateTime.Today:MM-dd}";
}
}
+149
View File
@@ -0,0 +1,149 @@
using YMhut.Box.Core;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Net;
namespace YMhut.Box.Core.Api;
public enum ApiHealthStatus
{
Unknown,
Healthy,
Degraded,
Unhealthy
}
public sealed record ApiResponse(
string EndpointId,
Uri Uri,
bool Success,
string Content,
string? Error,
DateTimeOffset FetchedAt,
int StatusCode = 0);
public interface IApiManager
{
Task<ApiResponse> FetchAsync(string endpointId, string input = "", CancellationToken cancellationToken = default);
Task<ApiResponse> FetchUriAsync(string endpointId, Uri uri, string input = "", CancellationToken cancellationToken = default);
Task<ApiHealthStatus> CheckHealthAsync(string endpointId, string input = "", CancellationToken cancellationToken = default);
}
public sealed class ApiManager(IHttpService httpService, ILogService? logService = null) : IApiManager
{
private readonly Dictionary<string, (DateTimeOffset fetchedAt, ApiResponse response)> _cache = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);
public async Task<ApiResponse> FetchAsync(string endpointId, string input = "", CancellationToken cancellationToken = default)
{
if (!ApiEndpoints.TryResolve(endpointId, input, out var endpoint, out var uri))
{
return new ApiResponse(endpointId, new Uri("about:blank"), false, string.Empty, "No upstream endpoint is configured for this tool.", DateTimeOffset.Now);
}
return await FetchUriAsync(endpoint.Id, uri, input, cancellationToken).ConfigureAwait(false);
}
public async Task<ApiResponse> FetchUriAsync(string endpointId, Uri uri, string input = "", CancellationToken cancellationToken = default)
{
var cacheKey = $"{endpointId}|{uri}|{input.Trim()}";
if (_cache.TryGetValue(cacheKey, out var cached) && DateTimeOffset.Now - cached.fetchedAt < _cacheDuration)
{
return cached.response;
}
var endpoint = ApiEndpoints.TryGet(endpointId, out var knownEndpoint) ? knownEndpoint : null;
var endpointName = endpoint?.Name ?? endpointId;
try
{
var result = await httpService.SendAsync(
uri,
ensureSuccess: false,
policy: ResolvePolicy(endpointId),
cancellationToken: cancellationToken).ConfigureAwait(false);
if ((int)result.StatusCode < 200 || (int)result.StatusCode >= 300)
{
return new ApiResponse(
endpointId,
uri,
false,
result.Content,
$"HTTP {(int)result.StatusCode} {result.StatusCode}",
DateTimeOffset.Now,
(int)result.StatusCode);
}
var response = new ApiResponse(endpointId, uri, true, result.Content, null, DateTimeOffset.Now, (int)result.StatusCode);
_cache[cacheKey] = (DateTimeOffset.Now, response);
return response;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return new ApiResponse(endpointId, uri, false, string.Empty, "Request canceled.", DateTimeOffset.Now);
}
catch (HttpRequestTimeoutException exception)
{
var error = FriendlyTimeoutMessage(exception.Timeout);
await WriteFailureLogAsync(endpointName, endpoint, error, cancellationToken).ConfigureAwait(false);
return new ApiResponse(endpointId, uri, false, string.Empty, error, DateTimeOffset.Now);
}
catch (TimeoutException)
{
var error = FriendlyTimeoutMessage(ResolvePolicy(endpointId).Timeout);
await WriteFailureLogAsync(endpointName, endpoint, error, cancellationToken).ConfigureAwait(false);
return new ApiResponse(endpointId, uri, false, string.Empty, error, DateTimeOffset.Now);
}
catch (HttpRequestException exception)
{
const string error = "Network request failed. Check the network connection or proxy settings, then retry.";
var detail = endpoint is { ShouldHideEndpoint: true } ? "Sensitive endpoint hidden." : SensitiveText.Sanitize(exception.Message);
await (logService?.WriteAsync("Error", "api", $"{endpointName} request failed", detail, cancellationToken) ?? Task.CompletedTask)
.ConfigureAwait(false);
return new ApiResponse(endpointId, uri, false, string.Empty, error, DateTimeOffset.Now);
}
catch (Exception exception)
{
var error = endpoint is { ShouldHideEndpoint: true }
? "Remote data is temporarily unavailable. Please retry later."
: SensitiveText.Sanitize(exception.Message);
await WriteFailureLogAsync(endpointName, endpoint, error, cancellationToken).ConfigureAwait(false);
return new ApiResponse(endpointId, uri, false, string.Empty, error, DateTimeOffset.Now);
}
}
public async Task<ApiHealthStatus> CheckHealthAsync(string endpointId, string input = "", CancellationToken cancellationToken = default)
{
var response = await FetchAsync(endpointId, input, cancellationToken).ConfigureAwait(false);
if (!response.Success)
{
return ApiHealthStatus.Unhealthy;
}
return string.IsNullOrWhiteSpace(response.Content)
? ApiHealthStatus.Degraded
: ApiHealthStatus.Healthy;
}
public static ApiManager CreateDefault()
{
return new ApiManager(new HttpService());
}
private static HttpRequestPolicy ResolvePolicy(string endpointId)
{
return endpointId.Equals("http_diagnostic", StringComparison.OrdinalIgnoreCase) ||
endpointId.Contains("diagnostic", StringComparison.OrdinalIgnoreCase)
? HttpRequestPolicy.Diagnostics
: HttpRequestPolicy.RemoteTool;
}
private static string FriendlyTimeoutMessage(TimeSpan timeout)
=> $"Remote service response timed out after {timeout.TotalSeconds:0} seconds. Please retry later or check your network/proxy settings.";
private Task WriteFailureLogAsync(string endpointName, ApiEndpoint? endpoint, string error, CancellationToken cancellationToken)
{
var detail = endpoint is { ShouldHideEndpoint: true } ? "Sensitive endpoint hidden." : SensitiveText.Sanitize(error);
return logService?.WriteAsync("Error", "api", $"{endpointName} request failed", detail, cancellationToken) ?? Task.CompletedTask;
}
}
@@ -0,0 +1,9 @@
namespace YMhut.Box.Core.App;
public static class AppDatabasePaths
{
public const string MainDatabaseFileName = "app-log.db";
public static string ResolveMainDatabasePath(AppPaths paths)
=> Path.Combine(paths.Logs, MainDatabaseFileName);
}
+151
View File
@@ -0,0 +1,151 @@
namespace YMhut.Box.Core.App;
public sealed class AppPaths
{
public static IReadOnlyList<string> RuntimePayloadDirectoryNames { get; } =
[
"Runtime",
"runtime",
"Runtimes",
"runtimes"
];
public static IReadOnlyList<string> UserPayloadDirectoryNames { get; } =
[
"Runtime",
"runtime",
"Runtimes",
"runtimes",
"Tools",
"Metadata"
];
public AppPaths(string root, string? assetsRoot = null)
{
Root = root;
Logs = Path.Combine(root, "Logs");
Cache = Path.Combine(root, "Cache");
Data = Path.Combine(root, "Data");
Assets = assetsRoot ?? Path.Combine(AppContext.BaseDirectory, "Assets");
}
public string Root { get; }
public string Logs { get; }
public string Cache { get; }
public string Data { get; }
public string Assets { get; }
public void EnsureCreated()
{
Directory.CreateDirectory(Root);
Directory.CreateDirectory(Logs);
Directory.CreateDirectory(Cache);
Directory.CreateDirectory(Data);
CleanupUserPayloadDirectories(Root);
}
public IReadOnlyList<string> CleanupRuntimePayloadDirectories()
=> CleanupRuntimePayloadDirectories(Root);
public static IReadOnlyList<string> CleanupRuntimePayloadDirectories(string root)
=> CleanupPayloadDirectories(root, RuntimePayloadDirectoryNames);
public IReadOnlyList<string> CleanupUserPayloadDirectories()
=> CleanupUserPayloadDirectories(Root);
public static IReadOnlyList<string> CleanupUserPayloadDirectories(string root)
=> CleanupPayloadDirectories(root, UserPayloadDirectoryNames);
private static IReadOnlyList<string> CleanupPayloadDirectories(string root, IEnumerable<string> names)
{
if (string.IsNullOrWhiteSpace(root))
{
return [];
}
var removed = new List<string>();
string rootFullPath;
try
{
rootFullPath = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
catch
{
return removed;
}
foreach (var name in names)
{
var candidate = Path.Combine(rootFullPath, name);
if (!IsChildPath(rootFullPath, candidate))
{
continue;
}
if (TryDeleteDirectory(candidate))
{
removed.Add(candidate);
}
}
return removed;
}
public static AppPaths ForCurrentUser(string? root = null, string? assetsRoot = null)
{
var appRoot = root ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"YMhut Box",
"WinUI");
var paths = new AppPaths(appRoot, assetsRoot);
paths.EnsureCreated();
return paths;
}
private static bool TryDeleteDirectory(string directory)
{
try
{
if (!Directory.Exists(directory))
{
return false;
}
NormalizeAttributes(directory);
Directory.Delete(directory, recursive: true);
return true;
}
catch
{
return false;
}
}
private static void NormalizeAttributes(string directory)
{
foreach (var path in Directory.EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories))
{
File.SetAttributes(path, FileAttributes.Normal);
}
File.SetAttributes(directory, FileAttributes.Normal);
}
private static bool IsChildPath(string rootFullPath, string candidate)
{
try
{
var childFullPath = Path.GetFullPath(candidate).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return childFullPath.StartsWith(rootFullPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
childFullPath.StartsWith(rootFullPath + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
}
@@ -0,0 +1,120 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
namespace YMhut.Box.Core.App;
public enum IndependentWindowKind
{
Browser,
Media
}
public sealed record IndependentWindowHostOptions(
IndependentWindowKind Kind,
string Title,
string InitialUrl = "",
string Root = "",
string AssetsRoot = "")
{
public const string HostSwitch = "--ymhut-window-host";
public const string KindSwitch = "--kind";
public const string TitleSwitch = "--title";
public const string UrlSwitch = "--url";
public const string RootSwitch = "--root";
public const string AssetsRootSwitch = "--assets-root";
public static IndependentWindowHostOptions Browser(string title, string initialUrl, AppPaths paths)
=> new(IndependentWindowKind.Browser, title, initialUrl, paths.Root, paths.Assets);
public static IndependentWindowHostOptions Media(string title, AppPaths paths)
=> new(IndependentWindowKind.Media, title, string.Empty, paths.Root, paths.Assets);
public IReadOnlyList<string> ToArguments()
{
var args = new List<string>
{
HostSwitch,
KindSwitch,
Kind.ToString().ToLowerInvariant(),
TitleSwitch,
Title
};
AddOptional(args, UrlSwitch, InitialUrl);
AddOptional(args, RootSwitch, Root);
AddOptional(args, AssetsRootSwitch, AssetsRoot);
return new ReadOnlyCollection<string>(args);
}
public static bool TryParse(IEnumerable<string> args, [NotNullWhen(true)] out IndependentWindowHostOptions? options)
{
options = null;
var tokens = args.Where(token => !string.IsNullOrWhiteSpace(token)).ToArray();
if (!tokens.Any(token => string.Equals(token, HostSwitch, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < tokens.Length; index++)
{
var token = tokens[index];
if (!token.StartsWith("--", StringComparison.Ordinal) || string.Equals(token, HostSwitch, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (index + 1 < tokens.Length && !tokens[index + 1].StartsWith("--", StringComparison.Ordinal))
{
map[token] = tokens[++index];
}
}
var kindText = Value(map, KindSwitch);
if (!TryParseKind(kindText, out var kind))
{
return false;
}
var title = Value(map, TitleSwitch);
if (string.IsNullOrWhiteSpace(title))
{
title = kind == IndependentWindowKind.Browser ? "Safe Browser" : "Media Player";
}
options = new IndependentWindowHostOptions(
kind,
title,
Value(map, UrlSwitch),
Value(map, RootSwitch),
Value(map, AssetsRootSwitch));
return true;
}
private static bool TryParseKind(string value, out IndependentWindowKind kind)
{
var normalized = value.Trim().ToLowerInvariant();
kind = normalized switch
{
"browser" or "safe_browser" or "safe-browser" => IndependentWindowKind.Browser,
"media" or "media_player" or "media-player" => IndependentWindowKind.Media,
_ => default
};
return normalized is "browser" or "safe_browser" or "safe-browser" or "media" or "media_player" or "media-player";
}
private static string Value(IReadOnlyDictionary<string, string> map, string key)
=> map.TryGetValue(key, out var value) ? value : string.Empty;
private static void AddOptional(ICollection<string> args, string key, string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
args.Add(key);
args.Add(value);
}
}
@@ -0,0 +1,160 @@
namespace YMhut.Box.Core.App;
public static class InstallLayoutPaths
{
public const string InstallRootEnvironmentVariable = "YMHUT_BOX_INSTALL_ROOT";
public const string ArchivedLayoutEnvironmentVariable = "YMHUT_BOX_ARCHIVED_LAYOUT";
public static string ResolveInstallRoot(string? baseDirectory = null)
{
return ResolveInstallRootContext(baseDirectory).InstallRoot;
}
public static InstallRootContext ResolveInstallRootContext(string? baseDirectory = null)
{
var selected = SelectInstallRoot(baseDirectory);
var installRoot = selected.Path;
var assetsRoot = Path.Combine(installRoot, "Assets");
if (!Directory.Exists(assetsRoot))
{
assetsRoot = CandidateRoots(baseDirectory)
.Select(candidate => Path.Combine(candidate, "Assets"))
.FirstOrDefault(Directory.Exists)
?? Path.Combine(installRoot, "Assets");
}
var manifestPath = Path.Combine(installRoot, InstallManifest.RelativePath.Replace('/', Path.DirectorySeparatorChar));
var manifestIdentity = InstallRootContext.BuildManifestIdentity(installRoot);
return new InstallRootContext(
installRoot,
assetsRoot,
manifestPath,
manifestIdentity,
IsPackagedInstallPath(installRoot),
selected.Source);
}
public static string ResolveInstalledExecutablePath(string? baseDirectory = null)
{
var processPath = Environment.ProcessPath;
if (!string.IsNullOrWhiteSpace(processPath) &&
string.Equals(Path.GetFileName(processPath), "YMhutBox.exe", StringComparison.OrdinalIgnoreCase) &&
File.Exists(processPath))
{
return processPath;
}
foreach (var root in CandidateRoots(baseDirectory))
{
var executable = Path.Combine(root, "YMhutBox.exe");
if (File.Exists(executable))
{
return executable;
}
}
return Environment.ProcessPath ?? Path.Combine(Path.GetFullPath(baseDirectory ?? AppContext.BaseDirectory), "YMhutBox.exe");
}
public static string ResolveAssetsRoot(string? baseDirectory = null)
{
return ResolveInstallRootContext(baseDirectory).AssetsRoot;
}
public static string? ResolveArchivedLayoutRoot()
=> NormalizeCandidate(Environment.GetEnvironmentVariable(ArchivedLayoutEnvironmentVariable));
public static IEnumerable<string> CandidateRoots(string? baseDirectory = null)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var (candidate, _) in RawCandidates(baseDirectory))
{
var fullPath = NormalizeCandidate(candidate);
if (fullPath is not null && seen.Add(fullPath))
{
yield return fullPath;
}
}
}
private static (string Path, string Source) SelectInstallRoot(string? baseDirectory)
{
var candidates = RawCandidates(baseDirectory)
.Select(candidate => (Path: NormalizeCandidate(candidate.Path), candidate.Source))
.Where(candidate => candidate.Path is not null)
.Select(candidate => (Path: candidate.Path!, candidate.Source))
.ToArray();
foreach (var candidate in candidates)
{
if (LooksLikeInstallRoot(candidate.Path))
{
return candidate;
}
}
var explicitRoot = NormalizeCandidate(Environment.GetEnvironmentVariable(InstallRootEnvironmentVariable));
if (explicitRoot is not null && Directory.Exists(explicitRoot))
{
return (explicitRoot, "install-root-env");
}
var firstExisting = candidates.FirstOrDefault(candidate => Directory.Exists(candidate.Path));
if (!string.IsNullOrWhiteSpace(firstExisting.Path))
{
return firstExisting;
}
return (Path.GetFullPath(baseDirectory ?? AppContext.BaseDirectory), "fallback");
}
private static IEnumerable<(string? Path, string Source)> RawCandidates(string? baseDirectory)
{
var processPath = Environment.ProcessPath;
if (!string.IsNullOrWhiteSpace(processPath) &&
string.Equals(Path.GetFileName(processPath), "YMhutBox.exe", StringComparison.OrdinalIgnoreCase))
{
yield return (Path.GetDirectoryName(processPath), "process");
}
yield return (Environment.GetEnvironmentVariable(InstallRootEnvironmentVariable), "install-root-env");
yield return (baseDirectory, "base-directory");
yield return (AppContext.BaseDirectory, "app-context");
}
private static string? NormalizeCandidate(string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return null;
}
try
{
return Path.GetFullPath(candidate.Trim())
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
catch
{
return null;
}
}
private static bool LooksLikeInstallRoot(string root)
{
if (!Directory.Exists(root))
{
return false;
}
return File.Exists(Path.Combine(root, "YMhutBox.exe")) ||
File.Exists(Path.Combine(root, "YMhutBox.dll")) ||
File.Exists(Path.Combine(root, InstallManifest.RelativePath.Replace('/', Path.DirectorySeparatorChar)));
}
private static bool IsPackagedInstallPath(string root)
{
var normalized = root.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
return normalized.Contains($"{Path.DirectorySeparatorChar}WindowsApps{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase);
}
}
+94
View File
@@ -0,0 +1,94 @@
namespace YMhut.Box.Core.App;
public sealed record InstallManifestRelease(
string Version,
string Build,
string Channel,
string PackageVersion);
public sealed record InstallManifest(
InstallManifestRelease? Release,
IReadOnlyList<string> RequiredFiles,
IReadOnlyList<string> Files)
{
public const string RelativePath = "config/install-manifest.ini";
public static InstallManifest Parse(string text)
{
var release = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var requiredFiles = new List<string>();
var files = new List<string>();
var section = string.Empty;
using var reader = new StringReader(text);
while (reader.ReadLine() is { } line)
{
var value = line.Trim();
if (string.IsNullOrWhiteSpace(value) || value.StartsWith(';') || value.StartsWith('#'))
{
continue;
}
if (value.StartsWith('[') && value.EndsWith(']'))
{
section = value[1..^1].Trim();
continue;
}
if (section.Equals("Release", StringComparison.OrdinalIgnoreCase))
{
var separator = value.IndexOf('=');
if (separator <= 0)
{
continue;
}
release[value[..separator].Trim()] = value[(separator + 1)..].Trim();
continue;
}
if (section.Equals("RequiredFiles", StringComparison.OrdinalIgnoreCase))
{
requiredFiles.Add(NormalizeManifestPath(value));
continue;
}
if (section.Equals("Files", StringComparison.OrdinalIgnoreCase))
{
files.Add(NormalizeManifestPath(value));
}
}
InstallManifestRelease? releaseInfo = null;
if (release.Count > 0)
{
releaseInfo = new InstallManifestRelease(
GetReleaseValue(release, "Version"),
GetReleaseValue(release, "Build"),
GetReleaseValue(release, "Channel"),
GetReleaseValue(release, "PackageVersion"));
}
return new InstallManifest(releaseInfo, requiredFiles, files);
}
public static async Task<InstallManifest> ReadAsync(string installRoot, CancellationToken cancellationToken = default)
{
var path = Path.Combine(installRoot, RelativePath.Replace('/', Path.DirectorySeparatorChar));
var text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
return Parse(text);
}
public bool ContainsFile(string relativePath)
{
var normalized = NormalizeManifestPath(relativePath);
return Files.Contains(normalized, StringComparer.OrdinalIgnoreCase) ||
RequiredFiles.Contains(normalized, StringComparer.OrdinalIgnoreCase);
}
private static string GetReleaseValue(IReadOnlyDictionary<string, string> release, string key)
=> release.TryGetValue(key, out var value) ? value : string.Empty;
private static string NormalizeManifestPath(string path)
=> path.Trim().Replace('\\', '/').TrimStart('/');
}
@@ -0,0 +1,54 @@
using System.Globalization;
namespace YMhut.Box.Core.App;
public sealed record InstallRootContext(
string InstallRoot,
string AssetsRoot,
string ManifestPath,
string ManifestIdentity,
bool IsPackaged,
string ResolvedFrom)
{
public string InstallIdentity { get; init; } = BuildInstallIdentity(InstallRoot, ManifestIdentity);
public string CheckBasis => $"installRoot={InstallRoot}; manifest={ManifestIdentity}; source={ResolvedFrom}";
public static string BuildInstallIdentity(string installRoot, string manifestIdentity)
=> $"{NormalizePath(installRoot)}|{manifestIdentity}";
public static string BuildManifestIdentity(string installRoot)
{
var manifestPath = Path.Combine(installRoot, InstallManifest.RelativePath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(manifestPath))
{
return $"no-manifest:{NormalizePath(installRoot)}";
}
try
{
var info = new FileInfo(manifestPath);
var manifest = InstallManifest.Parse(File.ReadAllText(manifestPath));
var release = manifest.Release is null
? "unknown"
: $"{manifest.Release.Version}/{manifest.Release.Build}/{manifest.Release.Channel}/{manifest.Release.PackageVersion}";
return $"{release}:{info.Length}:{info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture)}";
}
catch
{
return $"invalid-manifest:{NormalizePath(manifestPath)}";
}
}
private static string NormalizePath(string path)
{
try
{
return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
catch
{
return path.Trim();
}
}
}
@@ -0,0 +1,315 @@
using System.Xml.Linq;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.Core.App;
public sealed record OpenSourceReferenceItem(
string Name,
string Kind,
string Version,
string Usage,
string Attribution,
string Path = "");
public interface IOpenSourceReferenceService
{
Task<IReadOnlyList<OpenSourceReferenceItem>> GetReferencesAsync(CancellationToken cancellationToken = default);
}
public sealed class OpenSourceReferenceService(AppPaths paths, ILogService? logService = null) : IOpenSourceReferenceService
{
private static readonly StringComparer PathComparer = StringComparer.OrdinalIgnoreCase;
public async Task<IReadOnlyList<OpenSourceReferenceItem>> GetReferencesAsync(CancellationToken cancellationToken = default)
{
var references = new List<OpenSourceReferenceItem>
{
new(
"YMhut Box",
"Application",
string.Empty,
"Primary desktop toolbox application and integration shell.",
"Copyright and branding belong to YMhut / YMhut Box."),
new(
"tubatool reference project",
"Reference project attribution",
string.Empty,
"Toolbox layout, built-in tool behavior, settings interaction, and bundled Tools directory were used as reference material with author permission.",
"Original reference project author attribution is retained here; migrated user-facing branding is YMhut Box."),
new(
".NET",
"Runtime",
"net10.0",
"Application runtime, libraries, file IO, process, JSON, XML, and platform interop.",
"Microsoft and .NET contributors retain their original rights.")
};
try
{
AddPackageReferences(references);
AddBundledToolNotices(references, cancellationToken);
var toolNoticeCount = references.Count(item => item.Kind == "Third-party tool notice");
await WriteLogAsync(
"Information",
"about",
"Open source references loaded",
$"count={references.Count}; toolNotices={toolNoticeCount}",
cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or global::System.Xml.XmlException)
{
await WriteLogAsync(
"Warning",
"about",
"Open source reference scan degraded",
Sanitize(exception.Message),
cancellationToken).ConfigureAwait(false);
}
return references
.OrderBy(item => ReferenceOrder(item.Kind))
.ThenBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
}
private void AddPackageReferences(List<OpenSourceReferenceItem> references)
{
var packages = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var projectFile in FindProjectFiles())
{
var document = XDocument.Load(projectFile);
foreach (var package in document.Descendants("PackageReference"))
{
var name = package.Attribute("Include")?.Value ?? package.Attribute("Update")?.Value;
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
var version = package.Attribute("Version")?.Value
?? package.Element("Version")?.Value
?? string.Empty;
packages[name] = version;
}
}
if (packages.Count == 0)
{
foreach (var package in StaticPackageFallback())
{
packages[package.Name] = package.Version;
}
}
foreach (var package in packages.OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase))
{
references.Add(new OpenSourceReferenceItem(
package.Key,
"NuGet package",
package.Value,
PackageUsage(package.Key),
"Original package license and ownership remain with its maintainers."));
}
}
private void AddBundledToolNotices(List<OpenSourceReferenceItem> references, CancellationToken cancellationToken)
{
var toolsRoot = FindToolsRoot();
if (string.IsNullOrWhiteSpace(toolsRoot) || !Directory.Exists(toolsRoot))
{
references.Add(new OpenSourceReferenceItem(
"Bundled third-party Tools",
"Third-party tool notice",
string.Empty,
"Tools directory was not found in the current runtime layout.",
"Third-party tool declarations are preserved when the Tools directory is present."));
return;
}
var files = Directory.EnumerateFiles(toolsRoot, "*.*", SearchOption.AllDirectories)
.Where(IsNoticeFile)
.Take(120)
.ToArray();
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
references.Add(new OpenSourceReferenceItem(
ToolNoticeName(toolsRoot, file),
"Third-party tool notice",
string.Empty,
"Bundled external tool notice. Open the original file for full license, EULA, README, or copyright text.",
"Original third-party declaration file is kept unchanged on disk.",
file));
}
}
private IEnumerable<string> FindProjectFiles()
{
var seen = new HashSet<string>(PathComparer);
foreach (var root in CandidateRoots())
{
if (!Directory.Exists(root))
{
continue;
}
foreach (var project in Directory.EnumerateFiles(root, "*.csproj", SearchOption.TopDirectoryOnly).Take(12))
{
if (seen.Add(project))
{
yield return project;
}
}
var src = Path.Combine(root, "src");
if (!Directory.Exists(src))
{
continue;
}
foreach (var project in Directory.EnumerateFiles(src, "*.csproj", SearchOption.AllDirectories).Take(24))
{
if (seen.Add(project))
{
yield return project;
}
}
}
}
private string FindToolsRoot()
{
foreach (var root in CandidateRoots())
{
var direct = Path.Combine(root, "Tools");
if (Directory.Exists(direct))
{
return direct;
}
foreach (var child in SafeEnumerateDirectories(root))
{
if (!Path.GetFileName(child).Contains("tubatool", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var tools = Path.Combine(child, "Tools");
if (Directory.Exists(tools))
{
return tools;
}
}
}
return string.Empty;
}
private IEnumerable<string> CandidateRoots()
{
foreach (var root in InstallLayoutPaths.CandidateRoots())
{
yield return root;
}
yield return paths.Root;
yield return paths.Assets;
foreach (var root in InstallLayoutPaths.CandidateRoots())
{
var directory = new DirectoryInfo(root);
for (var depth = 0; depth < 8 && directory is not null; depth++, directory = directory.Parent)
{
yield return directory.FullName;
}
}
}
private static IEnumerable<string> SafeEnumerateDirectories(string root)
{
try
{
return Directory.Exists(root) ? Directory.EnumerateDirectories(root).Take(40).ToArray() : [];
}
catch
{
return [];
}
}
private static bool IsNoticeFile(string path)
{
var name = Path.GetFileName(path);
return name.Contains("license", StringComparison.OrdinalIgnoreCase)
|| name.Contains("readme", StringComparison.OrdinalIgnoreCase)
|| name.Contains("eula", StringComparison.OrdinalIgnoreCase)
|| name.Contains("copyright", StringComparison.OrdinalIgnoreCase)
|| name.Contains("copying", StringComparison.OrdinalIgnoreCase)
|| name.Contains("notice", StringComparison.OrdinalIgnoreCase);
}
private static string ToolNoticeName(string toolsRoot, string file)
{
var relative = Path.GetRelativePath(toolsRoot, file);
var parts = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (parts.Length >= 3)
{
return $"{parts[1]} / {parts[^1]}";
}
return relative;
}
private static IEnumerable<(string Name, string Version)> StaticPackageFallback()
{
yield return ("Microsoft.Data.Sqlite", "10.0.0");
yield return ("Microsoft.Extensions.DependencyInjection", "10.0.0");
yield return ("Microsoft.Web.WebView2", "1.0.3967.48");
yield return ("Microsoft.WindowsAppSDK", "1.8.260416003");
yield return ("QRCoder", "1.8.0");
yield return ("System.Drawing.Common", "10.0.0");
yield return ("System.Management", "10.0.0");
yield return ("ZXing.Net", "0.16.11");
}
private static string PackageUsage(string packageName)
{
return packageName switch
{
"Microsoft.WindowsAppSDK" => "WinUI 3 desktop shell, windows, controls, app lifecycle, and packaging support.",
"Microsoft.Web.WebView2" => "Embedded web content for browser, plugin, and preview surfaces.",
"Microsoft.Extensions.DependencyInjection" => "Application service registration and dependency injection.",
"Microsoft.Data.Sqlite" => "Local SQLite logging and app data persistence.",
"QRCoder" => "QR code generation tools.",
"ZXing.Net" => "Barcode and QR decoding helpers.",
"System.Drawing.Common" => "EXE and shortcut icon extraction and image conversion for external Tools.",
"System.Management" => "WMI hardware and system information queries.",
_ => "Application dependency used by YMhut Box."
};
}
private static int ReferenceOrder(string kind)
{
return kind switch
{
"Application" => 0,
"Reference project attribution" => 1,
"Runtime" => 2,
"NuGet package" => 3,
_ => 4
};
}
private static string Sanitize(string value)
{
return value
.Replace(Environment.UserName, "<user>", StringComparison.OrdinalIgnoreCase)
.Replace(AppContext.BaseDirectory, "<app>", StringComparison.OrdinalIgnoreCase);
}
private Task WriteLogAsync(string level, string category, string message, string? detail, CancellationToken cancellationToken)
{
return logService?.WriteAsync(level, category, message, detail, cancellationToken) ?? Task.CompletedTask;
}
}
@@ -0,0 +1,50 @@
namespace YMhut.Box.Core.App;
public static class RuntimeLayoutPolicy
{
public const string ReadyMarker = ".ymhut-runtime-layout";
private static readonly string[] ExcludedRootDirectories =
[
"Tools",
"Metadata",
"data",
"FeedbackPackages",
"Assets",
"config",
"download-host",
"plugin-host",
"prereqs",
"updater",
"worker"
];
public static bool ShouldSkipRelativePath(string relativePath)
{
var normalized = relativePath.Replace('\\', '/').Trim('/');
var firstSegment = normalized.Split('/', 2, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
return firstSegment is not null &&
ExcludedRootDirectories.Any(excluded => string.Equals(firstSegment, excluded, StringComparison.OrdinalIgnoreCase));
}
public static bool ShouldCopyFile(string relativePath)
{
return !ShouldSkipRelativePath(relativePath) && !ShouldSkipFile(relativePath);
}
public static bool ShouldSkipFile(string relativePath)
{
var normalized = relativePath.Replace('\\', '/').Trim('/');
var fileName = Path.GetFileName(relativePath);
return fileName.Equals(ReadyMarker, StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("unins", StringComparison.OrdinalIgnoreCase) ||
fileName.Equals("YMhutBox.exe", StringComparison.OrdinalIgnoreCase) ||
fileName.Equals("YMhutBox.dll", StringComparison.OrdinalIgnoreCase) ||
fileName.Equals("resources.pri", StringComparison.OrdinalIgnoreCase) ||
fileName.EndsWith(".mui", StringComparison.OrdinalIgnoreCase) ||
fileName.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("lang/", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("Microsoft.UI.Xaml/", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("runtimes/", StringComparison.OrdinalIgnoreCase);
}
}
+4
View File
@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("YMhut.Box.Tests")]
[assembly: InternalsVisibleTo("YMhutBox")]
@@ -0,0 +1,225 @@
using System.Text.Json;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using YMhut.Box.Core.App;
namespace YMhut.Box.Core.Data;
public sealed record ReferenceDataValidationResult(
IReadOnlyList<string> PresentPaths,
IReadOnlyList<string> MissingPaths)
{
public bool IsHealthy => MissingPaths.Count == 0;
}
public interface IReferenceDataService
{
Task<string?> ReadTextAsync(string relativePath, CancellationToken cancellationToken = default);
Task<T?> ReadJsonAsync<T>(string relativePath, CancellationToken cancellationToken = default);
bool Exists(string relativePath);
IReadOnlyList<string> EnumerateFiles(string relativeDirectory, string searchPattern = "*.*");
ReferenceDataValidationResult ValidateRequiredAssets(params string[] relativePaths);
}
public sealed class ReferenceDataService(AppPaths paths) : IReferenceDataService
{
private static readonly byte[] YbinMagic = Encoding.ASCII.GetBytes("YMHUTYBIN1");
private static readonly byte[] YbinKey = SHA256.HashData(Encoding.UTF8.GetBytes("YMhut.Box.ReferenceData.YBin.v1"));
public async Task<string?> ReadTextAsync(string relativePath, CancellationToken cancellationToken = default)
{
var fullPath = ResolveAssetPath(relativePath);
if (File.Exists(fullPath))
{
return await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
}
return await ReadFromDatAsync(relativePath, cancellationToken).ConfigureAwait(false);
}
public async Task<T?> ReadJsonAsync<T>(string relativePath, CancellationToken cancellationToken = default)
{
var text = await ReadTextAsync(relativePath, cancellationToken).ConfigureAwait(false);
return string.IsNullOrWhiteSpace(text)
? default
: JsonSerializer.Deserialize<T>(text, new JsonSerializerOptions(JsonSerializerDefaults.Web));
}
public bool Exists(string relativePath)
{
return File.Exists(ResolveAssetPath(relativePath)) || DatContains(relativePath);
}
public IReadOnlyList<string> EnumerateFiles(string relativeDirectory, string searchPattern = "*.*")
{
var directory = ResolveAssetDirectory(relativeDirectory);
var physicalFiles = Directory.Exists(directory)
? Directory.EnumerateFiles(directory, searchPattern, SearchOption.AllDirectories)
.Select(path => Path.GetRelativePath(directory, path))
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray()
: [];
var datFiles = EnumerateDatFiles(relativeDirectory, searchPattern);
return physicalFiles
.Concat(datFiles)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public ReferenceDataValidationResult ValidateRequiredAssets(params string[] relativePaths)
{
var present = new List<string>();
var missing = new List<string>();
foreach (var path in relativePaths)
{
if (Exists(path))
{
present.Add(path);
}
else
{
missing.Add(path);
}
}
return new ReferenceDataValidationResult(present, missing);
}
private string ResolveAssetPath(string relativePath)
{
var normalized = relativePath.Replace('/', Path.DirectorySeparatorChar).TrimStart(Path.DirectorySeparatorChar);
var assetPath = Path.Combine(paths.Assets, normalized);
if (File.Exists(assetPath))
{
return assetPath;
}
return Path.Combine(AppContext.BaseDirectory, normalized);
}
private string ResolveAssetDirectory(string relativeDirectory)
{
var normalized = relativeDirectory.Replace('/', Path.DirectorySeparatorChar).TrimStart(Path.DirectorySeparatorChar);
var assetDirectory = Path.Combine(paths.Assets, normalized);
if (Directory.Exists(assetDirectory))
{
return assetDirectory;
}
return Path.Combine(AppContext.BaseDirectory, normalized);
}
private async Task<string?> ReadFromDatAsync(string relativePath, CancellationToken cancellationToken)
{
var entryName = NormalizeEntryName(relativePath);
foreach (var package in DatCandidates())
{
if (!File.Exists(package))
{
continue;
}
using var archive = OpenPackage(package);
var entry = archive.GetEntry(entryName);
if (entry is null)
{
continue;
}
await using var stream = entry.Open();
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
}
return null;
}
private bool DatContains(string relativePath)
{
var entryName = NormalizeEntryName(relativePath);
foreach (var package in DatCandidates())
{
if (!File.Exists(package))
{
continue;
}
using var archive = OpenPackage(package);
if (archive.GetEntry(entryName) is not null)
{
return true;
}
}
return false;
}
private IReadOnlyList<string> EnumerateDatFiles(string relativeDirectory, string searchPattern)
{
var prefix = NormalizeEntryName(relativeDirectory).TrimEnd('/') + "/";
var extension = searchPattern.StartsWith("*.") ? searchPattern[1..] : string.Empty;
var results = new List<string>();
foreach (var package in DatCandidates())
{
if (!File.Exists(package))
{
continue;
}
using var archive = OpenPackage(package);
results.AddRange(archive.Entries
.Where(entry => entry.FullName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Where(entry => string.IsNullOrWhiteSpace(extension) || entry.FullName.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
.Select(entry => entry.FullName[prefix.Length..]));
}
return results;
}
private IEnumerable<string> DatCandidates()
{
yield return Path.Combine(paths.Assets, "data", "ymhut-data.ybin");
yield return Path.Combine(AppContext.BaseDirectory, "Assets", "data", "ymhut-data.ybin");
yield return Path.Combine(AppContext.BaseDirectory, "resources", "ymhut-data.ybin");
yield return Path.Combine(paths.Assets, "data", "reference-data.dat");
yield return Path.Combine(AppContext.BaseDirectory, "Assets", "data", "reference-data.dat");
yield return Path.Combine(AppContext.BaseDirectory, "resources", "reference-data.dat");
}
private static string NormalizeEntryName(string relativePath)
{
return relativePath.Replace('\\', '/').TrimStart('/');
}
private static ZipArchive OpenPackage(string package)
{
if (package.EndsWith(".ybin", StringComparison.OrdinalIgnoreCase))
{
var encrypted = File.ReadAllBytes(package);
if (encrypted.Length <= YbinMagic.Length + 16 ||
!encrypted.AsSpan(0, YbinMagic.Length).SequenceEqual(YbinMagic))
{
throw new InvalidDataException("Reference data package has an invalid header.");
}
var iv = encrypted.AsSpan(YbinMagic.Length, 16).ToArray();
var cipherText = encrypted.AsSpan(YbinMagic.Length + 16).ToArray();
using var aes = Aes.Create();
aes.Key = YbinKey;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var decryptor = aes.CreateDecryptor();
var payload = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length);
return new ZipArchive(new MemoryStream(payload), ZipArchiveMode.Read, leaveOpen: false);
}
return ZipFile.OpenRead(package);
}
}
@@ -0,0 +1,727 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Downloads;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Net;
namespace YMhut.Box.Core.DevEnvironments;
public enum DevEnvironmentInstallMode
{
QuickInstall,
SourceBuild
}
public sealed record DevEnvironmentDefinition(
string Id,
string Name,
string CommandName,
string VersionArgument,
string OfficialHome,
string VersionSource,
string WindowsAssetHint,
string SourceAssetHint = "source")
{
public static IReadOnlyList<DevEnvironmentDefinition> Defaults { get; } =
[
new("go", "Go", "go", "version", "https://go.dev/dl/", "https://go.dev/dl/?mode=json", "windows-amd64.msi", "src.tar.gz"),
new("python", "Python", "python", "--version", "https://www.python.org/downloads/windows/", "https://www.python.org/ftp/python/", "amd64.exe", "tgz"),
new("java", "Java / Temurin JDK", "java", "-version", "https://adoptium.net/temurin/releases/", "https://api.adoptium.net/v3/assets/latest/21/hotspot?os=windows&architecture=x64&image_type=jdk", "jdk_x64_windows_hotspot", "sources"),
new("docker", "Docker Desktop", "docker", "--version", "https://www.docker.com/products/docker-desktop/", "https://desktop.docker.com/win/main/amd64/appcast.xml", "Docker Desktop Installer.exe"),
new("mysql", "MySQL", "mysql", "--version", "https://dev.mysql.com/downloads/mysql/", "https://dev.mysql.com/downloads/mysql/", "mysql-installer"),
new("node", "Node.js", "node", "--version", "https://nodejs.org/en/download", "https://nodejs.org/dist/index.json", "win-x64.msi", "tar.gz"),
new("dotnet", ".NET SDK", "dotnet", "--list-sdks", "https://dotnet.microsoft.com/download", "https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/releases-index.json", "win-x64.exe", "source"),
new("git", "Git", "git", "--version", "https://git-scm.com/download/win", "https://api.github.com/repos/git-for-windows/git/releases", "64-bit.exe", "tar.gz"),
new("rust", "Rust", "rustc", "--version", "https://www.rust-lang.org/tools/install", "https://static.rust-lang.org/dist/channel-rust-stable.toml", "rustup-init.exe", "tar.gz"),
new("cmake", "CMake", "cmake", "--version", "https://cmake.org/download/", "https://api.github.com/repos/Kitware/CMake/releases", "windows-x86_64.msi", "tar.gz")
];
}
public sealed record DetectedDevEnvironment(
string Id,
string Name,
bool IsInstalled,
string Version = "",
string Path = "",
string Source = "",
string Error = "");
public sealed record DevEnvironmentDownloadCandidate(
string Id,
string DisplayName,
DownloadSource Source,
DevEnvironmentInstallMode Mode,
string SourceType = "Official",
string Region = "",
bool IsDefault = false);
public sealed record DevEnvironmentVersion(
string Version,
DownloadSource? Installer,
DownloadSource? SourceArchive,
DateTimeOffset? PublishedAt = null,
string ReleaseNotesUrl = "",
IReadOnlyList<DevEnvironmentDownloadCandidate>? Candidates = null)
{
public IReadOnlyList<DevEnvironmentDownloadCandidate> AllCandidates
{
get
{
if (Candidates is { Count: > 0 })
{
return Candidates;
}
var list = new List<DevEnvironmentDownloadCandidate>();
if (Installer is not null)
{
list.Add(new DevEnvironmentDownloadCandidate("installer", Installer.DisplayName, Installer, DevEnvironmentInstallMode.QuickInstall, Installer.SourceKind, Installer.MirrorRegion, true));
}
if (SourceArchive is not null)
{
list.Add(new DevEnvironmentDownloadCandidate("source", SourceArchive.DisplayName, SourceArchive, DevEnvironmentInstallMode.SourceBuild, SourceArchive.SourceKind, SourceArchive.MirrorRegion));
}
return list;
}
}
}
public sealed record DevEnvironmentBuildRecipe(
string EnvironmentId,
string Summary,
IReadOnlyList<string> Prerequisites,
IReadOnlyList<string> Commands,
bool IsSupported = true);
public sealed record DevEnvironmentBuildSession(
string Id,
string EnvironmentId,
string WorkingDirectory,
string SourceArchivePath,
DevEnvironmentBuildRecipe Recipe,
DateTimeOffset CreatedAt);
public sealed record DevEnvironmentInstallPlan(
string EnvironmentId,
string EnvironmentName,
DevEnvironmentVersion Version,
DevEnvironmentInstallMode Mode,
string BuildRecipe = "",
string InstallCommand = "",
string InstallArguments = "");
public interface IDevEnvironmentDetectionService
{
Task<IReadOnlyList<DetectedDevEnvironment>> DetectAsync(CancellationToken cancellationToken = default);
}
public interface IDevEnvironmentCatalogService
{
IReadOnlyList<DevEnvironmentDefinition> Definitions { get; }
Task<IReadOnlyList<DevEnvironmentVersion>> GetVersionsAsync(string environmentId, CancellationToken cancellationToken = default);
DevEnvironmentInstallPlan CreateInstallPlan(string environmentId, DevEnvironmentVersion version, DevEnvironmentInstallMode mode);
DevEnvironmentInstallPlan CreateInstallPlan(string environmentId, DevEnvironmentVersion version, DevEnvironmentDownloadCandidate candidate);
}
public sealed class DevEnvironmentDetectionService(ILogService? logService = null) : IDevEnvironmentDetectionService
{
public async Task<IReadOnlyList<DetectedDevEnvironment>> DetectAsync(CancellationToken cancellationToken = default)
{
var tasks = DevEnvironmentDefinition.Defaults.Select(definition => DetectOneAsync(definition, cancellationToken));
return await Task.WhenAll(tasks).ConfigureAwait(false);
}
private async Task<DetectedDevEnvironment> DetectOneAsync(DevEnvironmentDefinition definition, CancellationToken cancellationToken)
{
try
{
var result = await RunProcessAsync(definition.CommandName, definition.VersionArgument, cancellationToken).ConfigureAwait(false);
if (result.ExitCode != 0 && string.IsNullOrWhiteSpace(result.Output))
{
return new DetectedDevEnvironment(definition.Id, definition.Name, false, Error: result.Error);
}
var version = DevEnvironmentVersionParser.Parse(definition.Id, result.Output);
var path = await ResolveCommandPathAsync(definition.CommandName, cancellationToken).ConfigureAwait(false);
return new DetectedDevEnvironment(definition.Id, definition.Name, true, version, path, "PATH");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
await (logService?.WriteAsync("Debug", "dev-env", $"Detect {definition.Id} failed", exception.Message, cancellationToken) ?? Task.CompletedTask)
.ConfigureAwait(false);
return new DetectedDevEnvironment(definition.Id, definition.Name, false, Error: exception.Message);
}
}
private static async Task<(int ExitCode, string Output, string Error)> RunProcessAsync(string fileName, string arguments, CancellationToken cancellationToken)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
},
EnableRaisingEvents = true
};
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
var output = (await outputTask.ConfigureAwait(false) + Environment.NewLine + await errorTask.ConfigureAwait(false)).Trim();
return (process.ExitCode, output, string.Empty);
}
private static async Task<string> ResolveCommandPathAsync(string command, CancellationToken cancellationToken)
{
try
{
var result = await RunProcessAsync("where.exe", command, cancellationToken).ConfigureAwait(false);
return result.Output
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault() ?? string.Empty;
}
catch
{
return string.Empty;
}
}
}
public static partial class DevEnvironmentVersionParser
{
public static string Parse(string id, string output)
{
if (string.IsNullOrWhiteSpace(output))
{
return string.Empty;
}
return id switch
{
"go" => Match(output, @"go(?:\s+version)?\s+go(?<v>[0-9][^\s]+)"),
"python" => Match(output, @"Python\s+(?<v>[0-9][^\s]+)"),
"java" => Match(output, @"(?:openjdk|java)\s+version\s+""(?<v>[^""]+)""", RegexOptions.IgnoreCase),
"docker" => Match(output, @"Docker version\s+(?<v>[0-9][^,\s]+)"),
"mysql" => Match(output, @"Distrib\s+(?<v>[0-9][^,\s]+)|Ver\s+(?<v2>[0-9][^\s]+)"),
"node" => Match(output, @"v(?<v>[0-9][^\s]+)"),
"dotnet" => output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? string.Empty,
"git" => Match(output, @"git version\s+(?<v>[0-9][^\s]+)"),
"rust" => Match(output, @"rustc\s+(?<v>[0-9][^\s]+)"),
"cmake" => Match(output, @"cmake version\s+(?<v>[0-9][^\s]+)"),
_ => output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? string.Empty
};
}
private static string Match(string output, string pattern, RegexOptions options = RegexOptions.None)
{
var match = Regex.Match(output, pattern, options | RegexOptions.CultureInvariant);
if (!match.Success)
{
return output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? string.Empty;
}
return FirstGroup(match, "v", "v2");
}
private static string FirstGroup(Match match, params string[] names)
{
foreach (var name in names)
{
if (match.Groups[name] is { Success: true } group)
{
return group.Value;
}
}
return string.Empty;
}
}
public sealed class DevEnvironmentCatalogService(
AppPaths paths,
IHttpService? httpService = null,
ILogService? logService = null) : IDevEnvironmentCatalogService
{
public IReadOnlyList<DevEnvironmentDefinition> Definitions => DevEnvironmentDefinition.Defaults;
public async Task<IReadOnlyList<DevEnvironmentVersion>> GetVersionsAsync(string environmentId, CancellationToken cancellationToken = default)
{
var definition = Definitions.FirstOrDefault(item => string.Equals(item.Id, environmentId, StringComparison.OrdinalIgnoreCase));
if (definition is null)
{
return [];
}
var cached = await TryReadCacheAsync(environmentId, cancellationToken).ConfigureAwait(false);
if (cached.Count > 0)
{
return cached;
}
try
{
var versions = await FetchVersionsAsync(definition, cancellationToken).ConfigureAwait(false);
if (versions.Count > 0)
{
await WriteCacheAsync(environmentId, versions, cancellationToken).ConfigureAwait(false);
return versions;
}
}
catch (Exception exception)
{
await (logService?.WriteAsync("Warning", "dev-env", $"Fetch versions failed: {environmentId}", exception.Message, cancellationToken) ?? Task.CompletedTask)
.ConfigureAwait(false);
}
return OfflineFallback(definition);
}
public DevEnvironmentInstallPlan CreateInstallPlan(string environmentId, DevEnvironmentVersion version, DevEnvironmentInstallMode mode)
{
var candidate = version.AllCandidates.FirstOrDefault(item => item.Mode == mode) ??
version.AllCandidates.FirstOrDefault();
if (candidate is not null)
{
return CreateInstallPlan(environmentId, version, candidate);
}
var source = mode == DevEnvironmentInstallMode.SourceBuild
? version.SourceArchive ?? version.Installer
: version.Installer ?? version.SourceArchive;
candidate = source is null
? new DevEnvironmentDownloadCandidate("manual", version.Version, new DownloadSource(string.Empty, version.Version, "download.url"), mode)
: new DevEnvironmentDownloadCandidate(mode == DevEnvironmentInstallMode.SourceBuild ? "source" : "installer", source.DisplayName, source, mode);
return CreateInstallPlan(environmentId, version, candidate);
}
public DevEnvironmentInstallPlan CreateInstallPlan(string environmentId, DevEnvironmentVersion version, DevEnvironmentDownloadCandidate candidate)
{
var definition = Definitions.FirstOrDefault(item => string.Equals(item.Id, environmentId, StringComparison.OrdinalIgnoreCase))
?? new DevEnvironmentDefinition(environmentId, environmentId, environmentId, "--version", string.Empty, string.Empty, string.Empty);
if (candidate.Mode == DevEnvironmentInstallMode.SourceBuild)
{
var recipe = BuildRecipe(definition);
return new DevEnvironmentInstallPlan(
definition.Id,
definition.Name,
version,
candidate.Mode,
string.Join(Environment.NewLine, recipe.Commands.Prepend(recipe.Summary)),
"cmd.exe",
"/k echo Build recipe is ready. Download the source archive first, then follow the listed commands.");
}
return new DevEnvironmentInstallPlan(
definition.Id,
definition.Name,
version,
candidate.Mode,
InstallCommand: "installer",
InstallArguments: string.Empty);
}
private async Task<IReadOnlyList<DevEnvironmentVersion>> FetchVersionsAsync(DevEnvironmentDefinition definition, CancellationToken cancellationToken)
{
if (httpService is null)
{
return OfflineFallback(definition);
}
var response = await httpService.GetAsync(new Uri(definition.VersionSource), cancellationToken).ConfigureAwait(false);
return definition.Id switch
{
"go" => ParseGo(definition, response.Content),
"node" => ParseNode(definition, response.Content),
"dotnet" => ParseDotNet(definition, response.Content),
"git" => ParseGitHub(definition, response.Content, "Git for Windows", static name =>
name.EndsWith("64-bit.exe", StringComparison.OrdinalIgnoreCase) ||
name.Contains("64-bit.exe", StringComparison.OrdinalIgnoreCase)),
"cmake" => ParseGitHub(definition, response.Content, "CMake", static name =>
name.Contains("windows-x86_64.msi", StringComparison.OrdinalIgnoreCase) ||
name.Contains("windows-x86_64.zip", StringComparison.OrdinalIgnoreCase)),
"rust" => ParseRust(definition, response.Content),
"python" => ParsePython(definition, response.Content),
"java" => ParseAdoptium(definition, response.Content),
_ => OfflineFallback(definition)
};
}
private async Task<IReadOnlyList<DevEnvironmentVersion>> TryReadCacheAsync(string environmentId, CancellationToken cancellationToken)
{
try
{
var path = CachePath(environmentId);
if (!File.Exists(path) || DateTimeOffset.UtcNow - File.GetLastWriteTimeUtc(path) > TimeSpan.FromHours(6))
{
return [];
}
await using var stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<List<DevEnvironmentVersion>>(stream, cancellationToken: cancellationToken).ConfigureAwait(false) ?? [];
}
catch
{
return [];
}
}
private async Task WriteCacheAsync(string environmentId, IReadOnlyList<DevEnvironmentVersion> versions, CancellationToken cancellationToken)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(CachePath(environmentId))!);
await using var stream = File.Create(CachePath(environmentId));
await JsonSerializer.SerializeAsync(stream, versions.Take(30).ToArray(), cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch
{
}
}
private string CachePath(string environmentId)
{
return Path.Combine(paths.Cache, "DevEnvironments", $"{environmentId}.json");
}
private static IReadOnlyList<DevEnvironmentVersion> ParseGo(DevEnvironmentDefinition definition, string json)
{
using var document = JsonDocument.Parse(json);
var results = new List<DevEnvironmentVersion>();
foreach (var release in document.RootElement.EnumerateArray().Take(12))
{
var version = release.GetProperty("version").GetString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(version) || !release.TryGetProperty("files", out var files))
{
continue;
}
DownloadSource? installer = null;
DownloadSource? source = null;
foreach (var file in files.EnumerateArray())
{
var name = file.GetProperty("filename").GetString() ?? string.Empty;
var url = $"https://go.dev/dl/{name}";
if (name.Contains("windows-amd64.msi", StringComparison.OrdinalIgnoreCase))
{
installer = OfficialSource(url, $"{definition.Name} {version}", name, Sha256: GetString(file, "sha256"), sizeBytes: GetLong(file, "size"));
}
else if (name.Contains("src.tar.gz", StringComparison.OrdinalIgnoreCase))
{
source = OfficialSource(url, $"{definition.Name} {version} source", name, Sha256: GetString(file, "sha256"), sizeBytes: GetLong(file, "size"));
}
}
var cleanVersion = version.TrimStart('g', 'o');
results.Add(WithCandidates(definition.Id, new DevEnvironmentVersion(cleanVersion, installer, source)));
}
return results;
}
private static IReadOnlyList<DevEnvironmentVersion> ParseNode(DevEnvironmentDefinition definition, string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.EnumerateArray()
.Take(20)
.Select(item =>
{
var version = (item.GetProperty("version").GetString() ?? string.Empty).TrimStart('v');
var installerName = $"node-v{version}-x64.msi";
var sourceName = $"node-v{version}.tar.gz";
return new DevEnvironmentVersion(
version,
OfficialSource($"https://nodejs.org/dist/v{version}/{installerName}", $"{definition.Name} {version}", installerName),
OfficialSource($"https://nodejs.org/dist/v{version}/{sourceName}", $"{definition.Name} {version} source", sourceName),
TryDate(GetString(item, "date")));
})
.Select(version => WithCandidates(definition.Id, version))
.ToArray();
}
private static IReadOnlyList<DevEnvironmentVersion> ParseDotNet(DevEnvironmentDefinition definition, string json)
{
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("releases-index", out var releases))
{
return [];
}
return releases.EnumerateArray()
.Take(10)
.Select(item =>
{
var version = GetString(item, "latest-sdk");
var channel = GetString(item, "channel-version");
var installerName = $"dotnet-sdk-{version}-win-x64.exe";
var url = $"https://dotnet.microsoft.com/download/dotnet/thank-you/sdk-{version}-windows-x64-installer";
return new DevEnvironmentVersion(
string.IsNullOrWhiteSpace(version) ? channel : version,
OfficialSource(url, $"{definition.Name} {version}", installerName),
null,
TryDate(GetString(item, "release-date")));
})
.Where(item => !string.IsNullOrWhiteSpace(item.Version))
.Select(version => WithCandidates(definition.Id, version))
.ToArray();
}
private static IReadOnlyList<DevEnvironmentVersion> ParseGitHub(
DevEnvironmentDefinition definition,
string json,
string displayPrefix,
Func<string, bool> installerPredicate)
{
using var document = JsonDocument.Parse(json);
var releases = document.RootElement.ValueKind == JsonValueKind.Array
? document.RootElement.EnumerateArray()
: [];
return releases
.Take(20)
.Select(release =>
{
var tag = GetString(release, "tag_name");
var version = tag.TrimStart('v');
var published = TryDate(GetString(release, "published_at"));
DownloadSource? installer = null;
DownloadSource? source = null;
if (release.TryGetProperty("assets", out var assets) && assets.ValueKind == JsonValueKind.Array)
{
foreach (var asset in assets.EnumerateArray())
{
var name = GetString(asset, "name");
var url = GetString(asset, "browser_download_url");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
{
continue;
}
if (installer is null && installerPredicate(name))
{
installer = new DownloadSource(url, $"{displayPrefix} {version}", name, "GitHub", SourceLabel: "GitHub", SizeBytes: GetLong(asset, "size"));
}
else if (source is null && (name.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)))
{
source = new DownloadSource(url, $"{displayPrefix} {version} source", name, "GitHub", SourceLabel: "GitHub", SizeBytes: GetLong(asset, "size"));
}
}
}
var notes = GetString(release, "html_url");
return WithCandidates(definition.Id, new DevEnvironmentVersion(version, installer, source, published, notes));
})
.Where(item => !string.IsNullOrWhiteSpace(item.Version) && item.AllCandidates.Count > 0)
.ToArray();
}
private static IReadOnlyList<DevEnvironmentVersion> ParseRust(DevEnvironmentDefinition definition, string _)
{
var installer = OfficialSource(
"https://win.rustup.rs/x86_64",
"Rust stable rustup-init",
"rustup-init.exe");
return [WithCandidates(definition.Id, new DevEnvironmentVersion("stable", installer, null, ReleaseNotesUrl: definition.OfficialHome))];
}
private static IReadOnlyList<DevEnvironmentVersion> ParsePython(DevEnvironmentDefinition definition, string _)
{
var versions = new[] { "3.13.1", "3.12.8", "3.11.11", "3.10.16" };
return versions.Select(version =>
{
var installerName = $"python-{version}-amd64.exe";
var installer = OfficialSource(
$"https://www.python.org/ftp/python/{version}/{installerName}",
$"Python {version}",
installerName);
var sourceName = $"Python-{version}.tgz";
var source = OfficialSource(
$"https://www.python.org/ftp/python/{version}/{sourceName}",
$"Python {version} source",
sourceName);
return WithCandidates(definition.Id, new DevEnvironmentVersion(version, installer, source, ReleaseNotesUrl: definition.OfficialHome));
}).ToArray();
}
private static IReadOnlyList<DevEnvironmentVersion> ParseAdoptium(DevEnvironmentDefinition definition, string json)
{
try
{
using var document = JsonDocument.Parse(json);
var versions = new List<DevEnvironmentVersion>();
foreach (var asset in document.RootElement.EnumerateArray().Take(12))
{
var version = asset.TryGetProperty("version", out var versionElement)
? GetString(versionElement, "semver")
: string.Empty;
if (!asset.TryGetProperty("binary", out var binary) ||
!binary.TryGetProperty("package", out var package))
{
continue;
}
var link = GetString(package, "link");
var name = Path.GetFileName(new Uri(link).LocalPath);
if (string.IsNullOrWhiteSpace(version) || string.IsNullOrWhiteSpace(link) || string.IsNullOrWhiteSpace(name))
{
continue;
}
var installer = OfficialSource(link, $"{definition.Name} {version}", name, Sha256: GetString(package, "checksum"), sizeBytes: GetLong(package, "size"));
versions.Add(WithCandidates(definition.Id, new DevEnvironmentVersion(version, installer, null)));
}
return versions.Count > 0 ? versions : OfflineFallback(definition);
}
catch
{
return OfflineFallback(definition);
}
}
private static IReadOnlyList<DevEnvironmentVersion> OfflineFallback(DevEnvironmentDefinition definition)
{
var source = new DownloadSource(definition.OfficialHome, $"{definition.Name} official downloads", $"{definition.Id}-official-download.url", "Manual", SourceLabel: "Manual");
return [WithCandidates(definition.Id, new DevEnvironmentVersion("Latest", source, null, ReleaseNotesUrl: definition.OfficialHome))];
}
private static DevEnvironmentBuildRecipe BuildRecipe(DevEnvironmentDefinition definition)
{
return definition.Id switch
{
"go" => new(definition.Id, "Build Go from source after installing a bootstrap Go toolchain.", ["Go bootstrap toolchain", "Git", "Visual Studio Build Tools"], ["tar -xf <archive>", "cd go\\src", "make.bat"]),
"python" => new(definition.Id, "Build CPython from source.", ["Visual Studio Build Tools", "Python dependencies"], ["tar -xf <archive>", "cd Python-*", "PCbuild\\build.bat -p x64"]),
"java" => new(definition.Id, "Build OpenJDK/Temurin from source.", ["Boot JDK", "Visual Studio Build Tools", "Cygwin or MSYS2 where required"], ["tar -xf <archive>", "bash configure", "make images"]),
"node" => new(definition.Id, "Build Node.js from source.", ["Python", "Visual Studio Build Tools", "Git"], ["tar -xf <archive>", "cd node-*", "vcbuild.bat release x64"]),
"mysql" => new(definition.Id, "Build MySQL from source.", ["CMake", "Visual Studio Build Tools", "Bison", "Boost"], ["tar -xf <archive>", "cmake -S . -B build -G \"Visual Studio 17 2022\" -A x64", "cmake --build build --config Release"]),
"cmake" => new(definition.Id, "Build CMake from source.", ["Visual Studio Build Tools"], ["tar -xf <archive>", "cmake -S . -B build -A x64", "cmake --build build --config Release"]),
"docker" => new(definition.Id, "Docker Desktop source build is not exposed in this tool.", [], [], IsSupported: false),
_ => new(definition.Id, "Download the source archive, verify prerequisites from the official documentation, then run the vendor build commands.", [], ["tar -xf <archive>"])
};
}
private static DevEnvironmentVersion WithCandidates(string environmentId, DevEnvironmentVersion version)
{
var candidates = new List<DevEnvironmentDownloadCandidate>();
if (version.Installer is not null)
{
candidates.Add(new DevEnvironmentDownloadCandidate(
"installer-official",
version.Installer.DisplayName,
version.Installer,
DevEnvironmentInstallMode.QuickInstall,
version.Installer.SourceKind,
version.Installer.MirrorRegion,
true));
}
if (version.SourceArchive is not null)
{
candidates.Add(new DevEnvironmentDownloadCandidate(
"source-official",
version.SourceArchive.DisplayName,
version.SourceArchive,
DevEnvironmentInstallMode.SourceBuild,
version.SourceArchive.SourceKind,
version.SourceArchive.MirrorRegion));
}
foreach (var mirror in MirrorCandidates(environmentId, version))
{
if (candidates.All(item => !string.Equals(item.Source.Url, mirror.Source.Url, StringComparison.OrdinalIgnoreCase)))
{
candidates.Add(mirror);
}
}
return version with { Candidates = candidates };
}
private static IEnumerable<DevEnvironmentDownloadCandidate> MirrorCandidates(string environmentId, DevEnvironmentVersion version)
{
var v = version.Version.TrimStart('v');
if (string.IsNullOrWhiteSpace(v))
{
yield break;
}
if (environmentId == "node")
{
var installerName = $"node-v{v}-x64.msi";
yield return Mirror("mirror-cn", "Node.js CN mirror", $"https://npmmirror.com/mirrors/node/v{v}/{installerName}", installerName, DevEnvironmentInstallMode.QuickInstall);
var sourceName = $"node-v{v}.tar.gz";
yield return Mirror("mirror-cn-source", "Node.js CN source mirror", $"https://npmmirror.com/mirrors/node/v{v}/{sourceName}", sourceName, DevEnvironmentInstallMode.SourceBuild);
}
else if (environmentId == "go")
{
var installerName = $"go{v}.windows-amd64.msi";
yield return Mirror("mirror-cn", "Go CN mirror", $"https://mirrors.aliyun.com/golang/{installerName}", installerName, DevEnvironmentInstallMode.QuickInstall);
var sourceName = $"go{v}.src.tar.gz";
yield return Mirror("mirror-cn-source", "Go CN source mirror", $"https://mirrors.aliyun.com/golang/{sourceName}", sourceName, DevEnvironmentInstallMode.SourceBuild);
}
else if (environmentId == "python")
{
var installerName = $"python-{v}-amd64.exe";
yield return Mirror("mirror-cn", "Python CN mirror", $"https://mirrors.huaweicloud.com/python/{v}/{installerName}", installerName, DevEnvironmentInstallMode.QuickInstall);
}
}
private static DevEnvironmentDownloadCandidate Mirror(string id, string displayName, string url, string fileName, DevEnvironmentInstallMode mode)
{
return new DevEnvironmentDownloadCandidate(
id,
displayName,
new DownloadSource(url, displayName, fileName, "MirrorCN", SourceLabel: "MirrorCN", MirrorRegion: "CN"),
mode,
"MirrorCN",
"CN");
}
private static DownloadSource OfficialSource(string url, string displayName, string fileName, string? Sha256 = null, long? sizeBytes = null)
{
return new DownloadSource(url, displayName, fileName, "Official", Sha256, "Official", SizeBytes: sizeBytes);
}
private static string GetString(JsonElement element, string name)
{
return element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(name, out var value)
? value.ValueKind == JsonValueKind.String ? value.GetString() ?? string.Empty : value.ToString()
: string.Empty;
}
private static long? GetLong(JsonElement element, string name)
{
if (element.ValueKind != JsonValueKind.Object ||
!element.TryGetProperty(name, out var value))
{
return null;
}
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var number))
{
return number;
}
return long.TryParse(value.ToString(), out number) ? number : null;
}
private static DateTimeOffset? TryDate(string value)
{
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
}
}
@@ -0,0 +1,293 @@
using System.Net.Http.Headers;
using System.Text;
namespace YMhut.Box.Core.Downloads;
public interface IDirectDownloadValidator
{
Task<DirectDownloadValidationResult> ValidateAsync(string input, CancellationToken cancellationToken = default);
}
public sealed record DirectDownloadValidationResult(
bool IsDirectDownload,
string Url,
string OriginalUrl = "",
string ResolvedUrl = "",
string ContentType = "",
string SuggestedFileName = "",
string Message = "",
bool WasInternetShortcut = false)
{
public string EffectiveUrl => string.IsNullOrWhiteSpace(ResolvedUrl) ? Url : ResolvedUrl;
}
public sealed class DirectDownloadValidator(HttpClient? client = null) : IDirectDownloadValidator, IDisposable
{
private const int ProbeBytes = 4096;
private readonly HttpClient _client = client ?? CreateDefaultClient();
private readonly bool _disposeClient = client is null;
public async Task<DirectDownloadValidationResult> ValidateAsync(string input, CancellationToken cancellationToken = default)
{
var value = input.Trim();
if (TryParseInternetShortcut(value, out var shortcutUrl))
{
var nested = await ValidateAsync(shortcutUrl, cancellationToken).ConfigureAwait(false);
return nested with
{
OriginalUrl = value,
WasInternetShortcut = true,
Message = nested.IsDirectDownload
? "Internet shortcut resolved to a direct download URL."
: nested.Message
};
}
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
uri.Scheme is not ("http" or "https"))
{
return new DirectDownloadValidationResult(false, value, Message: "Enter a valid HTTP/HTTPS download URL.");
}
if (LooksLikeInternetShortcutName(uri))
{
var shortcut = await TryReadShortcutAsync(uri, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(shortcut) && TryParseInternetShortcut(shortcut, out var resolved))
{
var nested = await ValidateAsync(resolved, cancellationToken).ConfigureAwait(false);
return nested with
{
OriginalUrl = uri.ToString(),
WasInternetShortcut = true,
Message = nested.IsDirectDownload
? "Internet shortcut resolved to a direct download URL."
: nested.Message
};
}
return new DirectDownloadValidationResult(
false,
uri.ToString(),
Message: "This link points to an Internet shortcut instead of a downloadable file.",
WasInternetShortcut: true);
}
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Range = new RangeHeaderValue(0, ProbeBytes - 1);
using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var resolvedUri = response.RequestMessage?.RequestUri?.ToString() ?? uri.ToString();
var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
var contentDispositionName = response.Content.Headers.ContentDisposition?.FileNameStar ??
response.Content.Headers.ContentDisposition?.FileName;
var suggestedName = CleanHeaderFileName(contentDispositionName);
if (string.IsNullOrWhiteSpace(suggestedName))
{
suggestedName = Path.GetFileName(new Uri(resolvedUri).LocalPath);
}
var probe = await ReadProbeAsync(response, cancellationToken).ConfigureAwait(false);
var textProbe = LooksTextual(contentType, probe)
? Encoding.UTF8.GetString(probe)
: string.Empty;
if (IsBlockedContent(contentType, suggestedName, textProbe, out var message))
{
if (TryParseInternetShortcut(textProbe, out var nestedUrl))
{
var nested = await ValidateAsync(nestedUrl, cancellationToken).ConfigureAwait(false);
return nested with
{
OriginalUrl = uri.ToString(),
WasInternetShortcut = true,
Message = nested.IsDirectDownload
? "Internet shortcut resolved to a direct download URL."
: nested.Message
};
}
return new DirectDownloadValidationResult(
false,
uri.ToString(),
ResolvedUrl: resolvedUri,
ContentType: contentType,
SuggestedFileName: suggestedName,
Message: message);
}
return new DirectDownloadValidationResult(
true,
uri.ToString(),
ResolvedUrl: resolvedUri,
ContentType: contentType,
SuggestedFileName: suggestedName,
Message: "Direct download URL verified.");
}
catch (Exception exception)
{
return new DirectDownloadValidationResult(
false,
uri.ToString(),
Message: $"Could not verify the download link: {exception.Message}");
}
}
public void Dispose()
{
if (_disposeClient)
{
_client.Dispose();
}
}
public static bool TryParseInternetShortcut(string content, out string url)
{
url = string.Empty;
foreach (var rawLine in content.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'))
{
var line = rawLine.Trim();
if (!line.StartsWith("URL=", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var value = line[4..].Trim();
if (Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
uri.Scheme is "http" or "https")
{
url = uri.ToString();
return true;
}
}
return false;
}
private static HttpClient CreateDefaultClient()
{
var client = new HttpClient(new HttpClientHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = 8
})
{
Timeout = TimeSpan.FromSeconds(20)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("YMhutBox/2.0 DownloadValidator");
return client;
}
private static async Task<string> TryReadShortcutAsync(Uri uri, CancellationToken cancellationToken)
{
try
{
using var client = CreateDefaultClient();
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Range = new RangeHeaderValue(0, ProbeBytes - 1);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return string.Empty;
}
var bytes = await ReadProbeAsync(response, cancellationToken).ConfigureAwait(false);
return Encoding.UTF8.GetString(bytes);
}
catch
{
return string.Empty;
}
}
private static async Task<byte[]> ReadProbeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var memory = new MemoryStream();
var buffer = new byte[ProbeBytes];
var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false);
if (read > 0)
{
memory.Write(buffer, 0, read);
}
return memory.ToArray();
}
private static bool IsBlockedContent(string contentType, string fileName, string probe, out string message)
{
var lowerType = contentType.ToLowerInvariant();
var lowerName = fileName.ToLowerInvariant();
var text = probe.TrimStart('\uFEFF', ' ', '\t', '\r', '\n');
if (lowerName.EndsWith(".url", StringComparison.OrdinalIgnoreCase) ||
lowerType.Contains("internet-shortcut", StringComparison.OrdinalIgnoreCase))
{
message = "This is an Internet shortcut, not the actual downloadable file.";
return true;
}
if (lowerType.Contains("text/html", StringComparison.OrdinalIgnoreCase) ||
text.StartsWith("<!doctype html", StringComparison.OrdinalIgnoreCase) ||
text.StartsWith("<html", StringComparison.OrdinalIgnoreCase))
{
message = "This link opened a web page instead of a direct file download.";
return true;
}
if (LooksLikeRedirectText(text))
{
message = "This text response looks like a redirect or shortcut, not a direct file download.";
return true;
}
message = string.Empty;
return false;
}
private static bool LooksLikeRedirectText(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
return text.Contains("[InternetShortcut]", StringComparison.OrdinalIgnoreCase) ||
text.StartsWith("URL=", StringComparison.OrdinalIgnoreCase) ||
text.Contains("window.location", StringComparison.OrdinalIgnoreCase) ||
text.Contains("location.href", StringComparison.OrdinalIgnoreCase) ||
text.Contains("http-equiv=\"refresh\"", StringComparison.OrdinalIgnoreCase);
}
private static bool LooksTextual(string contentType, byte[] bytes)
{
if (contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) ||
contentType.Contains("json", StringComparison.OrdinalIgnoreCase) ||
contentType.Contains("xml", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return bytes.Length > 0 && bytes.Take(Math.Min(bytes.Length, 128)).All(value =>
value is 9 or 10 or 13 || value >= 32);
}
private static bool LooksLikeInternetShortcutName(Uri uri)
{
return uri.LocalPath.EndsWith(".url", StringComparison.OrdinalIgnoreCase);
}
private static string CleanHeaderFileName(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return value.Trim().Trim('"');
}
}
@@ -0,0 +1,258 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace YMhut.Box.Core.Downloads;
public enum DownloadState
{
Queued,
Running,
Paused,
Completed,
Failed,
Canceled
}
public sealed record DownloadSource(
string Url,
string DisplayName,
string FileName,
string SourceKind = "Official",
string? Sha256 = null,
string SourceLabel = "",
string MirrorRegion = "",
long? SizeBytes = null,
string OriginalUrl = "",
string ResolvedUrl = "",
string ValidatedContentType = "",
string PackageId = "")
{
public string EffectiveLabel => string.IsNullOrWhiteSpace(SourceLabel) ? SourceKind : SourceLabel;
public string EffectiveUrl => string.IsNullOrWhiteSpace(ResolvedUrl) ? Url : ResolvedUrl;
}
public sealed record DownloadOptions(
string? TargetDirectory = null,
string? TargetPath = null,
string? InstallCommand = null,
string? InstallArguments = null,
bool IsInstaller = false,
bool DeleteAfterInstall = false);
public sealed record DownloadSettings(
string DefaultDirectory,
int MaxConcurrentDownloads = 5)
{
public int EffectiveMaxConcurrentDownloads => Math.Clamp(MaxConcurrentDownloads, 1, 5);
}
public sealed record DownloadItem(
string Id,
DownloadSource Source,
string TargetPath,
DownloadState State,
long ReceivedBytes = 0,
long? TotalBytes = null,
double BytesPerSecond = 0,
DateTimeOffset CreatedAt = default,
DateTimeOffset UpdatedAt = default,
string? Error = null,
string? InstallCommand = null,
string? InstallArguments = null,
bool IsInstaller = false,
bool DeleteAfterInstall = false,
bool InstallLaunched = false,
bool InstallCleanupCompleted = false,
string PartialPath = "",
string ETag = "",
string LastModified = "",
string AcceptRanges = "",
long? ContentLength = null,
string FinalUrl = "",
bool ResumeSupported = false)
{
[JsonIgnore]
public string EffectivePartialPath => string.IsNullOrWhiteSpace(PartialPath) ? TargetPath + ".partial" : PartialPath;
public static DownloadItem Create(
DownloadSource source,
string targetPath,
string? installCommand = null,
string? installArguments = null,
bool isInstaller = false,
bool deleteAfterInstall = false)
{
var now = DateTimeOffset.UtcNow;
return new DownloadItem(
Guid.NewGuid().ToString("N"),
source,
targetPath,
DownloadState.Queued,
CreatedAt: now,
UpdatedAt: now,
InstallCommand: installCommand,
InstallArguments: installArguments,
IsInstaller: isInstaller,
DeleteAfterInstall: deleteAfterInstall,
PartialPath: targetPath + ".partial");
}
public DownloadItem WithProgress(DownloadState state, long receivedBytes, long? totalBytes, double bytesPerSecond, string? error = null)
{
return this with
{
State = state,
ReceivedBytes = Math.Max(0, receivedBytes),
TotalBytes = totalBytes is > 0 ? totalBytes : null,
BytesPerSecond = Math.Max(0, bytesPerSecond),
UpdatedAt = DateTimeOffset.UtcNow,
Error = error
};
}
public DownloadItem WithResumeMetadata(
string? eTag = null,
string? lastModified = null,
string? acceptRanges = null,
long? contentLength = null,
string? finalUrl = null,
bool? resumeSupported = null)
{
return this with
{
PartialPath = string.IsNullOrWhiteSpace(PartialPath) ? TargetPath + ".partial" : PartialPath,
ETag = string.IsNullOrWhiteSpace(eTag) ? ETag : eTag,
LastModified = string.IsNullOrWhiteSpace(lastModified) ? LastModified : lastModified,
AcceptRanges = string.IsNullOrWhiteSpace(acceptRanges) ? AcceptRanges : acceptRanges,
ContentLength = contentLength is > 0 ? contentLength : ContentLength,
FinalUrl = string.IsNullOrWhiteSpace(finalUrl) ? FinalUrl : finalUrl,
ResumeSupported = resumeSupported ?? ResumeSupported,
UpdatedAt = DateTimeOffset.UtcNow
};
}
public DownloadItem WithInstallState(bool launched, bool cleanupCompleted = false)
{
return this with
{
InstallLaunched = launched,
InstallCleanupCompleted = cleanupCompleted,
UpdatedAt = DateTimeOffset.UtcNow
};
}
}
public sealed record DownloadProgressSnapshot(
string Id,
DownloadState State,
long ReceivedBytes,
long? TotalBytes,
double BytesPerSecond,
string? Error = null,
string ETag = "",
string LastModified = "",
string AcceptRanges = "",
long? ContentLength = null,
string FinalUrl = "",
bool RestartedFromZero = false)
{
public double Progress
{
get
{
if (TotalBytes is not > 0)
{
return 0;
}
return Math.Clamp(ReceivedBytes / (double)TotalBytes.Value, 0, 1);
}
}
}
public static class DownloadFormat
{
public static string FormatBytes(long bytes)
{
string[] units = ["B", "KB", "MB", "GB", "TB"];
var value = Math.Max(0, bytes);
var unit = 0;
var scaled = (double)value;
while (scaled >= 1024 && unit < units.Length - 1)
{
scaled /= 1024;
unit++;
}
return unit == 0 ? $"{value} {units[unit]}" : $"{scaled:0.#} {units[unit]}";
}
public static string FormatSpeed(double bytesPerSecond)
{
return $"{FormatBytes((long)Math.Max(0, bytesPerSecond))}/s";
}
public static string FormatEta(long receivedBytes, long? totalBytes, double bytesPerSecond)
{
if (totalBytes is not > 0 ||
receivedBytes <= 0 ||
receivedBytes >= totalBytes.Value ||
bytesPerSecond <= 1)
{
return "-";
}
var remaining = Math.Max(0, totalBytes.Value - receivedBytes);
var eta = TimeSpan.FromSeconds(remaining / bytesPerSecond);
if (eta.TotalHours >= 1)
{
return $"{(int)eta.TotalHours:0}h {eta.Minutes:00}m";
}
return eta.TotalMinutes >= 1
? $"{eta.Minutes:0}m {eta.Seconds:00}s"
: $"{Math.Max(1, eta.Seconds):0}s";
}
}
public static class DownloadHostProtocol
{
public const string Version = "1";
public const string Ready = "ready";
public const string Start = "start";
public const string Pause = "pause";
public const string Resume = "resume";
public const string Cancel = "cancel";
public const string Progress = "progress";
public const string Completed = "completed";
public const string Failed = "failed";
public const string Paused = "paused";
public const string Canceled = "canceled";
public const string Shutdown = "shutdown";
public const string Ping = "ping";
public const string Pong = "pong";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter<DownloadState>() }
};
public static string Serialize(DownloadHostMessage message)
{
return JsonSerializer.Serialize(message, JsonOptions);
}
public static DownloadHostMessage? Deserialize(string line)
{
return JsonSerializer.Deserialize<DownloadHostMessage>(line, JsonOptions);
}
}
public sealed record DownloadHostMessage(
string Type,
string RequestId = "",
string Version = "",
DownloadItem? Item = null,
DownloadProgressSnapshot? Progress = null,
string? Error = null);
@@ -0,0 +1,189 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using YMhut.Box.Core.App;
namespace YMhut.Box.Core.Downloads;
public interface IDownloadQueueStore
{
Task<IReadOnlyList<DownloadItem>> LoadAsync(CancellationToken cancellationToken = default);
Task SaveAsync(IReadOnlyList<DownloadItem> items, CancellationToken cancellationToken = default);
Task<DownloadSettings> LoadSettingsAsync(CancellationToken cancellationToken = default);
Task SaveSettingsAsync(DownloadSettings settings, CancellationToken cancellationToken = default);
}
public sealed class DownloadQueueStore(AppPaths paths) : IDownloadQueueStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter<DownloadState>() }
};
private readonly string _path = Path.Combine(paths.Data, "downloads.json");
private readonly string _settingsPath = Path.Combine(paths.Data, "download-settings.json");
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task<IReadOnlyList<DownloadItem>> LoadAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!File.Exists(_path))
{
return [];
}
await using var stream = File.OpenRead(_path);
return await JsonSerializer.DeserializeAsync<List<DownloadItem>>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false)
?? [];
}
catch
{
return [];
}
finally
{
_gate.Release();
}
}
public async Task SaveAsync(IReadOnlyList<DownloadItem> items, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
var temp = _path + ".tmp";
await using (var stream = File.Create(temp))
{
await JsonSerializer.SerializeAsync(stream, items, JsonOptions, cancellationToken).ConfigureAwait(false);
}
File.Copy(temp, _path, overwrite: true);
File.Delete(temp);
}
finally
{
_gate.Release();
}
}
public async Task<DownloadSettings> LoadSettingsAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var fallback = DefaultSettings();
if (!File.Exists(_settingsPath))
{
return fallback;
}
await using var stream = File.OpenRead(_settingsPath);
var settings = await JsonSerializer.DeserializeAsync<DownloadSettings>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false)
?? fallback;
var directory = string.IsNullOrWhiteSpace(settings.DefaultDirectory)
? fallback.DefaultDirectory
: settings.DefaultDirectory;
return settings with
{
DefaultDirectory = directory,
MaxConcurrentDownloads = settings.EffectiveMaxConcurrentDownloads
};
}
catch
{
return DefaultSettings();
}
finally
{
_gate.Release();
}
}
public async Task SaveSettingsAsync(DownloadSettings settings, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var normalized = settings with
{
DefaultDirectory = string.IsNullOrWhiteSpace(settings.DefaultDirectory)
? DefaultSettings().DefaultDirectory
: settings.DefaultDirectory,
MaxConcurrentDownloads = settings.EffectiveMaxConcurrentDownloads
};
Directory.CreateDirectory(Path.GetDirectoryName(_settingsPath)!);
var temp = _settingsPath + ".tmp";
await using (var stream = File.Create(temp))
{
await JsonSerializer.SerializeAsync(stream, normalized, JsonOptions, cancellationToken).ConfigureAwait(false);
}
File.Copy(temp, _settingsPath, overwrite: true);
File.Delete(temp);
}
finally
{
_gate.Release();
}
}
private DownloadSettings DefaultSettings()
{
return new DownloadSettings(Path.Combine(paths.Data, "Downloads"), 5);
}
}
public interface IDownloadHostProcessService : IDisposable
{
event EventHandler<DownloadProgressSnapshot>? ProgressChanged;
Task StartAsync(DownloadItem item, CancellationToken cancellationToken = default);
Task PauseAsync(string id, CancellationToken cancellationToken = default);
Task ResumeAsync(DownloadItem item, CancellationToken cancellationToken = default);
Task CancelAsync(string id, CancellationToken cancellationToken = default);
}
public interface IDownloadManagerService
{
event EventHandler? ItemsChanged;
IReadOnlyList<DownloadItem> Items { get; }
DownloadSettings Settings { get; }
Task InitializeAsync(CancellationToken cancellationToken = default);
Task<DownloadItem> EnqueueAsync(DownloadSource source, string? targetDirectory = null, string? installCommand = null, string? installArguments = null, CancellationToken cancellationToken = default);
Task<DownloadItem> EnqueueAsync(DownloadSource source, DownloadOptions options, CancellationToken cancellationToken = default);
Task UpdateSettingsAsync(DownloadSettings settings, CancellationToken cancellationToken = default);
Task StartAsync(string id, CancellationToken cancellationToken = default);
Task PauseAsync(string id, CancellationToken cancellationToken = default);
Task ResumeAsync(string id, CancellationToken cancellationToken = default);
Task CancelAsync(string id, CancellationToken cancellationToken = default);
Task RetryAsync(string id, CancellationToken cancellationToken = default);
Task ClearCompletedAsync(CancellationToken cancellationToken = default);
Task MarkInstallLaunchedAsync(string id, bool cleanupCompleted = false, CancellationToken cancellationToken = default);
Task TryCleanupAfterInstallAsync(string id, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,32 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
namespace YMhut.Box.Core.Feedback;
public static class FeedbackCode
{
private static readonly Regex Pattern = new(
"^FB-[0-9]{8}-[A-F0-9]{6}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static string Create(DateTimeOffset? now = null)
{
Span<byte> bytes = stackalloc byte[3];
RandomNumberGenerator.Fill(bytes);
var stamp = (now ?? DateTimeOffset.Now).ToString("yyyyMMdd", CultureInfo.InvariantCulture);
return $"FB-{stamp}-{Convert.ToHexString(bytes)}";
}
public static bool IsValid(string? value)
{
return Pattern.IsMatch(Normalize(value));
}
public static string Normalize(string? value)
{
return string.IsNullOrWhiteSpace(value)
? string.Empty
: value.Trim().ToUpperInvariant();
}
}
@@ -0,0 +1,121 @@
namespace YMhut.Box.Core.Feedback;
public sealed record FeedbackRequest(
string Title,
string Type,
string Severity,
string Contact,
string Body,
bool IncludeTodayLogs,
bool IncludeToolStatus,
bool IncludeSystemSummary,
string? ScreenshotPath = null,
string Language = "zh-CN",
string? FeedbackCode = null);
public sealed record FeedbackPackageResult(
string PackagePath,
string PackageSha256,
long PackageBytes,
DateTimeOffset CreatedAt,
string SummaryText,
IReadOnlyDictionary<string, string> IncludedFiles);
public sealed record FeedbackSubmissionResponse(
bool Ok,
string? Code,
string? ErrorCode,
string? Message,
bool Duplicate = false);
public sealed record FeedbackStatusResponse(
bool Ok,
string? Code,
string? Status,
string? StatusLabel,
bool HasReply,
string? Reply,
string? ReceivedAt,
string? UpdatedAt,
string? ErrorCode,
string? Message,
string? StatusDetail = null,
string? Category = null,
string? Priority = null,
bool MailSent = false);
public enum FeedbackRecordState
{
Draft,
PackageCreated,
PackageFailed,
Sending,
Sent,
SendFailed,
Querying,
StatusUpdated,
QueryFailed,
Archived
}
public sealed record FeedbackRecord(
string Code,
string Title,
string Type,
string Severity,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc,
FeedbackRecordState State,
string StatusLabel,
string LastMessage,
string? PackagePath = null,
string? PackageSha256 = null,
long PackageBytes = 0,
DateTimeOffset? SubmittedAtUtc = null,
DateTimeOffset? LastQueryAtUtc = null,
DateTimeOffset? ArchivedAtUtc = null,
bool IsArchived = false,
int FailureCount = 0,
string? ServiceStatus = null,
string? PublicReply = null,
string? ServiceStatusDetail = null,
string? ServiceCategory = null,
string? ServicePriority = null,
bool? ServiceMailSent = null,
string? ServiceReceivedAt = null,
string? ServiceUpdatedAt = null);
public interface IFeedbackPackageService
{
Task<FeedbackPackageResult> BuildPackageAsync(
FeedbackRequest request,
CancellationToken cancellationToken = default);
}
public interface IFeedbackSubmissionService
{
Task<FeedbackSubmissionResponse> SubmitAsync(
FeedbackRequest request,
FeedbackPackageResult package,
CancellationToken cancellationToken = default);
Task<FeedbackStatusResponse> GetStatusAsync(
string feedbackCode,
CancellationToken cancellationToken = default);
}
public interface IFeedbackRecordStore
{
Task<IReadOnlyList<FeedbackRecord>> ReadAsync(CancellationToken cancellationToken = default);
Task<FeedbackRecord?> FindAsync(string feedbackCode, CancellationToken cancellationToken = default);
Task<FeedbackRecord> UpsertAsync(FeedbackRecord record, CancellationToken cancellationToken = default);
Task RemoveAsync(string feedbackCode, CancellationToken cancellationToken = default);
Task<IReadOnlyList<FeedbackRecord>> ArchiveOlderThanAsync(
TimeSpan activeWindow,
DateTimeOffset? now = null,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,89 @@
using System.Security.Cryptography;
using System.Text;
namespace YMhut.Box.Core.Feedback;
public sealed record EncryptedFeedbackPackage(
string PackagePath,
string PackageSha256,
long PackageBytes);
public static class FeedbackPackageCrypto
{
public const string MagicText = "YMHUTFB1";
public const string EncryptionKeyMaterial = "ymhut-box-feedback-package-v1";
private const int NonceSize = 12;
private const int TagSize = 16;
private static readonly byte[] Magic = Encoding.ASCII.GetBytes(MagicText);
private static readonly byte[] Key = SHA256.HashData(Encoding.UTF8.GetBytes(EncryptionKeyMaterial));
public static async Task<EncryptedFeedbackPackage> EncryptPackageAsync(
string packagePath,
string? encryptedPath = null,
CancellationToken cancellationToken = default)
{
if (!File.Exists(packagePath))
{
throw new FileNotFoundException("反馈包不存在。", packagePath);
}
encryptedPath ??= Path.ChangeExtension(packagePath, ".ymfb");
var plaintext = await File.ReadAllBytesAsync(packagePath, cancellationToken).ConfigureAwait(false);
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var tag = new byte[TagSize];
var ciphertext = new byte[plaintext.Length];
using (var aes = new AesGcm(Key, TagSize))
{
aes.Encrypt(nonce, plaintext, ciphertext, tag, Magic);
}
await using (var stream = File.Create(encryptedPath))
{
await stream.WriteAsync(Magic, cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(nonce, cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(tag, cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(ciphertext, cancellationToken).ConfigureAwait(false);
}
var hash = await HashFileAsync(encryptedPath, cancellationToken).ConfigureAwait(false);
var info = new FileInfo(encryptedPath);
return new EncryptedFeedbackPackage(info.FullName, hash, info.Length);
}
public static async Task DecryptPackageAsync(
string encryptedPath,
string outputPath,
CancellationToken cancellationToken = default)
{
var payload = await File.ReadAllBytesAsync(encryptedPath, cancellationToken).ConfigureAwait(false);
if (payload.Length < Magic.Length + NonceSize + TagSize ||
!payload.AsSpan(0, Magic.Length).SequenceEqual(Magic))
{
throw new InvalidDataException("反馈包加密格式无效。");
}
var offset = Magic.Length;
var nonce = payload.AsSpan(offset, NonceSize).ToArray();
offset += NonceSize;
var tag = payload.AsSpan(offset, TagSize).ToArray();
offset += TagSize;
var ciphertext = payload.AsSpan(offset).ToArray();
var plaintext = new byte[ciphertext.Length];
using (var aes = new AesGcm(Key, TagSize))
{
aes.Decrypt(nonce, ciphertext, tag, plaintext, Magic);
}
await File.WriteAllBytesAsync(outputPath, plaintext, cancellationToken).ConfigureAwait(false);
}
private static async Task<string> HashFileAsync(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
@@ -0,0 +1,268 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using YMhut.Box.Core;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.System;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.Core.Feedback;
public sealed class FeedbackPackageService(
ILogService logService,
ISystemMetricsService? systemMetricsService = null,
ToolCatalog? toolCatalog = null) : IFeedbackPackageService
{
public const long MaxScreenshotBytes = 5L * 1024L * 1024L;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
private static readonly string[] AllowedScreenshotExtensions = [".png", ".jpg", ".jpeg", ".webp", ".bmp"];
public async Task<FeedbackPackageResult> BuildPackageAsync(
FeedbackRequest request,
CancellationToken cancellationToken = default)
{
ValidateRequest(request);
var feedbackRoot = FeedbackPackagesRoot();
Directory.CreateDirectory(feedbackRoot);
var createdAt = DateTimeOffset.Now;
var packagePath = NextAvailablePath(Path.Combine(feedbackRoot, $"feedback-{createdAt:yyyyMMdd-HHmmss}-{RandomSuffix()}.zip"));
var included = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
await using (var fileStream = File.Create(packagePath))
using (var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false))
{
var manifest = new
{
createdAt,
feedbackCode = FeedbackCode.Normalize(request.FeedbackCode),
request = new
{
feedbackCode = FeedbackCode.Normalize(request.FeedbackCode),
title = SensitiveText.Sanitize(request.Title, 160),
type = request.Type,
severity = request.Severity,
contact = SensitiveText.Sanitize(request.Contact, 180),
body = SensitiveText.Sanitize(request.Body, 4000),
includeTodayLogs = request.IncludeTodayLogs,
includeToolStatus = request.IncludeToolStatus,
includeSystemSummary = request.IncludeSystemSummary,
hasScreenshot = !string.IsNullOrWhiteSpace(request.ScreenshotPath)
},
client = new
{
app = "YMhut Box"
}
};
await WriteJsonEntryAsync(archive, "feedback.json", manifest, cancellationToken).ConfigureAwait(false);
included["feedback.json"] = "反馈正文和勾选项";
if (request.IncludeSystemSummary)
{
var system = CaptureSystemSummary();
await WriteJsonEntryAsync(archive, "system-summary.json", system, cancellationToken).ConfigureAwait(false);
included["system-summary.json"] = "系统摘要";
}
if (request.IncludeToolStatus)
{
var status = CaptureToolStatus();
await WriteJsonEntryAsync(archive, "tool-status.json", status, cancellationToken).ConfigureAwait(false);
included["tool-status.json"] = "工具运行状态";
}
if (request.IncludeTodayLogs)
{
var entries = await logService.ReadByDateAsync(
DateOnly.FromDateTime(DateTime.Now),
take: 1000,
cancellationToken: cancellationToken).ConfigureAwait(false);
var logs = entries.Select(entry => new
{
raw = new
{
timestamp = entry.Timestamp,
level = entry.Level,
category = entry.Category,
message = SensitiveText.Sanitize(entry.Message, 360),
detail = SensitiveText.Sanitize(entry.Detail, 800)
},
display = new
{
level = LogDisplayLocalizer.Level(entry.Level, request.Language),
category = LogDisplayLocalizer.Category(entry.Category, request.Language),
message = LogDisplayLocalizer.Message(entry.Message, request.Language),
detail = LogDisplayLocalizer.Detail(entry.Detail, request.Language, 800)
}
}).ToArray();
await WriteJsonEntryAsync(archive, $"logs-{createdAt:yyyyMMdd}.json", logs, cancellationToken).ConfigureAwait(false);
included[$"logs-{createdAt:yyyyMMdd}.json"] = $"当天日志 {logs.Length} 条";
}
if (!string.IsNullOrWhiteSpace(request.ScreenshotPath))
{
var screenshot = ValidateScreenshot(request.ScreenshotPath);
var entryName = "screenshot" + screenshot.Extension.ToLowerInvariant();
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
await using var input = File.OpenRead(screenshot.FullName);
await using var output = entry.Open();
await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
included[entryName] = $"截图 {screenshot.Length} bytes";
}
var summary = BuildSummary(request, included, createdAt);
await WriteTextEntryAsync(archive, "summary.txt", summary, cancellationToken).ConfigureAwait(false);
included["summary.txt"] = "反馈包摘要";
}
var sha256 = await HashFileAsync(packagePath, cancellationToken).ConfigureAwait(false);
var fileInfo = new FileInfo(packagePath);
return new FeedbackPackageResult(
packagePath,
sha256,
fileInfo.Length,
createdAt,
BuildSummary(request, included, createdAt),
included);
}
private static void ValidateRequest(FeedbackRequest request)
{
if (string.IsNullOrWhiteSpace(request.Body))
{
throw new InvalidOperationException("反馈正文不能为空。");
}
}
public static string FeedbackPackagesRoot()
{
return Path.Combine(AppContext.BaseDirectory, "FeedbackPackages");
}
private static FileInfo ValidateScreenshot(string path)
{
var info = new FileInfo(path.Trim().Trim('"'));
if (!info.Exists)
{
throw new FileNotFoundException("截图文件不存在。", info.FullName);
}
if (!AllowedScreenshotExtensions.Contains(info.Extension, StringComparer.OrdinalIgnoreCase))
{
throw new InvalidOperationException("截图仅支持 png、jpg、jpeg、webp、bmp。");
}
if (info.Length > MaxScreenshotBytes)
{
throw new InvalidOperationException("截图不能超过 5 MB。");
}
return info;
}
private object CaptureSystemSummary()
{
var snapshot = systemMetricsService?.Capture();
return new
{
machineName = SensitiveText.Sanitize(snapshot?.MachineName, 120),
osDescription = SensitiveText.Sanitize(snapshot?.OsDescription, 200),
processorCount = snapshot?.ProcessorCount ?? Environment.ProcessorCount,
workingSetBytes = snapshot?.WorkingSetBytes ?? Environment.WorkingSet,
managedMemoryBytes = snapshot?.ManagedMemoryBytes ?? GC.GetTotalMemory(false),
capturedAt = snapshot?.CapturedAt ?? DateTimeOffset.Now
};
}
private object CaptureToolStatus()
{
var catalog = toolCatalog ?? new ToolCatalog();
return new
{
capturedAt = DateTimeOffset.Now,
totalTools = catalog.Modules.Count,
byCategory = catalog.Modules
.GroupBy(module => module.Metadata.Category.ToString())
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => new { category = group.Key, count = group.Count() })
.ToArray(),
newTools = new[]
{
"compression_codec",
"totp_generator",
"color_contrast_checker",
"url_redirect_trace",
"image_metadata_inspector",
"markdown_preview"
}.Select(id => new { id, exists = catalog.GetById(id) is not null }).ToArray()
};
}
private static string BuildSummary(FeedbackRequest request, IReadOnlyDictionary<string, string> included, DateTimeOffset createdAt)
{
var english = string.Equals(request.Language, "en-US", StringComparison.OrdinalIgnoreCase);
var lines = new List<string>
{
english ? "YMhut Box Feedback Package" : "YMhut Box 反馈包",
$"{(english ? "Created" : "")}: {createdAt:O}",
$"{(english ? "Feedback code" : "")}: {FeedbackCode.Normalize(request.FeedbackCode)}",
$"{(english ? "Title" : "")}: {SensitiveText.Sanitize(request.Title, 160)}",
$"{(english ? "Type" : "")}: {request.Type}",
$"{(english ? "Severity" : "")}: {request.Severity}",
$"{(english ? "Contact" : "")}: {SensitiveText.Sanitize(request.Contact, 180)}",
$"{(english ? "Body" : "")}: {SensitiveText.Sanitize(request.Body, 1200)}",
string.Empty,
english ? "Included files:" : "包含文件:"
};
lines.AddRange(included.Select(pair => $"- {pair.Key}: {pair.Value}"));
return string.Join(Environment.NewLine, lines);
}
private static async Task WriteJsonEntryAsync(ZipArchive archive, string name, object value, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(value, JsonOptions);
await WriteTextEntryAsync(archive, name, json, cancellationToken).ConfigureAwait(false);
}
private static async Task WriteTextEntryAsync(ZipArchive archive, string name, string text, CancellationToken cancellationToken)
{
var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
await using var stream = entry.Open();
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
await writer.WriteAsync(text.AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task<string> HashFileAsync(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string RandomSuffix()
{
Span<byte> bytes = stackalloc byte[3];
RandomNumberGenerator.Fill(bytes);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static string NextAvailablePath(string path)
{
if (!File.Exists(path))
{
return path;
}
var directory = Path.GetDirectoryName(path) ?? Environment.CurrentDirectory;
var name = Path.GetFileNameWithoutExtension(path);
var extension = Path.GetExtension(path);
for (var index = 2; ; index++)
{
var candidate = Path.Combine(directory, $"{name}-{index}{extension}");
if (!File.Exists(candidate))
{
return candidate;
}
}
}
}
@@ -0,0 +1,287 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace YMhut.Box.Core.Feedback;
public sealed class FeedbackRecordStore(string? storagePath = null) : IFeedbackRecordStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly string _storagePath = string.IsNullOrWhiteSpace(storagePath)
? DefaultStoragePath()
: storagePath;
public static string DefaultStoragePath()
{
return Path.Combine(FeedbackPackageService.FeedbackPackagesRoot(), "feedback-records.json");
}
public async Task<IReadOnlyList<FeedbackRecord>> ReadAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await ReadUnsafeAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task<FeedbackRecord?> FindAsync(string feedbackCode, CancellationToken cancellationToken = default)
{
var normalized = FeedbackCode.Normalize(feedbackCode);
if (!FeedbackCode.IsValid(normalized))
{
return null;
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var records = await ReadUnsafeAsync(cancellationToken).ConfigureAwait(false);
return records.FirstOrDefault(record => string.Equals(record.Code, normalized, StringComparison.OrdinalIgnoreCase));
}
finally
{
_gate.Release();
}
}
public async Task<FeedbackRecord> UpsertAsync(FeedbackRecord record, CancellationToken cancellationToken = default)
{
var normalized = FeedbackCode.Normalize(record.Code);
if (!FeedbackCode.IsValid(normalized))
{
throw new InvalidOperationException("反馈编号无效。");
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var records = (await ReadUnsafeAsync(cancellationToken).ConfigureAwait(false)).ToList();
var index = records.FindIndex(item => string.Equals(item.Code, normalized, StringComparison.OrdinalIgnoreCase));
var existing = index >= 0 ? records[index] : null;
var merged = Merge(existing, record, normalized);
if (index >= 0)
{
records[index] = merged;
}
else
{
records.Add(merged);
}
await WriteUnsafeAsync(records, cancellationToken).ConfigureAwait(false);
return merged;
}
finally
{
_gate.Release();
}
}
public async Task RemoveAsync(string feedbackCode, CancellationToken cancellationToken = default)
{
var normalized = FeedbackCode.Normalize(feedbackCode);
if (!FeedbackCode.IsValid(normalized))
{
return;
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var records = (await ReadUnsafeAsync(cancellationToken).ConfigureAwait(false)).ToList();
var removed = records.RemoveAll(record => string.Equals(record.Code, normalized, StringComparison.OrdinalIgnoreCase));
if (removed > 0)
{
await WriteUnsafeAsync(records, cancellationToken).ConfigureAwait(false);
}
}
finally
{
_gate.Release();
}
}
public async Task<IReadOnlyList<FeedbackRecord>> ArchiveOlderThanAsync(
TimeSpan activeWindow,
DateTimeOffset? now = null,
CancellationToken cancellationToken = default)
{
var current = (now ?? DateTimeOffset.UtcNow).ToUniversalTime();
var threshold = current - activeWindow;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var changed = false;
var records = (await ReadUnsafeAsync(cancellationToken).ConfigureAwait(false)).ToList();
for (var index = 0; index < records.Count; index++)
{
var record = Normalize(records[index]);
if (!record.IsArchived && record.CreatedAtUtc.ToUniversalTime() <= threshold)
{
record = record with
{
State = FeedbackRecordState.Archived,
StatusLabel = "已归档",
LastMessage = "该反馈记录已超过 10 天,已自动归档到本地历史。",
UpdatedAtUtc = current,
ArchivedAtUtc = current,
IsArchived = true
};
changed = true;
}
records[index] = record;
}
if (changed)
{
await WriteUnsafeAsync(records, cancellationToken).ConfigureAwait(false);
}
return Sort(records);
}
finally
{
_gate.Release();
}
}
private static FeedbackRecord Merge(FeedbackRecord? existing, FeedbackRecord record, string normalizedCode)
{
var normalized = Normalize(record with { Code = normalizedCode });
if (existing is null)
{
return normalized;
}
var archived = existing.IsArchived || normalized.IsArchived;
var archivedAt = normalized.ArchivedAtUtc ?? existing.ArchivedAtUtc;
return normalized with
{
CreatedAtUtc = existing.CreatedAtUtc <= normalized.CreatedAtUtc ? existing.CreatedAtUtc : normalized.CreatedAtUtc,
ArchivedAtUtc = archivedAt,
IsArchived = archived
};
}
private static FeedbackRecord Normalize(FeedbackRecord record)
{
var now = DateTimeOffset.UtcNow;
var created = record.CreatedAtUtc == default ? now : record.CreatedAtUtc.ToUniversalTime();
var updated = record.UpdatedAtUtc == default ? created : record.UpdatedAtUtc.ToUniversalTime();
var title = string.IsNullOrWhiteSpace(record.Title) ? "未命名反馈" : record.Title.Trim();
var label = string.IsNullOrWhiteSpace(record.StatusLabel) ? StateLabel(record.State) : record.StatusLabel.Trim();
var message = string.IsNullOrWhiteSpace(record.LastMessage) ? "暂无更多状态信息。" : record.LastMessage.Trim();
return record with
{
Code = FeedbackCode.Normalize(record.Code),
Title = title.Length > 160 ? title[..160] : title,
Type = string.IsNullOrWhiteSpace(record.Type) ? "issue" : record.Type.Trim(),
Severity = string.IsNullOrWhiteSpace(record.Severity) ? "normal" : record.Severity.Trim(),
CreatedAtUtc = created,
UpdatedAtUtc = updated,
StatusLabel = label.Length > 80 ? label[..80] : label,
LastMessage = message.Length > 500 ? message[..500] : message,
FailureCount = Math.Max(0, record.FailureCount),
PackageBytes = Math.Max(0, record.PackageBytes),
ServiceStatus = TrimOrNull(record.ServiceStatus, 80),
PublicReply = TrimOrNull(record.PublicReply, 800),
ServiceStatusDetail = TrimOrNull(record.ServiceStatusDetail, 500),
ServiceCategory = TrimOrNull(record.ServiceCategory, 80),
ServicePriority = TrimOrNull(record.ServicePriority, 80),
ServiceReceivedAt = TrimOrNull(record.ServiceReceivedAt, 80),
ServiceUpdatedAt = TrimOrNull(record.ServiceUpdatedAt, 80)
};
}
private static string? TrimOrNull(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
}
private static string StateLabel(FeedbackRecordState state)
{
return state switch
{
FeedbackRecordState.PackageCreated => "反馈包已生成",
FeedbackRecordState.PackageFailed => "生成失败",
FeedbackRecordState.Sending => "发送中",
FeedbackRecordState.Sent => "发送成功",
FeedbackRecordState.SendFailed => "发送失败",
FeedbackRecordState.Querying => "正在获取状态",
FeedbackRecordState.StatusUpdated => "状态已更新",
FeedbackRecordState.QueryFailed => "获取失败",
FeedbackRecordState.Archived => "已归档",
_ => "未发送"
};
}
private async Task<IReadOnlyList<FeedbackRecord>> ReadUnsafeAsync(CancellationToken cancellationToken)
{
if (!File.Exists(_storagePath))
{
return [];
}
try
{
await using var stream = File.OpenRead(_storagePath);
var records = await JsonSerializer.DeserializeAsync<List<FeedbackRecord>>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false);
return Sort(records?.Select(Normalize) ?? []);
}
catch (JsonException)
{
return [];
}
catch (IOException)
{
return [];
}
}
private async Task WriteUnsafeAsync(IReadOnlyList<FeedbackRecord> records, CancellationToken cancellationToken)
{
var folder = Path.GetDirectoryName(_storagePath);
if (!string.IsNullOrWhiteSpace(folder))
{
Directory.CreateDirectory(folder);
}
var ordered = Sort(records.Select(Normalize));
var tempPath = _storagePath + ".tmp";
await using (var stream = File.Create(tempPath))
{
await JsonSerializer.SerializeAsync(stream, ordered, JsonOptions, cancellationToken).ConfigureAwait(false);
}
File.Move(tempPath, _storagePath, overwrite: true);
}
private static IReadOnlyList<FeedbackRecord> Sort(IEnumerable<FeedbackRecord> records)
{
return records
.OrderBy(record => record.IsArchived)
.ThenByDescending(record => record.UpdatedAtUtc)
.ThenByDescending(record => record.CreatedAtUtc)
.ToArray();
}
}
@@ -0,0 +1,172 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace YMhut.Box.Core.Feedback;
public sealed class FeedbackSubmissionService(HttpClient? httpClient = null) : IFeedbackSubmissionService
{
public const string Endpoint = "https://mail-smtp.ymhut.cn/";
public const string ClientSignatureKey = "ymhut-box-feedback-client-v1";
private readonly HttpClient _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
public async Task<FeedbackSubmissionResponse> SubmitAsync(
FeedbackRequest request,
FeedbackPackageResult package,
CancellationToken cancellationToken = default)
{
try
{
if (!File.Exists(package.PackagePath))
{
return new FeedbackSubmissionResponse(false, null, "PACKAGE_MISSING", "本地反馈包不存在。");
}
var encrypted = await FeedbackPackageCrypto.EncryptPackageAsync(package.PackagePath, cancellationToken: cancellationToken)
.ConfigureAwait(false);
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(global::System.Globalization.CultureInfo.InvariantCulture);
var nonce = Guid.NewGuid().ToString("N");
var feedbackCode = FeedbackCode.Normalize(request.FeedbackCode);
var payload = JsonSerializer.Serialize(new
{
feedbackCode,
title = request.Title,
type = request.Type,
severity = request.Severity,
contact = request.Contact,
bodyLength = request.Body?.Length ?? 0,
packageEncrypted = true,
encryption = FeedbackPackageCrypto.MagicText,
packageBytes = encrypted.PackageBytes,
packageSha256 = encrypted.PackageSha256,
plainPackageBytes = package.PackageBytes,
plainPackageSha256 = package.PackageSha256,
createdAt = package.CreatedAt
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
var signature = Sign(timestamp, nonce, encrypted.PackageSha256, payload);
using var form = new MultipartFormDataContent();
form.Add(new StringContent(payload, Encoding.UTF8, "application/json"), "payload");
form.Add(new StringContent(timestamp, Encoding.UTF8), "timestamp");
form.Add(new StringContent(nonce, Encoding.UTF8), "nonce");
form.Add(new StringContent(encrypted.PackageSha256, Encoding.UTF8), "packageSha256");
form.Add(new StringContent(signature, Encoding.UTF8), "signature");
await using var stream = File.OpenRead(encrypted.PackagePath);
using var fileContent = new StreamContent(stream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
form.Add(fileContent, "package", Path.GetFileName(encrypted.PackagePath));
using var response = await _httpClient.PostAsync(Endpoint, form, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var parsed = ParseResponse(text);
if (response.IsSuccessStatusCode)
{
return parsed;
}
return parsed with
{
Ok = false,
ErrorCode = parsed.ErrorCode ?? $"HTTP_{(int)response.StatusCode}",
Message = parsed.Message ?? response.ReasonPhrase
};
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or OperationCanceledException)
{
return new FeedbackSubmissionResponse(false, null, exception is TaskCanceledException ? "TIMEOUT" : "NETWORK_ERROR", exception.Message);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or CryptographicException)
{
return new FeedbackSubmissionResponse(false, null, "PACKAGE_ENCRYPT_FAILED", exception.Message);
}
}
public async Task<FeedbackStatusResponse> GetStatusAsync(
string feedbackCode,
CancellationToken cancellationToken = default)
{
var normalized = FeedbackCode.Normalize(feedbackCode);
if (!FeedbackCode.IsValid(normalized))
{
return new FeedbackStatusResponse(false, normalized, null, null, false, null, null, null, "INVALID_CODE", "Invalid feedback code.");
}
try
{
var uri = $"{Endpoint}?api=status&code={Uri.EscapeDataString(normalized)}";
using var response = await _httpClient.GetAsync(uri, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var parsed = ParseStatusResponse(text);
if (response.IsSuccessStatusCode)
{
return parsed;
}
return parsed with
{
Ok = false,
ErrorCode = parsed.ErrorCode ?? $"HTTP_{(int)response.StatusCode}",
Message = parsed.Message ?? response.ReasonPhrase
};
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or OperationCanceledException)
{
return new FeedbackStatusResponse(false, normalized, null, null, false, null, null, null, exception is TaskCanceledException ? "TIMEOUT" : "NETWORK_ERROR", exception.Message);
}
}
public static string Sign(string timestamp, string nonce, string packageSha256, string payload)
{
var material = $"{timestamp}\n{nonce}\n{packageSha256}\n{payload}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(ClientSignatureKey));
return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(material))).ToLowerInvariant();
}
private static FeedbackSubmissionResponse ParseResponse(string text)
{
try
{
using var document = JsonDocument.Parse(text);
var root = document.RootElement;
var ok = root.TryGetProperty("ok", out var okElement) && okElement.ValueKind == JsonValueKind.True;
var code = root.TryGetProperty("code", out var codeElement) ? codeElement.GetString() : null;
var error = root.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null;
var message = root.TryGetProperty("message", out var messageElement) ? messageElement.GetString() : null;
var duplicate = root.TryGetProperty("duplicate", out var duplicateElement) && duplicateElement.ValueKind == JsonValueKind.True;
return new FeedbackSubmissionResponse(ok, code, error, message, duplicate);
}
catch (JsonException)
{
return new FeedbackSubmissionResponse(false, null, "INVALID_RESPONSE", text);
}
}
private static FeedbackStatusResponse ParseStatusResponse(string text)
{
try
{
using var document = JsonDocument.Parse(text);
var root = document.RootElement;
var ok = root.TryGetProperty("ok", out var okElement) && okElement.ValueKind == JsonValueKind.True;
var code = root.TryGetProperty("code", out var codeElement) ? codeElement.GetString() : null;
var status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null;
var statusLabel = root.TryGetProperty("statusLabel", out var statusLabelElement) ? statusLabelElement.GetString() : null;
var statusDetail = root.TryGetProperty("statusDetail", out var statusDetailElement) ? statusDetailElement.GetString() : null;
var category = root.TryGetProperty("category", out var categoryElement) ? categoryElement.GetString() : null;
var priority = root.TryGetProperty("priority", out var priorityElement) ? priorityElement.GetString() : null;
var hasReply = root.TryGetProperty("hasReply", out var hasReplyElement) && hasReplyElement.ValueKind == JsonValueKind.True;
var reply = root.TryGetProperty("reply", out var replyElement) ? replyElement.GetString() : null;
var receivedAt = root.TryGetProperty("receivedAt", out var receivedAtElement) ? receivedAtElement.GetString() : null;
var updatedAt = root.TryGetProperty("updatedAt", out var updatedAtElement) ? updatedAtElement.GetString() : null;
var mailSent = root.TryGetProperty("mailSent", out var mailSentElement) && mailSentElement.ValueKind == JsonValueKind.True;
var error = root.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null;
var message = root.TryGetProperty("message", out var messageElement) ? messageElement.GetString() : null;
return new FeedbackStatusResponse(ok, code, status, statusLabel, hasReply, reply, receivedAt, updatedAt, error, message, statusDetail, category, priority, mailSent);
}
catch (JsonException)
{
return new FeedbackStatusResponse(false, null, null, null, false, null, null, null, "INVALID_RESPONSE", text);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
namespace YMhut.Box.Core.Logging;
public interface ILogService
{
event EventHandler<LogEntry>? EntryWritten;
string LogPath { get; }
Task WriteAsync(
string level,
string category,
string message,
string? detail = null,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<LogEntry>> ReadAsync(
string? level = null,
string? category = null,
int take = 500,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<LogEntry>> ReadByDateAsync(
DateOnly date,
string? level = null,
string? query = null,
int take = 0,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<LogEntry>> ReadByDatePageAsync(
DateOnly date,
string? level = null,
string? query = null,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default);
Task CleanupAsync(int retentionCount, CancellationToken cancellationToken = default);
Task ClearAllAsync(CancellationToken cancellationToken = default);
Task ClearTodayAsync(CancellationToken cancellationToken = default);
Task ClearFilteredTodayAsync(
string? level = null,
string? query = null,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,307 @@
using System.Text.Json;
using YMhut.Box.Core.App;
namespace YMhut.Box.Core.Logging;
public sealed class JsonFileLogService : ILogService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly SemaphoreSlim _gate = new(1, 1);
public JsonFileLogService(AppPaths paths)
{
paths.EnsureCreated();
LogPath = Path.Combine(paths.Logs, "app-log.jsonl");
}
public event EventHandler<LogEntry>? EntryWritten;
public string LogPath { get; }
public async Task WriteAsync(
string level,
string category,
string message,
string? detail = null,
CancellationToken cancellationToken = default)
{
var entry = new LogEntry(DateTimeOffset.Now, level, category, message, detail);
var line = JsonSerializer.Serialize(entry, JsonOptions);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
await File.AppendAllTextAsync(LogPath, line + Environment.NewLine, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
EntryWritten?.Invoke(this, entry);
}
public async Task<IReadOnlyList<LogEntry>> ReadAsync(
string? level = null,
string? category = null,
int take = 500,
CancellationToken cancellationToken = default)
{
if (!File.Exists(LogPath))
{
return [];
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var entries = new List<LogEntry>();
foreach (var line in await File.ReadAllLinesAsync(LogPath, cancellationToken).ConfigureAwait(false))
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
try
{
var entry = JsonSerializer.Deserialize<LogEntry>(line, JsonOptions);
if (entry is not null)
{
entries.Add(entry);
}
}
catch (JsonException)
{
}
}
return entries
.Where(entry => string.IsNullOrWhiteSpace(level) || string.Equals(entry.Level, level, StringComparison.OrdinalIgnoreCase))
.Where(entry => string.IsNullOrWhiteSpace(category) || string.Equals(entry.Category, category, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(entry => entry.Timestamp)
.Take(Math.Clamp(take, 1, 5000))
.ToList();
}
finally
{
_gate.Release();
}
}
public async Task<IReadOnlyList<LogEntry>> ReadByDateAsync(
DateOnly date,
string? level = null,
string? query = null,
int take = 0,
CancellationToken cancellationToken = default)
{
var filtered = await ReadByDateFilteredAsync(date, level, query, cancellationToken).ConfigureAwait(false);
return take > 0 ? filtered.Take(take).ToList() : filtered.ToList();
}
public async Task<IReadOnlyList<LogEntry>> ReadByDatePageAsync(
DateOnly date,
string? level = null,
string? query = null,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default)
{
var filtered = await ReadByDateFilteredAsync(date, level, query, cancellationToken).ConfigureAwait(false);
return filtered
.Skip(Math.Max(0, skip))
.Take(Math.Clamp(take, 1, 5000))
.ToList();
}
private async Task<IReadOnlyList<LogEntry>> ReadByDateFilteredAsync(
DateOnly date,
string? level,
string? query,
CancellationToken cancellationToken)
{
var entries = await ReadAllEntriesAsync(cancellationToken).ConfigureAwait(false);
var normalizedQuery = query?.Trim() ?? string.Empty;
return entries
.Where(entry => DateOnly.FromDateTime(entry.Timestamp.LocalDateTime) == date)
.Where(entry => string.IsNullOrWhiteSpace(level) || string.Equals(entry.Level, level, StringComparison.OrdinalIgnoreCase))
.Where(entry => string.IsNullOrWhiteSpace(normalizedQuery)
|| entry.Message.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)
|| entry.Category.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)
|| (entry.Detail?.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase) ?? false))
.OrderByDescending(entry => entry.Timestamp)
.ToList();
}
private async Task<IReadOnlyList<LogEntry>> ReadAllEntriesAsync(CancellationToken cancellationToken)
{
if (!File.Exists(LogPath))
{
return [];
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var entries = new List<LogEntry>();
foreach (var line in await File.ReadAllLinesAsync(LogPath, cancellationToken).ConfigureAwait(false))
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
try
{
var entry = JsonSerializer.Deserialize<LogEntry>(line, JsonOptions);
if (entry is not null)
{
entries.Add(entry);
}
}
catch (JsonException)
{
}
}
return entries;
}
finally
{
_gate.Release();
}
}
public async Task CleanupAsync(int retentionCount, CancellationToken cancellationToken = default)
{
if (!File.Exists(LogPath))
{
return;
}
var keep = Math.Max(0, retentionCount);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(LogPath, cancellationToken).ConfigureAwait(false);
var retained = keep == 0 ? [] : lines.TakeLast(keep).ToArray();
await File.WriteAllLinesAsync(LogPath, retained, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task ClearAllAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (File.Exists(LogPath))
{
File.Delete(LogPath);
}
}
finally
{
_gate.Release();
}
}
public async Task ClearTodayAsync(CancellationToken cancellationToken = default)
{
if (!File.Exists(LogPath))
{
return;
}
var today = DateTimeOffset.Now.Date;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var retained = new List<string>();
foreach (var line in await File.ReadAllLinesAsync(LogPath, cancellationToken).ConfigureAwait(false))
{
try
{
var entry = JsonSerializer.Deserialize<LogEntry>(line, JsonOptions);
if (entry is null || entry.Timestamp.Date != today)
{
retained.Add(line);
}
}
catch (JsonException)
{
retained.Add(line);
}
}
await File.WriteAllLinesAsync(LogPath, retained, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task ClearFilteredTodayAsync(
string? level = null,
string? query = null,
CancellationToken cancellationToken = default)
{
if (!File.Exists(LogPath))
{
return;
}
var today = DateTimeOffset.Now.Date;
var normalizedQuery = query?.Trim() ?? string.Empty;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var retained = new List<string>();
foreach (var line in await File.ReadAllLinesAsync(LogPath, cancellationToken).ConfigureAwait(false))
{
try
{
var entry = JsonSerializer.Deserialize<LogEntry>(line, JsonOptions);
if (entry is null || !Matches(entry))
{
retained.Add(line);
}
}
catch (JsonException)
{
retained.Add(line);
}
}
await File.WriteAllLinesAsync(LogPath, retained, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
bool Matches(LogEntry entry)
{
if (entry.Timestamp.Date != today)
{
return false;
}
if (!string.IsNullOrWhiteSpace(level) && !string.Equals(entry.Level, level, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return string.IsNullOrWhiteSpace(normalizedQuery)
|| entry.Message.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)
|| entry.Category.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)
|| (entry.Detail?.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase) ?? false);
}
}
}
@@ -0,0 +1,370 @@
using System.Text.RegularExpressions;
using YMhut.Box.Core;
namespace YMhut.Box.Core.Logging;
public static class LogDisplayLocalizer
{
public static string Level(string? level, string language = "zh-CN")
{
var english = English(language);
return level?.Trim() switch
{
"Trace" => english ? "Trace" : "跟踪",
"Debug" => english ? "Debug" : "调试",
"Information" => english ? "Info" : "信息",
"Warning" => english ? "Warning" : "警告",
"Error" => english ? "Error" : "错误",
"Critical" => english ? "Critical" : "严重",
_ => english ? "Info" : "信息"
};
}
public static string Category(string? category, string language = "zh-CN")
{
var english = English(language);
return (category ?? string.Empty).Trim().ToLowerInvariant() switch
{
"navigation" => english ? "Navigation" : "导航",
"tool" => english ? "Tool" : "工具",
"network" => english ? "Network" : "网络",
"api" => english ? "API" : "接口",
"solar" => english ? "Solar system" : "太阳系",
"service" => english ? "Service" : "服务",
"about" => english ? "About" : "关于",
"download" => english ? "Download" : "下载",
"risk" => english ? "Risk" : "风险确认",
"dev-env" => english ? "Development environment" : "开发环境",
"tool-run" => english ? "Tool run" : "工具运行",
"builtin-tool" => english ? "Built-in tool" : "内置工具",
"tool-result" => english ? "Tool result" : "工具结果",
"tool-metadata" => english ? "Tool metadata" : "工具元数据",
"plugin" => english ? "Plugin" : "插件",
"plugin-host" => english ? "Plugin host" : "插件宿主",
"update" => english ? "Update" : "更新",
"settings" => english ? "Settings" : "设置",
"feedback" => english ? "Feedback" : "反馈",
"safe_browser" => english ? "Safe browser" : "安全浏览器",
"media_player" => english ? "Media player" : "媒体播放器",
"window" => english ? "Window" : "窗口",
"system" => english ? "System" : "系统",
"startup" => english ? "Startup" : "启动",
"startup-check" => english ? "Startup check" : "启动自检",
"version" => english ? "Version" : "版本",
"worker" => english ? "Worker" : "工具进程",
"" => english ? "General" : "通用",
var value => Humanize(value)
};
}
public static string Message(string? message, string language = "zh-CN")
{
var text = SensitiveText.Sanitize(message, 260);
var english = English(language);
var normalized = text.Trim();
var exact = normalized.ToLowerInvariant() switch
{
"open home page" => english ? "Open home page" : "打开首页",
"open toolbox" => english ? "Open toolbox" : "打开工具箱",
"open logs" => english ? "Open logs" : "打开日志",
"open service status" => english ? "Open service status" : "打开服务状态",
"open startup check results" => english ? "Open startup check results" : "打开启动自检结果",
"open download manager" => english ? "Open download manager" : "打开下载管理",
"open plugin docs" => english ? "Open plugin docs" : "打开插件文档",
"open settings" => english ? "Open settings" : "打开设置",
"open about" => english ? "Open about" : "打开关于",
"open plugins" => english ? "Open plugins" : "打开插件页",
"open feedback" => english ? "Open feedback" : "打开反馈",
"tool catalog rebuilt" => english ? "Tool catalog rebuilt" : "工具目录已重建",
"external tools scanned" => english ? "External tools scanned" : "外部工具已扫描",
"user launched downloaded installer" => english ? "User launched downloaded installer" : "用户打开已下载的安装包",
"external tool icon extracted" => english ? "External tool icon extracted" : "已提取外部工具图标",
"tool worker process started" => english ? "Tool worker process started" : "工具工作进程已启动",
"tool worker ready" => english ? "Tool worker ready" : "工具工作进程已就绪",
"tool worker unavailable; using in-process fallback" => english ? "Tool worker unavailable; using in-process fallback" : "工具工作进程不可用,已改用进程内执行",
"tool worker execution failed" => english ? "Tool worker execution failed" : "工具工作进程执行失败",
"download host process started" => english ? "Download host process started" : "下载宿主进程已启动",
"download host read loop stopped" => english ? "Download host read loop stopped" : "下载宿主读取循环已停止",
"download started" => english ? "Download started" : "下载已开始",
"download completed" => english ? "Download completed" : "下载已完成",
"download completed and verified" => english ? "Download completed and verified" : "下载已完成并通过校验",
"installer package cleaned" => english ? "Installer package cleaned" : "安装包已清理",
"installer cleanup failed" => english ? "Installer cleanup failed" : "安装包清理失败",
"open source references loaded" => english ? "Open source references loaded" : "开源引用已加载",
"open source reference scan degraded" => english ? "Open source reference scan degraded" : "开源引用扫描已降级",
"external tool metadata load failed" => english ? "External tool metadata load failed" : "外部工具元数据加载失败",
"startup check found missing critical files or language resources. please reinstall with the latest setup package." => english ? "Startup check found missing critical files or language resources. Please reinstall with the latest setup package." : "启动自检发现关键文件或语言资源缺失,请使用最新安装包重新安装。",
"toolbox layout changed" => english ? "Toolbox layout changed" : "工具箱布局已更改",
"toolbox default scope changed" => english ? "Toolbox default scope changed" : "工具箱默认范围已更改",
"toolbox compact cards changed" => english ? "Toolbox compact cards changed" : "工具箱紧凑卡片设置已更改",
"toolbox recent-first changed" => english ? "Toolbox recent-first changed" : "工具箱最近优先设置已更改",
"hardware brand logo setting changed" => english ? "Hardware brand logo setting changed" : "硬件品牌标识设置已更改",
"risk confirmation setting changed" => english ? "Risk confirmation setting changed" : "风险确认设置已更改",
"risk confirmation reset cancelled" => english ? "Risk confirmation reset cancelled" : "风险确认重置已取消",
"risk confirmations reset from settings" => english ? "Risk confirmations reset from settings" : "已从设置重置风险确认",
"builtin tool execution cancelled" => english ? "Built-in tool execution cancelled" : "已取消内置工具执行",
"external tool launch cancelled" => english ? "External tool launch cancelled" : "已取消外部工具启动",
"builtin tool risk confirmed" => english ? "Built-in tool risk confirmed" : "已确认内置工具风险",
"external tool risk confirmed" => english ? "External tool risk confirmed" : "已确认外部工具风险",
"plugin validation failed" => english ? "Plugin validation failed" : "插件校验失败",
"plugin surface opened" => english ? "Plugin surface opened" : "插件页面已打开",
"plugin surface opened in window" => english ? "Plugin surface opened in window" : "插件窗口已打开",
"plugin host failed" => english ? "Plugin host failed" : "插件宿主异常",
"plugin window failed" => english ? "Plugin window failed" : "插件窗口异常",
"load plugin snapshot failed" => english ? "Failed to load plugin snapshot" : "插件快照加载失败",
"reload plugin catalog failed" => english ? "Failed to reload plugin catalog" : "插件目录刷新失败",
"plugin host ready" => english ? "Plugin host ready" : "插件宿主已就绪",
"plugin host process started" => english ? "Plugin host process started" : "插件宿主进程已启动",
"plugin host request timed out" => english ? "Plugin host request timed out" : "插件宿主请求超时",
"plugin host request failed" => english ? "Plugin host request failed" : "插件宿主请求失败",
"plugin host pipe failed" => english ? "Plugin host pipe failed" : "插件宿主管道异常",
"plugin background refresh failed" => english ? "Plugin background refresh failed" : "插件后台刷新失败",
"plugin watcher failed" => english ? "Plugin watcher failed" : "插件目录监听失败",
"plugin hot reload" => english ? "Plugin hot reload" : "插件热刷新",
"plugin hot reload failed" => english ? "Plugin hot reload failed" : "插件热刷新失败",
"plugin scan failed" => english ? "Plugin scan failed" : "插件扫描失败",
"plugin host failed to start" => english ? "Plugin host failed to start" : "插件宿主启动失败",
"plugin http fetch" => english ? "Plugin HTTP fetch" : "插件 HTTP 请求",
"plugin ran built-in tool" => english ? "Plugin ran built-in tool" : "插件调用了内置工具",
"feedback sent" => english ? "Feedback sent" : "反馈已发送",
"feedback send failed" => english ? "Feedback send failed" : "反馈发送失败",
"update check failed" => english ? "Update check failed" : "检查更新失败",
"update notice check failed" => english ? "Update notice check failed" : "\u66f4\u65b0\u901a\u77e5\u68c0\u67e5\u5931\u8d25",
"service status check completed" => english ? "Service status check completed" : "\u670d\u52a1\u72b6\u6001\u68c0\u67e5\u5b8c\u6210",
"install integrity check completed" => english ? "Install integrity check completed" : "\u5b89\u88c5\u5b8c\u6574\u6027\u68c0\u67e5\u5b8c\u6210",
"install integrity check failed" => english ? "Install integrity check failed" : "\u5b89\u88c5\u5b8c\u6574\u6027\u68c0\u67e5\u5931\u8d25",
"monthly install integrity check failed" => english ? "Monthly install integrity check failed" : "\u6708\u5ea6\u5b89\u88c5\u5b8c\u6574\u6027\u68c0\u67e5\u5931\u8d25",
"startup check result preload failed" => english ? "Startup check result preload failed" : "\u542f\u52a8\u81ea\u68c0\u7ed3\u679c\u9884\u52a0\u8f7d\u5931\u8d25",
"startup check report load failed" => english ? "Startup check report load failed" : "\u542f\u52a8\u81ea\u68c0\u62a5\u544a\u8bfb\u53d6\u5931\u8d25",
"settings page initialized" => english ? "Settings page initialized" : "\u8bbe\u7f6e\u9875\u5df2\u521d\u59cb\u5316",
"settings page load failed" => english ? "Settings page load failed" : "\u8bbe\u7f6e\u9875\u52a0\u8f7d\u5931\u8d25",
"close confirmation remembered" => english ? "Close confirmation remembered" : "\u5df2\u8bb0\u4f4f\u5173\u95ed\u786e\u8ba4\u9009\u62e9",
"window close requested" => english ? "Window close requested" : "\u7528\u6237\u8bf7\u6c42\u5173\u95ed\u7a97\u53e3",
"window close cancelled" => english ? "Window close cancelled" : "\u5df2\u53d6\u6d88\u5173\u95ed\u7a97\u53e3",
"window minimized to tray" => english ? "Window minimized to tray" : "\u7a97\u53e3\u5df2\u6700\u5c0f\u5316\u5230\u6258\u76d8",
"proxy diagnostic failed" => english ? "Proxy diagnostic failed" : "代理诊断失败",
_ => string.Empty
};
if (!string.IsNullOrEmpty(exact))
{
return exact;
}
var toolMatch = Regex.Match(normalized, @"^Open tool:\s*(?<tool>.+)$", RegexOptions.IgnoreCase);
if (toolMatch.Success)
{
return english ? $"Open tool: {toolMatch.Groups["tool"].Value}" : $"打开工具:{toolMatch.Groups["tool"].Value}";
}
var externalToolMatch = Regex.Match(normalized, @"^Open external tool:\s*(?<tool>.+)$", RegexOptions.IgnoreCase);
if (externalToolMatch.Success)
{
return english ? $"Open external tool: {externalToolMatch.Groups["tool"].Value}" : $"打开外部工具:{externalToolMatch.Groups["tool"].Value}";
}
var builtinToolMatch = Regex.Match(normalized, @"^Open built-in reference tool:\s*(?<tool>.+)$", RegexOptions.IgnoreCase);
if (builtinToolMatch.Success)
{
return english ? $"Open built-in reference tool: {builtinToolMatch.Groups["tool"].Value}" : $"打开内置工具:{builtinToolMatch.Groups["tool"].Value}";
}
var workerQueueMatch = Regex.Match(normalized, @"^Queued tool in worker:\s*(?<tool>.+)$", RegexOptions.IgnoreCase);
if (workerQueueMatch.Success)
{
return english ? $"Queued tool in worker: {workerQueueMatch.Groups["tool"].Value}" : $"工具已加入工作进程队列:{workerQueueMatch.Groups["tool"].Value}";
}
var workerExecuteMatch = Regex.Match(normalized, @"^Executing tool:\s*(?<tool>.+)$", RegexOptions.IgnoreCase);
if (workerExecuteMatch.Success)
{
return english ? $"Executing tool: {workerExecuteMatch.Groups["tool"].Value}" : $"正在执行工具:{workerExecuteMatch.Groups["tool"].Value}";
}
var pluginSurfaceMatch = Regex.Match(normalized, @"^Open plugin surface:\s*(?<surface>.+)$", RegexOptions.IgnoreCase);
if (pluginSurfaceMatch.Success)
{
return english ? $"Open plugin surface: {pluginSurfaceMatch.Groups["surface"].Value}" : $"打开插件页面:{pluginSurfaceMatch.Groups["surface"].Value}";
}
var externalRunMatch = Regex.Match(normalized, @"^External tool (?<result>success|failed|canceled|cancelled|timeout)(?::\s*(?<operation>.+))?$", RegexOptions.IgnoreCase);
if (externalRunMatch.Success)
{
var result = ResultText(externalRunMatch.Groups["result"].Value, english);
var operationValue = externalRunMatch.Groups["operation"].Value;
if (string.IsNullOrWhiteSpace(operationValue))
{
return english ? $"External tool {result}" : $"外部工具{result}";
}
var operation = OperationText(operationValue, english);
return english ? $"External tool {result}: {operation}" : $"外部工具{result}{operation}";
}
var builtinRunMatch = Regex.Match(normalized, @"^Builtin tool (?<result>success|failed|canceled|cancelled|timeout)(?::\s*(?<tool>.+))?$", RegexOptions.IgnoreCase);
if (builtinRunMatch.Success)
{
var result = ResultText(builtinRunMatch.Groups["result"].Value, english);
if (string.IsNullOrWhiteSpace(builtinRunMatch.Groups["tool"].Value))
{
return english ? $"Built-in tool {result}" : $"内置工具{result}";
}
return english ? $"Built-in tool {result}: {builtinRunMatch.Groups["tool"].Value}" : $"内置工具{result}{builtinRunMatch.Groups["tool"].Value}";
}
var toolResultMatch = Regex.Match(normalized, @"^Tool result (?<result>[^:]+):\s*(?<operation>.+)$", RegexOptions.IgnoreCase);
if (toolResultMatch.Success)
{
var result = ResultText(toolResultMatch.Groups["result"].Value, english);
var operation = OperationText(toolResultMatch.Groups["operation"].Value, english);
return english ? $"Tool result {result}: {operation}" : $"工具结果{result}{operation}";
}
var detectMatch = Regex.Match(normalized, @"^Detect (?<id>.+) failed$", RegexOptions.IgnoreCase);
if (detectMatch.Success)
{
return english ? $"Detect {detectMatch.Groups["id"].Value} failed" : $"检测失败:{detectMatch.Groups["id"].Value}";
}
var fetchVersionsMatch = Regex.Match(normalized, @"^Fetch versions failed:\s*(?<id>.+)$", RegexOptions.IgnoreCase);
if (fetchVersionsMatch.Success)
{
return english ? $"Fetch versions failed: {fetchVersionsMatch.Groups["id"].Value}" : $"获取版本失败:{fetchVersionsMatch.Groups["id"].Value}";
}
var startupMissingMatch = Regex.Match(normalized, @"^Startup check found (?<count>\d+) critical missing items\. Open Service status for details\.$", RegexOptions.IgnoreCase);
if (startupMissingMatch.Success)
{
return english
? $"Startup check found {startupMissingMatch.Groups["count"].Value} critical missing items. Open Service status for details."
: $"启动自检发现 {startupMissingMatch.Groups["count"].Value} 个关键缺失项,请在服务状态中查看详情。";
}
var navigationMatch = Regex.Match(normalized, @"^Navigation failed:\s*(?<page>.+)$", RegexOptions.IgnoreCase);
if (navigationMatch.Success)
{
return english ? $"Navigation failed: {navigationMatch.Groups["page"].Value}" : $"页面打开失败:{navigationMatch.Groups["page"].Value}";
}
var genericOpenMatch = Regex.Match(normalized, @"^Open\s+(?<target>.+)$", RegexOptions.IgnoreCase);
if (genericOpenMatch.Success)
{
return english ? $"Open {genericOpenMatch.Groups["target"].Value}" : $"打开 {genericOpenMatch.Groups["target"].Value}";
}
return text;
}
public static string Detail(string? detail, string language = "zh-CN", int maxLength = 600)
{
if (string.IsNullOrWhiteSpace(detail))
{
return English(language) ? "No details" : "暂无详情";
}
var safe = SensitiveText.Sanitize(detail, maxLength);
if (English(language))
{
return safe;
}
return LocalizeDetailPayload(safe);
}
private static bool English(string? language) => string.Equals(language, "en-US", StringComparison.OrdinalIgnoreCase);
private static string LocalizeDetailPayload(string detail)
{
var result = detail;
foreach (var (source, target) in new (string Source, string Target)[]
{
("result=success", "结果=成功"),
("result=failed", "结果=失败"),
("result=canceled", "结果=已取消"),
("operation=launch-admin", "操作=管理员启动"),
("operation=launch", "操作=启动"),
("operation=open-directory", "操作=打开目录"),
("operation=execute", "操作=执行"),
("operation=render", "操作=渲染"),
("operation=export", "操作=导出"),
("elapsedMs=", "耗时毫秒="),
("durationMs=", "耗时毫秒="),
("chars=", "字符数="),
("blocks=", "结果块="),
("error=", "错误="),
("detail=", "详情="),
("toolId=", "工具="),
("root=", "根目录="),
("count=", "数量="),
("toolNotices=", "工具声明数="),
("source=tray", "来源=托盘"),
("behavior=exit_directly", "行为=直接退出"),
("behavior=minimize_to_tray", "行为=最小化到托盘"),
("behavior=ask", "行为=询问"),
("dialog=com_exception", "弹窗=COM 异常"),
("result=primary", "结果=主按钮"),
("result=secondary", "结果=次按钮"),
("result=cancel", "结果=取消"),
("mode=", "模式="),
("scope=", "范围="),
("source=", "来源="),
("behavior=", "行为="),
("dialog=", "弹窗="),
("native=", "原生工具="),
("builtin=", "内置工具="),
("external=", "外部工具="),
("plugin=", "插件="),
("language=", "语言="),
("enabled=True", "已启用=是"),
("enabled=False", "已启用=否"),
("enabled=true", "已启用=是"),
("enabled=false", "已启用=否"),
("remembered=True", "已记住=是"),
("remembered=False", "已记住=否"),
("remembered=true", "已记住=是"),
("remembered=false", "已记住=否")
})
{
result = result.Replace(source, target, StringComparison.OrdinalIgnoreCase);
}
return result;
}
private static string ResultText(string value, bool english)
{
return value.Trim().ToLowerInvariant() switch
{
"success" => english ? "success" : "成功",
"failed" => english ? "failed" : "失败",
"canceled" or "cancelled" => english ? "canceled" : "已取消",
"timeout" => english ? "timeout" : "超时",
var other => english ? other : other
};
}
private static string OperationText(string value, bool english)
{
return value.Trim().ToLowerInvariant() switch
{
"launch" => english ? "launch" : "启动",
"launch-admin" => english ? "launch as administrator" : "管理员启动",
"open-directory" => english ? "open directory" : "打开目录",
"execute" => english ? "execute" : "执行",
"render" => english ? "render" : "渲染",
"export" => english ? "export" : "导出",
var other => english ? other : other
};
}
private static string Humanize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
var words = value.Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return string.Join(" ", words.Select(word => char.ToUpperInvariant(word[0]) + word[1..]));
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace YMhut.Box.Core.Logging;
public sealed record LogEntry(
DateTimeOffset Timestamp,
string Level,
string Category,
string Message,
string? Detail = null);
@@ -0,0 +1,270 @@
using System.Text.Json;
using YMhut.Box.Core.App;
namespace YMhut.Box.Core.Logging;
public sealed class ResilientLogService(ILogService primary, AppPaths paths) : ILogService
{
private readonly SemaphoreSlim _fallbackGate = new(1, 1);
public event EventHandler<LogEntry>? EntryWritten;
public string LogPath => primary.LogPath;
private string FallbackPath => Path.Combine(paths.Logs, "app-log-fallback.jsonl");
public async Task WriteAsync(
string level,
string category,
string message,
string? detail = null,
CancellationToken cancellationToken = default)
{
var entry = new LogEntry(DateTimeOffset.Now, level, category, message, detail);
try
{
await primary.WriteAsync(level, category, message, detail, cancellationToken).ConfigureAwait(false);
EntryWritten?.Invoke(this, entry);
}
catch (Exception exception)
{
var fallbackEntry = entry with
{
Detail = string.Join(Environment.NewLine, new[]
{
detail,
$"SQLite logging degraded: {exception.GetType().Name}: {Sanitize(exception.Message)}"
}.Where(value => !string.IsNullOrWhiteSpace(value)))
};
await WriteFallbackAsync(fallbackEntry, cancellationToken).ConfigureAwait(false);
EntryWritten?.Invoke(this, fallbackEntry);
}
}
public async Task<IReadOnlyList<LogEntry>> ReadAsync(
string? level = null,
string? category = null,
int take = 500,
CancellationToken cancellationToken = default)
{
try
{
return await primary.ReadAsync(level, category, take, cancellationToken).ConfigureAwait(false);
}
catch
{
return (await ReadFallbackAsync(cancellationToken).ConfigureAwait(false))
.Where(entry => string.IsNullOrWhiteSpace(level) || entry.Level == level)
.Where(entry => string.IsNullOrWhiteSpace(category) || entry.Category == category)
.OrderByDescending(entry => entry.Timestamp)
.Take(Math.Clamp(take, 1, 5000))
.ToArray();
}
}
public async Task<IReadOnlyList<LogEntry>> ReadByDateAsync(
DateOnly date,
string? level = null,
string? query = null,
int take = 0,
CancellationToken cancellationToken = default)
{
try
{
return await primary.ReadByDateAsync(date, level, query, take, cancellationToken).ConfigureAwait(false);
}
catch
{
var entries = FilterFallbackByDate(await ReadFallbackAsync(cancellationToken).ConfigureAwait(false), date, level, query);
return take > 0 ? entries.Take(take).ToArray() : entries.ToArray();
}
}
public async Task<IReadOnlyList<LogEntry>> ReadByDatePageAsync(
DateOnly date,
string? level = null,
string? query = null,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default)
{
try
{
return await primary.ReadByDatePageAsync(date, level, query, skip, take, cancellationToken).ConfigureAwait(false);
}
catch
{
return FilterFallbackByDate(await ReadFallbackAsync(cancellationToken).ConfigureAwait(false), date, level, query)
.Skip(Math.Max(0, skip))
.Take(Math.Clamp(take, 1, 5000))
.ToArray();
}
}
public async Task CleanupAsync(int retentionCount, CancellationToken cancellationToken = default)
{
try
{
await primary.CleanupAsync(retentionCount, cancellationToken).ConfigureAwait(false);
}
catch
{
await TrimFallbackAsync(retentionCount, cancellationToken).ConfigureAwait(false);
}
}
public async Task ClearAllAsync(CancellationToken cancellationToken = default)
{
try
{
await primary.ClearAllAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
}
await ClearFallbackAsync(_ => true, cancellationToken).ConfigureAwait(false);
}
public async Task ClearTodayAsync(CancellationToken cancellationToken = default)
{
try
{
await primary.ClearTodayAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
}
var today = DateOnly.FromDateTime(DateTime.Now);
await ClearFallbackAsync(entry => DateOnly.FromDateTime(entry.Timestamp.LocalDateTime) == today, cancellationToken).ConfigureAwait(false);
}
public async Task ClearFilteredTodayAsync(
string? level = null,
string? query = null,
CancellationToken cancellationToken = default)
{
try
{
await primary.ClearFilteredTodayAsync(level, query, cancellationToken).ConfigureAwait(false);
}
catch
{
}
var today = DateOnly.FromDateTime(DateTime.Now);
await ClearFallbackAsync(entry =>
DateOnly.FromDateTime(entry.Timestamp.LocalDateTime) == today &&
(string.IsNullOrWhiteSpace(level) || entry.Level == level) &&
Matches(entry, query), cancellationToken).ConfigureAwait(false);
}
private async Task WriteFallbackAsync(LogEntry entry, CancellationToken cancellationToken)
{
await _fallbackGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
Directory.CreateDirectory(paths.Logs);
var json = JsonSerializer.Serialize(entry);
await File.AppendAllTextAsync(FallbackPath, json + Environment.NewLine, cancellationToken).ConfigureAwait(false);
}
finally
{
_fallbackGate.Release();
}
}
private async Task<IReadOnlyList<LogEntry>> ReadFallbackAsync(CancellationToken cancellationToken)
{
await _fallbackGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!File.Exists(FallbackPath))
{
return [];
}
var entries = new List<LogEntry>();
foreach (var line in await File.ReadAllLinesAsync(FallbackPath, cancellationToken).ConfigureAwait(false))
{
try
{
var entry = JsonSerializer.Deserialize<LogEntry>(line);
if (entry is not null)
{
entries.Add(entry);
}
}
catch (JsonException)
{
}
}
return entries;
}
finally
{
_fallbackGate.Release();
}
}
private async Task TrimFallbackAsync(int retentionCount, CancellationToken cancellationToken)
{
var keep = Math.Max(0, retentionCount);
var entries = (await ReadFallbackAsync(cancellationToken).ConfigureAwait(false))
.OrderByDescending(entry => entry.Timestamp)
.Take(keep)
.Reverse()
.ToArray();
await RewriteFallbackAsync(entries, cancellationToken).ConfigureAwait(false);
}
private async Task ClearFallbackAsync(Func<LogEntry, bool> predicate, CancellationToken cancellationToken)
{
var entries = (await ReadFallbackAsync(cancellationToken).ConfigureAwait(false))
.Where(entry => !predicate(entry))
.ToArray();
await RewriteFallbackAsync(entries, cancellationToken).ConfigureAwait(false);
}
private async Task RewriteFallbackAsync(IReadOnlyList<LogEntry> entries, CancellationToken cancellationToken)
{
await _fallbackGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
Directory.CreateDirectory(paths.Logs);
var lines = entries.Select(entry => JsonSerializer.Serialize(entry)).ToArray();
await File.WriteAllLinesAsync(FallbackPath, lines, cancellationToken).ConfigureAwait(false);
}
finally
{
_fallbackGate.Release();
}
}
private static IReadOnlyList<LogEntry> FilterFallbackByDate(IReadOnlyList<LogEntry> entries, DateOnly date, string? level, string? query)
{
return entries
.Where(entry => DateOnly.FromDateTime(entry.Timestamp.LocalDateTime) == date)
.Where(entry => string.IsNullOrWhiteSpace(level) || entry.Level == level)
.Where(entry => Matches(entry, query))
.OrderByDescending(entry => entry.Timestamp)
.ToArray();
}
private static bool Matches(LogEntry entry, string? query)
{
if (string.IsNullOrWhiteSpace(query))
{
return true;
}
return entry.Message.Contains(query, StringComparison.OrdinalIgnoreCase) ||
entry.Category.Contains(query, StringComparison.OrdinalIgnoreCase) ||
(entry.Detail?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false);
}
private static string Sanitize(string value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Length > 240 ? value[..240] : value;
}
@@ -0,0 +1,378 @@
using Microsoft.Data.Sqlite;
using YMhut.Box.Core.App;
namespace YMhut.Box.Core.Logging;
public sealed class SqliteLogService : ILogService
{
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _initialized;
public SqliteLogService(AppPaths paths)
{
paths.EnsureCreated();
LogPath = AppDatabasePaths.ResolveMainDatabasePath(paths);
}
public event EventHandler<LogEntry>? EntryWritten;
public string LogPath { get; }
public async Task WriteAsync(
string level,
string category,
string message,
string? detail = null,
CancellationToken cancellationToken = default)
{
var entry = new LogEntry(DateTimeOffset.Now, level, category, message, detail);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO logs(timestamp, level, category, message, detail)
VALUES ($timestamp, $level, $category, $message, $detail);
""";
command.Parameters.AddWithValue("$timestamp", entry.Timestamp.ToString("O"));
command.Parameters.AddWithValue("$level", entry.Level);
command.Parameters.AddWithValue("$category", entry.Category);
command.Parameters.AddWithValue("$message", entry.Message);
command.Parameters.AddWithValue("$detail", (object?)entry.Detail ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
EntryWritten?.Invoke(this, entry);
}
public async Task<IReadOnlyList<LogEntry>> ReadAsync(
string? level = null,
string? category = null,
int take = 500,
CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT timestamp, level, category, message, detail
FROM logs
WHERE ($level = '' OR level = $level)
AND ($category = '' OR category = $category)
ORDER BY timestamp DESC
LIMIT $take;
""";
command.Parameters.AddWithValue("$level", level ?? string.Empty);
command.Parameters.AddWithValue("$category", category ?? string.Empty);
command.Parameters.AddWithValue("$take", Math.Clamp(take, 1, 5000));
var entries = new List<LogEntry>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
entries.Add(new LogEntry(
DateTimeOffset.TryParse(reader.GetString(0), out var timestamp) ? timestamp : DateTimeOffset.Now,
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.IsDBNull(4) ? null : reader.GetString(4)));
}
return entries;
}
finally
{
_gate.Release();
}
}
public async Task<IReadOnlyList<LogEntry>> ReadByDateAsync(
DateOnly date,
string? level = null,
string? query = null,
int take = 0,
CancellationToken cancellationToken = default)
{
return await ReadByDatePageCoreAsync(
date,
level,
query,
skip: 0,
take,
usePaging: take > 0,
cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<LogEntry>> ReadByDatePageAsync(
DateOnly date,
string? level = null,
string? query = null,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default)
{
return await ReadByDatePageCoreAsync(
date,
level,
query,
Math.Max(0, skip),
Math.Clamp(take, 1, 5000),
usePaging: true,
cancellationToken).ConfigureAwait(false);
}
private async Task<IReadOnlyList<LogEntry>> ReadByDatePageCoreAsync(
DateOnly date,
string? level,
string? query,
int skip,
int take,
bool usePaging,
CancellationToken cancellationToken)
{
var start = date.ToDateTime(TimeOnly.MinValue);
var end = start.AddDays(1);
var normalizedQuery = query?.Trim() ?? string.Empty;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = usePaging
? """
SELECT timestamp, level, category, message, detail
FROM logs
WHERE timestamp >= $start
AND timestamp < $end
AND ($level = '' OR level = $level)
AND (
$query = ''
OR message LIKE $like
OR category LIKE $like
OR IFNULL(detail, '') LIKE $like
)
ORDER BY timestamp DESC
LIMIT $take OFFSET $skip;
"""
: """
SELECT timestamp, level, category, message, detail
FROM logs
WHERE timestamp >= $start
AND timestamp < $end
AND ($level = '' OR level = $level)
AND (
$query = ''
OR message LIKE $like
OR category LIKE $like
OR IFNULL(detail, '') LIKE $like
)
ORDER BY timestamp DESC;
""";
command.Parameters.AddWithValue("$start", start.ToString("O"));
command.Parameters.AddWithValue("$end", end.ToString("O"));
command.Parameters.AddWithValue("$level", level ?? string.Empty);
command.Parameters.AddWithValue("$query", normalizedQuery);
command.Parameters.AddWithValue("$like", $"%{normalizedQuery}%");
if (usePaging)
{
command.Parameters.AddWithValue("$take", Math.Clamp(take, 1, 5000));
command.Parameters.AddWithValue("$skip", Math.Max(0, skip));
}
var entries = new List<LogEntry>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
entries.Add(new LogEntry(
DateTimeOffset.TryParse(reader.GetString(0), out var timestamp) ? timestamp : DateTimeOffset.Now,
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.IsDBNull(4) ? null : reader.GetString(4)));
}
return entries;
}
finally
{
_gate.Release();
}
}
public async Task CleanupAsync(int retentionCount, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
var keep = Math.Max(0, retentionCount);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = keep == 0
? "DELETE FROM logs;"
: """
DELETE FROM logs
WHERE id NOT IN (
SELECT id FROM logs ORDER BY timestamp DESC LIMIT $keep
);
""";
if (keep > 0)
{
command.Parameters.AddWithValue("$keep", keep);
}
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await VacuumAsync(connection, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task ClearAllAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM logs;";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await VacuumAsync(connection, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task ClearTodayAsync(CancellationToken cancellationToken = default)
{
var today = DateTimeOffset.Now.Date;
var tomorrow = today.AddDays(1);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM logs
WHERE timestamp >= $today
AND timestamp < $tomorrow;
""";
command.Parameters.AddWithValue("$today", today.ToString("O"));
command.Parameters.AddWithValue("$tomorrow", tomorrow.ToString("O"));
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await VacuumAsync(connection, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task ClearFilteredTodayAsync(
string? level = null,
string? query = null,
CancellationToken cancellationToken = default)
{
var today = DateTimeOffset.Now.Date;
var tomorrow = today.AddDays(1);
var normalizedQuery = query?.Trim() ?? string.Empty;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM logs
WHERE timestamp >= $today
AND timestamp < $tomorrow
AND ($level = '' OR level = $level)
AND (
$query = ''
OR message LIKE $like
OR category LIKE $like
OR IFNULL(detail, '') LIKE $like
);
""";
command.Parameters.AddWithValue("$today", today.ToString("O"));
command.Parameters.AddWithValue("$tomorrow", tomorrow.ToString("O"));
command.Parameters.AddWithValue("$level", level ?? string.Empty);
command.Parameters.AddWithValue("$query", normalizedQuery);
command.Parameters.AddWithValue("$like", $"%{normalizedQuery}%");
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await VacuumAsync(connection, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private async Task EnsureInitializedAsync(CancellationToken cancellationToken)
{
if (_initialized)
{
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
level TEXT NOT NULL,
category TEXT NOT NULL,
message TEXT NOT NULL,
detail TEXT NULL
);
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_logs_timestamp_level ON logs(timestamp DESC, level);
CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level);
CREATE INDEX IF NOT EXISTS idx_logs_category ON logs(category);
""";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_initialized = true;
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection($"Data Source={LogPath};Pooling=True");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = """
PRAGMA busy_timeout = 5000;
PRAGMA synchronous = NORMAL;
""";
command.ExecuteNonQuery();
return connection;
}
private static async Task VacuumAsync(SqliteConnection connection, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = "VACUUM;";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,52 @@
namespace YMhut.Box.Core.Media;
public enum MediaVolumeOutputMode
{
Silent,
Normal,
Boosted
}
public readonly record struct MediaVolumeState(
int Percent,
double PlatformVolume,
double AudioGain,
MediaVolumeOutputMode Mode)
{
public bool IsMuted => Percent <= 0;
public bool IsBoosted => Mode == MediaVolumeOutputMode.Boosted;
}
public static class MediaVolumeModel
{
public const int MinPercent = 0;
public const int MaxPercent = 120;
public const int NormalMaxPercent = 100;
public static MediaVolumeState FromPercent(double percent)
{
var normalized = (int)Math.Round(Math.Clamp(percent, MinPercent, MaxPercent), MidpointRounding.AwayFromZero);
return new MediaVolumeState(
normalized,
normalized <= 0 ? 0 : Math.Min(normalized, NormalMaxPercent) / 100d,
normalized / 100d,
normalized <= 0
? MediaVolumeOutputMode.Silent
: normalized > NormalMaxPercent
? MediaVolumeOutputMode.Boosted
: MediaVolumeOutputMode.Normal);
}
public static string FormatPercent(int percent, string language = "zh-CN")
{
var state = FromPercent(percent);
var english = string.Equals(language, "en-US", StringComparison.OrdinalIgnoreCase);
return state.Mode switch
{
MediaVolumeOutputMode.Silent => english ? "0% (Muted)" : "0%(静音)",
MediaVolumeOutputMode.Boosted => english ? $"{state.Percent}% (Boosted)" : $"{state.Percent}%(增强音量)",
_ => $"{state.Percent}%"
};
}
}
@@ -0,0 +1,673 @@
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace YMhut.Box.Core.Media;
public enum RemoteMediaKind
{
Unknown,
Image,
Audio,
Video
}
public enum RemoteMediaCatalogLoadSource
{
Remote,
Cache
}
public sealed record RemoteMediaCatalog(
IReadOnlyList<RemoteMediaCategory> Categories,
RemoteMediaUiConfig UiConfig,
string LastUpdated,
string LayoutVersion,
DateTimeOffset LoadedAt)
{
public IEnumerable<RemoteMediaCategory> EnabledCategories =>
Categories.Where(category => category.Enabled);
public IEnumerable<RemoteMediaEntry> Entries(bool enabledOnly = true)
{
foreach (var category in enabledOnly ? EnabledCategories : Categories)
{
foreach (var source in category.Sources)
{
yield return new RemoteMediaEntry(category, source);
}
}
}
}
public sealed record RemoteMediaEntry(RemoteMediaCategory Category, RemoteMediaSource Source);
public sealed record RemoteMediaCategory(
string Id,
string Name,
string Icon,
bool Enabled,
RemoteMediaKind Kind,
RemoteMediaLayout Layout,
IReadOnlyList<RemoteMediaSource> Sources)
{
public string DisplayName => RemoteMediaCatalogNames.CategoryName(Id, Name, Kind);
public IReadOnlyList<RemoteMediaSource> Subcategories => Sources;
}
public sealed record RemoteMediaSource(
string Id,
string Name,
string Description,
string ApiUrl,
string ThumbnailUrl,
bool Downloadable,
int RefreshIntervalSeconds,
IReadOnlyList<string> SupportedFormats,
RemoteMediaKind Kind)
{
public bool IsAvailable => Uri.TryCreate(ApiUrl, UriKind.Absolute, out _);
public string DisplayName => RemoteMediaCatalogNames.SourceName(Id, Name);
public string DisplayDescription => RemoteMediaCatalogNames.Description(Description);
public int RefreshInterval => RefreshIntervalSeconds;
}
public sealed record RemoteMediaLayout(
int Columns,
string AspectRatio,
bool ShowPreview,
bool AutoPlay,
string TransitionEffect);
public sealed record RemoteMediaUiConfig(
bool DarkMode,
bool ShowThumbnails,
string DefaultView,
RemoteMediaAnimationConfig Animations);
public sealed record RemoteMediaAnimationConfig(
string TransitionEffect,
int DurationMilliseconds);
public sealed record RemoteMediaCatalogLoadResult(
RemoteMediaCatalog Catalog,
RemoteMediaCatalogLoadSource Source,
string? Warning = null);
public static class RemoteMediaCatalogParser
{
private static readonly JsonDocumentOptions DocumentOptions = new()
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
};
public static RemoteMediaCatalog Parse(string content, DateTimeOffset? loadedAt = null)
{
if (string.IsNullOrWhiteSpace(content))
{
throw new InvalidDataException("Remote media configuration is empty.");
}
using var document = ParseDocument(content);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object ||
!TryGet(root, out var categoriesElement, "categories") ||
categoriesElement.ValueKind != JsonValueKind.Array)
{
throw new InvalidDataException("Remote media configuration does not contain categories.");
}
var uiConfig = ParseUiConfig(root);
var categories = new List<RemoteMediaCategory>();
foreach (var categoryElement in categoriesElement.EnumerateArray())
{
if (categoryElement.ValueKind != JsonValueKind.Object)
{
continue;
}
var id = JsonString(categoryElement, "id");
var categoryKind = InferKind(id, []);
var sources = ParseSources(categoryElement, categoryKind);
if (categoryKind == RemoteMediaKind.Unknown)
{
categoryKind = InferKind(id, sources.SelectMany(source => source.SupportedFormats));
}
var normalizedSources = sources
.Select(source => source.Kind == RemoteMediaKind.Unknown ? source with { Kind = categoryKind } : source)
.ToArray();
categories.Add(new RemoteMediaCategory(
Id: string.IsNullOrWhiteSpace(id) ? $"category-{categories.Count + 1}" : id,
Name: JsonString(categoryElement, "name"),
Icon: JsonString(categoryElement, "icon"),
Enabled: JsonBool(categoryElement, true, "enabled"),
Kind: categoryKind,
Layout: ParseLayout(categoryElement, categoryKind, uiConfig),
Sources: normalizedSources));
}
if (categories.Count == 0)
{
throw new InvalidDataException("Remote media configuration contains no usable categories.");
}
return new RemoteMediaCatalog(
Categories: categories,
UiConfig: uiConfig,
LastUpdated: JsonString(root, "last_updated", "lastUpdated"),
LayoutVersion: JsonString(root, "layout_version", "layoutVersion"),
LoadedAt: loadedAt ?? DateTimeOffset.Now);
}
public static bool TryParse(string content, out RemoteMediaCatalog catalog)
{
try
{
catalog = Parse(content);
return true;
}
catch
{
catalog = new RemoteMediaCatalog([], DefaultUiConfig(), string.Empty, string.Empty, DateTimeOffset.Now);
return false;
}
}
private static JsonDocument ParseDocument(string content)
{
try
{
return JsonDocument.Parse(content, DocumentOptions);
}
catch (JsonException)
{
var repaired = RepairBrokenQuotedLines(content);
return JsonDocument.Parse(repaired, DocumentOptions);
}
}
private static string RepairBrokenQuotedLines(string content)
{
var normalized = content.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n');
var lines = normalized.Split('\n');
for (var index = 0; index < lines.Length; index++)
{
lines[index] = RepairBrokenQuotedLine(lines[index]);
}
return string.Join('\n', lines);
}
private static string RepairBrokenQuotedLine(string line)
{
var colon = line.IndexOf(':');
if (colon < 0)
{
return line;
}
var valueStart = line.IndexOf('"', colon + 1);
if (valueStart < 0)
{
return line;
}
var between = line[(colon + 1)..valueStart];
if (!string.IsNullOrWhiteSpace(between))
{
return line;
}
var tail = line[(valueStart + 1)..];
if (ContainsUnescapedQuote(tail))
{
return line;
}
var trimmed = line.TrimEnd();
var endPadding = line.Length - trimmed.Length;
var insertAt = trimmed.EndsWith(",", StringComparison.Ordinal)
? line.LastIndexOf(',', line.Length - endPadding - 1)
: line.Length - endPadding;
return insertAt > valueStart
? line.Insert(insertAt, "\"")
: line;
}
private static bool ContainsUnescapedQuote(string value)
{
var escaped = false;
foreach (var ch in value)
{
if (escaped)
{
escaped = false;
continue;
}
if (ch == '\\')
{
escaped = true;
continue;
}
if (ch == '"')
{
return true;
}
}
return false;
}
private static RemoteMediaUiConfig ParseUiConfig(JsonElement root)
{
if (!TryGet(root, out var ui, "ui_config", "uiConfig") || ui.ValueKind != JsonValueKind.Object)
{
return DefaultUiConfig();
}
var animation = DefaultUiConfig().Animations;
if (TryGet(ui, out var animations, "animations") && animations.ValueKind == JsonValueKind.Object)
{
animation = new RemoteMediaAnimationConfig(
TransitionEffect: NonEmpty(JsonString(animations, "transition_effect", "transitionEffect"), animation.TransitionEffect),
DurationMilliseconds: Math.Clamp(JsonInt(animations, animation.DurationMilliseconds, "duration", "duration_ms", "durationMilliseconds"), 50, 3000));
}
return new RemoteMediaUiConfig(
DarkMode: JsonBool(ui, false, "dark_mode", "darkMode"),
ShowThumbnails: JsonBool(ui, true, "show_thumbnails", "showThumbnails"),
DefaultView: NonEmpty(JsonString(ui, "default_view", "defaultView"), "grid"),
Animations: animation);
}
private static RemoteMediaUiConfig DefaultUiConfig()
{
return new RemoteMediaUiConfig(
DarkMode: false,
ShowThumbnails: true,
DefaultView: "grid",
Animations: new RemoteMediaAnimationConfig("fade", 300));
}
private static IReadOnlyList<RemoteMediaSource> ParseSources(JsonElement category, RemoteMediaKind categoryKind)
{
if (!TryGet(category, out var subcategories, "subcategories", "sources") ||
subcategories.ValueKind != JsonValueKind.Array)
{
return [];
}
var sources = new List<RemoteMediaSource>();
foreach (var sourceElement in subcategories.EnumerateArray())
{
if (sourceElement.ValueKind != JsonValueKind.Object)
{
continue;
}
var id = JsonString(sourceElement, "id");
var formats = JsonStringArray(sourceElement, "supported_formats", "supportedFormats");
var kind = InferKind(id, formats);
if (kind == RemoteMediaKind.Unknown)
{
kind = categoryKind;
}
if (formats.Count == 0)
{
formats = DefaultFormats(kind);
}
var apiUrl = JsonString(sourceElement, "api_url", "apiUrl", "url");
var thumbnailUrl = JsonString(sourceElement, "thumbnail_url", "thumbnailUrl", "thumbnail", "cover");
sources.Add(new RemoteMediaSource(
Id: string.IsNullOrWhiteSpace(id) ? $"source-{sources.Count + 1}" : id,
Name: JsonString(sourceElement, "name"),
Description: JsonString(sourceElement, "description"),
ApiUrl: apiUrl,
ThumbnailUrl: string.IsNullOrWhiteSpace(thumbnailUrl) ? apiUrl : thumbnailUrl,
Downloadable: JsonBool(sourceElement, true, "downloadable"),
RefreshIntervalSeconds: NormalizedRefreshInterval(sourceElement, kind),
SupportedFormats: formats,
Kind: kind));
}
return sources;
}
private static RemoteMediaLayout ParseLayout(JsonElement category, RemoteMediaKind kind, RemoteMediaUiConfig uiConfig)
{
var columns = 1;
var aspectRatio = "16:9";
var showPreview = uiConfig.ShowThumbnails;
var autoPlay = false;
var transition = uiConfig.Animations.TransitionEffect;
if (TryGet(category, out var layout, "layout") && layout.ValueKind == JsonValueKind.Object)
{
columns = JsonInt(layout, columns, "columns");
aspectRatio = NonEmpty(JsonString(layout, "aspect_ratio", "aspectRatio"), aspectRatio);
showPreview = JsonBool(layout, showPreview, "show_preview", "showPreview");
autoPlay = JsonBool(layout, false, "auto_play", "autoPlay");
transition = NonEmpty(JsonString(layout, "transition_effect", "transitionEffect"), transition);
}
if (kind == RemoteMediaKind.Audio)
{
showPreview = false;
}
return new RemoteMediaLayout(
Columns: Math.Clamp(columns, 1, 4),
AspectRatio: aspectRatio,
ShowPreview: showPreview,
AutoPlay: autoPlay,
TransitionEffect: transition);
}
private static int NormalizedRefreshInterval(JsonElement source, RemoteMediaKind kind)
{
var fallback = kind == RemoteMediaKind.Video ? 60 : 30;
var value = JsonInt(source, fallback, "refresh_interval", "refreshInterval");
return Math.Clamp(value <= 0 ? fallback : value, 5, 3600);
}
private static List<string> DefaultFormats(RemoteMediaKind kind)
{
return kind switch
{
RemoteMediaKind.Video => ["mp4", "webm"],
RemoteMediaKind.Audio => ["mp3", "wav", "m4a", "ogg"],
_ => ["jpg", "jpeg", "png", "webp"]
};
}
public static RemoteMediaKind InferKind(string id, IEnumerable<string> formats)
{
var normalizedId = id.Trim().ToLowerInvariant();
if (normalizedId.Contains("video", StringComparison.Ordinal) ||
normalizedId.Contains("sp", StringComparison.Ordinal) ||
normalizedId.Contains("mv", StringComparison.Ordinal))
{
return RemoteMediaKind.Video;
}
if (normalizedId.Contains("audio", StringComparison.Ordinal) ||
normalizedId.Contains("music", StringComparison.Ordinal))
{
return RemoteMediaKind.Audio;
}
if (normalizedId.Contains("image", StringComparison.Ordinal) ||
normalizedId.Contains("img", StringComparison.Ordinal) ||
normalizedId.Contains("pic", StringComparison.Ordinal))
{
return RemoteMediaKind.Image;
}
var kind = RemoteMediaKind.Unknown;
foreach (var format in formats.Select(format => format.Trim().TrimStart('.').ToLowerInvariant()))
{
if (format is "mp4" or "mkv" or "webm" or "avi" or "mov" or "wmv" or "m4v")
{
return RemoteMediaKind.Video;
}
if (format is "mp3" or "wav" or "flac" or "aac" or "m4a" or "ogg" or "wma")
{
kind = RemoteMediaKind.Audio;
}
if (kind == RemoteMediaKind.Unknown &&
format is "png" or "jpg" or "jpeg" or "bmp" or "gif" or "webp" or "tif" or "tiff")
{
kind = RemoteMediaKind.Image;
}
}
return kind;
}
private static string JsonString(JsonElement root, params string[] names)
{
if (!TryGet(root, out var value, names))
{
return string.Empty;
}
return value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => value.ToString(),
_ => string.Empty
};
}
private static bool JsonBool(JsonElement root, bool fallback, params string[] names)
{
if (!TryGet(root, out var value, names))
{
return fallback;
}
return value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed,
_ => fallback
};
}
private static int JsonInt(JsonElement root, int fallback, params string[] names)
{
if (!TryGet(root, out var value, names))
{
return fallback;
}
return value.ValueKind switch
{
JsonValueKind.Number when value.TryGetInt32(out var parsed) => parsed,
JsonValueKind.String when int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed,
_ => fallback
};
}
private static List<string> JsonStringArray(JsonElement root, params string[] names)
{
if (!TryGet(root, out var value, names))
{
return [];
}
if (value.ValueKind == JsonValueKind.Array)
{
return value
.EnumerateArray()
.Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString())
.Select(item => item.Trim().TrimStart('.').ToLowerInvariant())
.Where(item => item.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
if (value.ValueKind == JsonValueKind.String)
{
return Regex.Split(value.GetString() ?? string.Empty, @"[\s,;/|]+")
.Select(item => item.Trim().TrimStart('.').ToLowerInvariant())
.Where(item => item.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
return [];
}
private static bool TryGet(JsonElement root, out JsonElement value, params string[] names)
{
value = default;
if (root.ValueKind != JsonValueKind.Object)
{
return false;
}
foreach (var name in names)
{
if (root.TryGetProperty(name, out value))
{
return true;
}
}
foreach (var property in root.EnumerateObject())
{
if (names.Any(name => string.Equals(name, property.Name, StringComparison.OrdinalIgnoreCase)))
{
value = property.Value;
return true;
}
}
return false;
}
private static string NonEmpty(string value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
public static class RemoteMediaCatalogFormatter
{
public static string FormatRandomSource(RemoteMediaCatalog catalog, string input, string language = "zh-CN")
{
var query = (input ?? string.Empty).Trim();
var entries = catalog
.Entries()
.Where(entry => entry.Source.IsAvailable)
.ToList();
if (!string.IsNullOrWhiteSpace(query))
{
entries = entries
.Where(entry =>
Contains(entry.Category.Id, query) ||
Contains(entry.Category.DisplayName, query) ||
Contains(entry.Source.Id, query) ||
Contains(entry.Source.DisplayName, query) ||
Contains(entry.Source.DisplayDescription, query))
.ToList();
}
if (entries.Count == 0)
{
throw new InvalidOperationException(T(language, "没有可用的远程媒体源。", "No available remote media sources."));
}
var selected = entries[Random.Shared.Next(entries.Count)];
var source = selected.Source;
var category = selected.Category;
var lines = new[]
{
T(language, "随机放映室远程源", "Random Cinema remote source"),
T(language, $"配置:远程媒体目录 {VersionText(catalog)}", $"Configuration: remote media catalog {VersionText(catalog)}"),
T(language, $"分类:{category.DisplayName} ({category.Id})", $"Category: {category.DisplayName} ({category.Id})"),
T(language, $"媒体:{source.DisplayName} ({source.Id})", $"Media: {source.DisplayName} ({source.Id})"),
T(language, $"说明:{source.DisplayDescription}", $"Description: {source.DisplayDescription}"),
T(language, "媒体源:已隐藏远程地址", "Media source: remote address hidden"),
T(language, "缩略图:已隐藏远程地址", "Thumbnail: remote address hidden"),
T(language, $"格式:{string.Join(" / ", source.SupportedFormats)}", $"Formats: {string.Join(" / ", source.SupportedFormats)}"),
T(language, $"建议刷新间隔:{source.RefreshIntervalSeconds} 秒", $"Refresh interval: {source.RefreshIntervalSeconds} seconds"),
T(language, source.Downloadable ? "下载:允许" : "下载:禁用", source.Downloadable ? "Download: allowed" : "Download: disabled"),
T(language,
$"布局:{category.Layout.Columns} 列 / {category.Layout.AspectRatio} / {(category.Layout.ShowPreview ? "" : "")} / {(category.Layout.AutoPlay ? "" : "")}",
$"Layout: {category.Layout.Columns} columns / {category.Layout.AspectRatio} / {(category.Layout.ShowPreview ? "preview on" : "preview off")} / {(category.Layout.AutoPlay ? "autoplay" : "manual play")}")
};
return string.Join(Environment.NewLine, lines.Where(line => !string.IsNullOrWhiteSpace(line)));
}
private static bool Contains(string value, string query)
=> value.Contains(query, StringComparison.OrdinalIgnoreCase);
private static string VersionText(RemoteMediaCatalog catalog)
=> string.IsNullOrWhiteSpace(catalog.LayoutVersion) ? string.Empty : $"v{catalog.LayoutVersion}";
private static string T(string language, string zh, string en)
=> string.Equals(language, "en-US", StringComparison.OrdinalIgnoreCase) ? en : zh;
}
internal static class RemoteMediaCatalogNames
{
private static readonly IReadOnlyDictionary<string, string> SourceFallbacks = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["xjj"] = "Portrait",
["baisi"] = "Light style",
["heisi"] = "Dark style",
["acg"] = "ACG 4K",
["miku"] = "Miku",
["wappller"] = "HD wallpaper",
["radom_xjj_leixing"] = "Style videos",
["radom_xjj_short"] = "Short videos",
["radom_xjj_mv"] = "JK video",
["radom_xjj_menv"] = "Random video"
};
public static string CategoryName(string id, string name, RemoteMediaKind kind)
{
if (!LooksBroken(name))
{
return name;
}
return kind switch
{
RemoteMediaKind.Video => "Random Videos",
RemoteMediaKind.Audio => "Random Audio",
RemoteMediaKind.Image => "Random Images",
_ => string.IsNullOrWhiteSpace(id) ? "Remote Media" : id
};
}
public static string SourceName(string id, string name)
{
if (!LooksBroken(name))
{
return name;
}
return SourceFallbacks.TryGetValue(id, out var fallback)
? fallback
: string.IsNullOrWhiteSpace(id) ? "Remote source" : id;
}
public static string Description(string description)
{
return LooksBroken(description)
? "Remote random media source with reload, preview, and save support."
: description;
}
private static bool LooksBroken(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
return value.Contains('\uFFFD') ||
value.Contains("锟", StringComparison.Ordinal) ||
value.Contains("闅", StringComparison.Ordinal) ||
value.Contains("濮", StringComparison.Ordinal) ||
value.Contains("绮", StringComparison.Ordinal) ||
value.Contains("瑙", StringComparison.Ordinal) ||
value.Contains("鐭", StringComparison.Ordinal);
}
}
@@ -0,0 +1,130 @@
using System.Text.Json;
using YMhut.Box.Core.Api;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.Core.Media;
public interface IRemoteMediaCatalogService
{
string CacheDirectory { get; }
Task<RemoteMediaCatalogLoadResult> LoadAsync(bool forceRefresh = false, CancellationToken cancellationToken = default);
Task<RemoteMediaCatalogLoadResult?> TryReadCacheAsync(CancellationToken cancellationToken = default);
Task ClearCacheAsync(CancellationToken cancellationToken = default);
}
public sealed class RemoteMediaCatalogService(
AppPaths paths,
IApiManager apiManager,
ILogService? logService = null) : IRemoteMediaCatalogService
{
public static readonly Uri PrimaryConfigUri = new("https://update.ymhut.cn/media-types.json");
private const string EndpointId = "media_types";
private const string SnapshotFileName = "media-types.json";
public string CacheDirectory => Path.Combine(paths.Cache, "remote-media");
private string SnapshotPath => Path.Combine(CacheDirectory, SnapshotFileName);
public async Task<RemoteMediaCatalogLoadResult> LoadAsync(bool forceRefresh = false, CancellationToken cancellationToken = default)
{
string? warning = null;
try
{
var response = forceRefresh
? await apiManager.FetchUriAsync(EndpointId, AddCacheBuster(PrimaryConfigUri), string.Empty, cancellationToken).ConfigureAwait(false)
: await apiManager.FetchAsync(EndpointId, string.Empty, cancellationToken).ConfigureAwait(false);
if (!response.Success)
{
throw new InvalidOperationException(response.Error ?? "Remote media configuration request failed.");
}
var catalog = RemoteMediaCatalogParser.Parse(response.Content, response.FetchedAt);
await WriteSnapshotAsync(response.Content, cancellationToken).ConfigureAwait(false);
return new RemoteMediaCatalogLoadResult(catalog, RemoteMediaCatalogLoadSource.Remote);
}
catch (Exception exception) when (exception is HttpRequestException or IOException or JsonException or InvalidDataException or TaskCanceledException or InvalidOperationException)
{
warning = exception.Message;
await WriteLogAsync("Warning", "remote-media", "Remote media configuration failed; trying local snapshot.", warning, cancellationToken).ConfigureAwait(false);
}
var cached = await TryReadCacheAsync(cancellationToken).ConfigureAwait(false);
if (cached is not null)
{
return cached with { Warning = warning };
}
throw new InvalidOperationException(warning ?? "Remote media configuration is unavailable and no local snapshot exists.");
}
public async Task<RemoteMediaCatalogLoadResult?> TryReadCacheAsync(CancellationToken cancellationToken = default)
{
if (!File.Exists(SnapshotPath))
{
return null;
}
try
{
var content = await File.ReadAllTextAsync(SnapshotPath, cancellationToken).ConfigureAwait(false);
var loadedAt = File.GetLastWriteTimeUtc(SnapshotPath);
var catalog = RemoteMediaCatalogParser.Parse(content, new DateTimeOffset(loadedAt, TimeSpan.Zero));
return new RemoteMediaCatalogLoadResult(catalog, RemoteMediaCatalogLoadSource.Cache);
}
catch (Exception exception) when (exception is IOException or JsonException or InvalidDataException)
{
await WriteLogAsync("Warning", "remote-media", "Remote media cache snapshot is unreadable.", exception.Message, cancellationToken).ConfigureAwait(false);
return null;
}
}
public Task ClearCacheAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (!Directory.Exists(CacheDirectory))
{
return Task.CompletedTask;
}
NormalizeAttributes(CacheDirectory);
Directory.Delete(CacheDirectory, recursive: true);
return Task.CompletedTask;
}
private async Task WriteSnapshotAsync(string content, CancellationToken cancellationToken)
{
Directory.CreateDirectory(CacheDirectory);
await File.WriteAllTextAsync(SnapshotPath, content, cancellationToken).ConfigureAwait(false);
}
private static Uri AddCacheBuster(Uri uri)
{
var builder = new UriBuilder(uri);
var token = $"_={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
builder.Query = string.IsNullOrWhiteSpace(builder.Query)
? token
: builder.Query.TrimStart('?') + "&" + token;
return builder.Uri;
}
private static void NormalizeAttributes(string directory)
{
foreach (var path in Directory.EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories))
{
File.SetAttributes(path, FileAttributes.Normal);
}
File.SetAttributes(directory, FileAttributes.Normal);
}
private Task WriteLogAsync(string level, string category, string message, string detail, CancellationToken cancellationToken)
{
return logService?.WriteAsync(level, category, message, detail, cancellationToken) ?? Task.CompletedTask;
}
}
@@ -0,0 +1,502 @@
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.RegularExpressions;
using YMhut.Box.Core.Net;
namespace YMhut.Box.Core.Media;
public interface IRemoteMediaResolver
{
Task<RemoteMediaResolution> ResolveMediaAsync(
string apiUrl,
RemoteMediaKind expectedKind = RemoteMediaKind.Unknown,
bool cacheBust = true,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default);
Task<Uri> ResolveMediaUriAsync(string apiUrl, bool cacheBust = true, IProgress<double>? progress = null, CancellationToken cancellationToken = default);
Task<byte[]> DownloadBytesAsync(Uri uri, IProgress<double>? progress = null, CancellationToken cancellationToken = default);
}
public sealed record RemoteMediaResolution(
Uri Uri,
string ContentType,
long? ContentLength,
bool IsDirectMedia,
string SuggestedExtension);
public sealed class RemoteMediaResolver : IRemoteMediaResolver
{
private const int MaxMediaRedirects = 8;
private const long MaxTextProbeLength = 2 * 1024 * 1024;
private static readonly Regex AbsoluteUrlRegex = new(@"https?://[^\s""'<>\\]+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly Func<HttpMessageHandler>? _handlerFactory;
public RemoteMediaResolver(Func<HttpMessageHandler>? handlerFactory = null)
{
_handlerFactory = handlerFactory;
}
public async Task<RemoteMediaResolution> ResolveMediaAsync(
string apiUrl,
RemoteMediaKind expectedKind = RemoteMediaKind.Unknown,
bool cacheBust = true,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
if (!Uri.TryCreate(cacheBust ? AddCacheBuster(apiUrl) : apiUrl, UriKind.Absolute, out var uri))
{
throw new InvalidOperationException("The remote media source URL is invalid.");
}
return await ResolveMediaAsync(uri, expectedKind, progress, cancellationToken).ConfigureAwait(false);
}
public async Task<Uri> ResolveMediaUriAsync(string apiUrl, bool cacheBust = true, IProgress<double>? progress = null, CancellationToken cancellationToken = default)
{
var resolution = await ResolveMediaAsync(apiUrl, RemoteMediaKind.Unknown, cacheBust, progress, cancellationToken).ConfigureAwait(false);
return resolution.Uri;
}
public async Task<byte[]> DownloadBytesAsync(Uri uri, IProgress<double>? progress = null, CancellationToken cancellationToken = default)
{
using var client = CreateMediaHttpClient();
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength;
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var buffer = new MemoryStream();
var bytes = new byte[64 * 1024];
long received = 0;
int read;
while ((read = await stream.ReadAsync(bytes.AsMemory(0, bytes.Length), cancellationToken).ConfigureAwait(false)) > 0)
{
buffer.Write(bytes, 0, read);
received += read;
if (total is > 0)
{
progress?.Report(45 + Math.Min(50, received * 50d / total.Value));
}
}
progress?.Report(98);
return buffer.ToArray();
}
private async Task<RemoteMediaResolution> ResolveMediaAsync(
Uri uri,
RemoteMediaKind expectedKind,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var current = uri;
RemoteMediaResolution? lastResolution = null;
for (var depth = 0; depth < MaxMediaRedirects; depth++)
{
progress?.Report(Math.Min(30, 6 + depth * 4));
try
{
using var redirectClient = CreateRedirectHttpClient();
current = await HttpRedirectResolver.ResolveAsync(current, redirectClient, MaxMediaRedirects, cancellationToken).ConfigureAwait(false);
using var probeClient = CreateMediaHttpClient(timeout: TimeSpan.FromSeconds(20));
using var response = await probeClient.GetAsync(current, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var finalUri = response.RequestMessage?.RequestUri ?? current;
var contentType = NormalizeContentType(response.Content.Headers.ContentType?.MediaType);
var length = response.Content.Headers.ContentLength;
var direct = IsDirectMedia(finalUri, contentType, length, expectedKind);
var suggestedExtension = SuggestedExtension(finalUri, contentType, expectedKind);
lastResolution = new RemoteMediaResolution(finalUri, contentType, length, direct, suggestedExtension);
if (direct)
{
return lastResolution;
}
if (!CanReadAsText(contentType, length))
{
return lastResolution;
}
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (TryExtractMediaUri(text, finalUri, expectedKind, out var next) && !UriEquals(finalUri, next))
{
current = next;
continue;
}
return lastResolution;
}
catch when (!cancellationToken.IsCancellationRequested)
{
return lastResolution ?? FromUriOnly(current, expectedKind);
}
}
return lastResolution ?? FromUriOnly(current, expectedKind);
}
private HttpClient CreateRedirectHttpClient()
{
if (_handlerFactory is not null)
{
return CreateMediaHttpClient(_handlerFactory(), TimeSpan.FromSeconds(20));
}
return CreateMediaHttpClient(new HttpClientHandler { AllowAutoRedirect = false }, TimeSpan.FromSeconds(20));
}
private HttpClient CreateMediaHttpClient(HttpMessageHandler? handler = null, TimeSpan? timeout = null)
{
var effectiveHandler = handler ?? _handlerFactory?.Invoke();
var client = effectiveHandler is null
? new HttpClient()
: new HttpClient(effectiveHandler, disposeHandler: true);
client.Timeout = timeout ?? TimeSpan.FromMinutes(5);
AddHeader(client.DefaultRequestHeaders.UserAgent, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36");
AddHeader(client.DefaultRequestHeaders.UserAgent, "YMhutBox/2.0");
client.DefaultRequestHeaders.Accept.ParseAdd("image/*, video/*, audio/*, application/json, text/plain, text/html, application/octet-stream, */*");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
return client;
}
private static void AddHeader(HttpHeaderValueCollection<ProductInfoHeaderValue> headers, string value)
{
if (!headers.Any(header => string.Equals(header.ToString(), value, StringComparison.OrdinalIgnoreCase)))
{
headers.ParseAdd(value);
}
}
private static string AddCacheBuster(string url)
{
if (string.IsNullOrWhiteSpace(url))
{
return url;
}
var separator = url.Contains('?', StringComparison.Ordinal) ? '&' : '?';
return $"{url}{separator}_={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
}
private static RemoteMediaResolution FromUriOnly(Uri uri, RemoteMediaKind expectedKind)
{
var extension = SuggestedExtension(uri, string.Empty, expectedKind);
return new RemoteMediaResolution(
uri,
string.Empty,
null,
LooksLikeDirectMediaUri(uri, expectedKind),
extension);
}
private static bool IsDirectMedia(Uri uri, string contentType, long? length, RemoteMediaKind expectedKind)
{
if (IsMediaContentType(contentType) || LooksLikeDirectMediaUri(uri, expectedKind))
{
return true;
}
if (contentType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase))
{
return expectedKind != RemoteMediaKind.Unknown || KnownMediaExtension(Path.GetExtension(uri.AbsolutePath));
}
return expectedKind != RemoteMediaKind.Unknown &&
string.IsNullOrWhiteSpace(contentType) &&
length is > MaxTextProbeLength;
}
private static bool LooksLikeDirectMediaUri(Uri uri, RemoteMediaKind expectedKind)
{
var extension = Path.GetExtension(uri.AbsolutePath).TrimStart('.').ToLowerInvariant();
if (string.IsNullOrWhiteSpace(extension))
{
return false;
}
if (expectedKind == RemoteMediaKind.Image && IsImageExtension(extension))
{
return true;
}
if (expectedKind == RemoteMediaKind.Video && IsVideoExtension(extension))
{
return true;
}
if (expectedKind == RemoteMediaKind.Audio && IsAudioExtension(extension))
{
return true;
}
return expectedKind == RemoteMediaKind.Unknown && KnownMediaExtension(extension);
}
private static bool KnownMediaExtension(string extension)
{
var normalized = extension.TrimStart('.').ToLowerInvariant();
return IsVideoExtension(normalized) || IsAudioExtension(normalized) || IsImageExtension(normalized);
}
private static bool IsVideoExtension(string extension)
=> extension is "mp4" or "mkv" or "webm" or "avi" or "mov" or "wmv" or "m4v";
private static bool IsAudioExtension(string extension)
=> extension is "mp3" or "wav" or "flac" or "aac" or "m4a" or "ogg" or "wma";
private static bool IsImageExtension(string extension)
=> extension is "png" or "jpg" or "jpeg" or "bmp" or "gif" or "webp" or "tif" or "tiff";
private static bool IsMediaContentType(string contentType)
{
return contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) ||
contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) ||
contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase);
}
private static bool CanReadAsText(string contentType, long? length)
{
if (length is > MaxTextProbeLength)
{
return false;
}
return string.IsNullOrWhiteSpace(contentType) ||
contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) ||
contentType.Contains("json", StringComparison.OrdinalIgnoreCase) ||
contentType.Contains("xml", StringComparison.OrdinalIgnoreCase) ||
contentType.Contains("javascript", StringComparison.OrdinalIgnoreCase);
}
internal static bool TryExtractMediaUri(string text, Uri baseUri, RemoteMediaKind expectedKind, out Uri uri)
{
if (TryExtractUriFromJson(text, baseUri, expectedKind, out uri))
{
return true;
}
var trimmed = (text ?? string.Empty).Trim();
if (TryCreateCandidateUri(trimmed, baseUri, expectedKind, out uri))
{
return true;
}
Uri? best = null;
var bestScore = int.MinValue;
var index = 0;
foreach (Match match in AbsoluteUrlRegex.Matches(text ?? string.Empty))
{
if (!TryCreateCandidateUri(match.Value.TrimEnd(',', ';', ')', ']', '}'), baseUri, expectedKind, out var candidate))
{
continue;
}
var score = ScoreUriCandidate(candidate, string.Empty, expectedKind, index++);
if (score > bestScore)
{
best = candidate;
bestScore = score;
}
}
if (best is not null)
{
uri = best;
return true;
}
uri = baseUri;
return false;
}
private static bool TryExtractUriFromJson(string text, Uri baseUri, RemoteMediaKind expectedKind, out Uri uri)
{
uri = baseUri;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
try
{
using var document = JsonDocument.Parse(text, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
return TryFindBestUri(document.RootElement, baseUri, expectedKind, out uri);
}
catch
{
return false;
}
}
private static bool TryFindBestUri(JsonElement element, Uri baseUri, RemoteMediaKind expectedKind, out Uri uri)
{
var candidates = new List<(Uri Uri, string Field, int Order)>();
CollectJsonUriCandidates(element, baseUri, candidates, string.Empty);
var best = candidates
.Select(candidate => (candidate.Uri, Score: ScoreUriCandidate(candidate.Uri, candidate.Field, expectedKind, candidate.Order)))
.OrderByDescending(candidate => candidate.Score)
.FirstOrDefault();
if (best.Uri is not null)
{
uri = best.Uri;
return true;
}
uri = baseUri;
return false;
}
private static void CollectJsonUriCandidates(
JsonElement element,
Uri baseUri,
List<(Uri Uri, string Field, int Order)> candidates,
string fieldName)
{
if (element.ValueKind == JsonValueKind.String)
{
var value = element.GetString();
if (TryCreateCandidateUri(value, baseUri, RemoteMediaKind.Unknown, out var candidate))
{
candidates.Add((candidate, fieldName, candidates.Count));
}
return;
}
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var property in element.EnumerateObject())
{
CollectJsonUriCandidates(property.Value, baseUri, candidates, property.Name);
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
CollectJsonUriCandidates(item, baseUri, candidates, fieldName);
}
}
}
private static bool TryCreateCandidateUri(string? value, Uri baseUri, RemoteMediaKind expectedKind, out Uri uri)
{
uri = baseUri;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim().Trim('"', '\'');
if (!trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase) &&
!trimmed.StartsWith("//", StringComparison.Ordinal) &&
!trimmed.StartsWith("/", StringComparison.Ordinal) &&
!trimmed.StartsWith("./", StringComparison.Ordinal) &&
!trimmed.StartsWith("../", StringComparison.Ordinal))
{
return false;
}
if (trimmed.StartsWith("//", StringComparison.Ordinal))
{
trimmed = $"{baseUri.Scheme}:{trimmed}";
}
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var absoluteUri) &&
!Uri.TryCreate(baseUri, trimmed, out absoluteUri))
{
return false;
}
uri = absoluteUri;
return true;
}
private static int ScoreUriCandidate(Uri candidate, string fieldName, RemoteMediaKind expectedKind, int order)
{
var field = fieldName.ToLowerInvariant();
var score = Math.Max(0, 1000 - order);
if (field is "url" or "src" or "media" or "file" or "uri")
{
score += 500;
}
if (field is "video" or "mp4" or "image" or "img" or "audio")
{
score += 420;
}
if (expectedKind != RemoteMediaKind.Unknown && LooksLikeDirectMediaUri(candidate, expectedKind))
{
score += 900;
}
else if (LooksLikeDirectMediaUri(candidate, RemoteMediaKind.Unknown))
{
score += 240;
}
return score;
}
private static string SuggestedExtension(Uri uri, string contentType, RemoteMediaKind expectedKind)
{
var fromContentType = ExtensionFromContentType(contentType);
if (!string.IsNullOrWhiteSpace(fromContentType))
{
return fromContentType;
}
var fromPath = Path.GetExtension(uri.AbsolutePath);
if (!string.IsNullOrWhiteSpace(fromPath) && fromPath.Length <= 8)
{
return fromPath.StartsWith('.') ? fromPath : "." + fromPath;
}
return expectedKind switch
{
RemoteMediaKind.Video => ".mp4",
RemoteMediaKind.Audio => ".mp3",
RemoteMediaKind.Image => ".jpg",
_ => ".bin"
};
}
private static string ExtensionFromContentType(string contentType)
{
return NormalizeContentType(contentType) switch
{
"image/jpeg" => ".jpg",
"image/png" => ".png",
"image/webp" => ".webp",
"image/gif" => ".gif",
"image/bmp" => ".bmp",
"video/mp4" => ".mp4",
"video/webm" => ".webm",
"video/quicktime" => ".mov",
"audio/mpeg" => ".mp3",
"audio/mp3" => ".mp3",
"audio/wav" => ".wav",
"audio/x-wav" => ".wav",
"audio/mp4" => ".m4a",
"audio/aac" => ".aac",
"audio/ogg" => ".ogg",
_ => string.Empty
};
}
private static string NormalizeContentType(string? contentType)
=> (contentType ?? string.Empty).Trim().ToLowerInvariant();
private static bool UriEquals(Uri left, Uri right)
=> string.Equals(left.AbsoluteUri, right.AbsoluteUri, StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,44 @@
using System.Net;
namespace YMhut.Box.Core.Net;
public static class HttpRedirectResolver
{
public static async Task<Uri> ResolveAsync(
Uri uri,
HttpMessageInvoker client,
int maxRedirects = 8,
CancellationToken cancellationToken = default)
{
var current = uri;
for (var redirect = 0; redirect < Math.Max(1, maxRedirects); redirect++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, current);
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!IsRedirect(response.StatusCode))
{
response.EnsureSuccessStatusCode();
return current;
}
var location = response.Headers.Location;
if (location is null)
{
return current;
}
current = location.IsAbsoluteUri ? location : new Uri(current, location);
}
return current;
}
private static bool IsRedirect(HttpStatusCode statusCode)
{
return statusCode is HttpStatusCode.Moved or
HttpStatusCode.Redirect or
HttpStatusCode.RedirectMethod or
HttpStatusCode.TemporaryRedirect or
HttpStatusCode.PermanentRedirect;
}
}
+356
View File
@@ -0,0 +1,356 @@
using System.Net;
using System.Text;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Settings;
namespace YMhut.Box.Core.Net;
public sealed record HttpServiceResult(
HttpStatusCode StatusCode,
string Content,
IReadOnlyDictionary<string, string[]> Headers,
TimeSpan Elapsed);
public sealed record HttpRequestPolicy(
TimeSpan Timeout,
int MaxRetries = 0,
TimeSpan? RetryDelay = null)
{
public static HttpRequestPolicy Default { get; } = new(TimeSpan.FromSeconds(30));
public static HttpRequestPolicy RemoteTool { get; } = new(TimeSpan.FromSeconds(45), 1, TimeSpan.FromMilliseconds(650));
public static HttpRequestPolicy Diagnostics { get; } = new(TimeSpan.FromSeconds(15));
public static HttpRequestPolicy LongDownload { get; } = new(TimeSpan.FromMinutes(5));
}
public sealed class HttpRequestTimeoutException(Uri uri, TimeSpan timeout, Exception? innerException = null)
: TimeoutException($"HTTP request timed out after {timeout.TotalSeconds:0.#} seconds.", innerException)
{
public Uri Uri { get; } = uri;
public TimeSpan Timeout { get; } = timeout;
}
public interface IHttpService
{
Task<HttpServiceResult> GetAsync(Uri uri, CancellationToken cancellationToken = default);
Task<string> GetStringAsync(Uri uri, CancellationToken cancellationToken = default);
Task<HttpServiceResult> SendAsync(
Uri uri,
string method = "GET",
string? body = null,
IReadOnlyDictionary<string, string>? headers = null,
bool ensureSuccess = true,
HttpRequestPolicy? policy = null,
CancellationToken cancellationToken = default);
}
public sealed class HttpService : IHttpService, IDisposable
{
private readonly HttpClient _client;
private readonly ILogService? _logService;
private readonly ISettingsService? _settingsService;
private readonly bool _disposeClient;
public HttpService(HttpClient? client = null, ILogService? logService = null, ISettingsService? settingsService = null)
{
_client = client ?? new HttpClient();
_client.Timeout = Timeout.InfiniteTimeSpan;
ConfigureDefaultHeaders(_client);
_logService = logService;
_settingsService = settingsService;
_disposeClient = client is null;
}
public async Task<HttpServiceResult> GetAsync(Uri uri, CancellationToken cancellationToken = default)
=> await SendAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false);
public async Task<HttpServiceResult> SendAsync(
Uri uri,
string method = "GET",
string? body = null,
IReadOnlyDictionary<string, string>? headers = null,
bool ensureSuccess = true,
HttpRequestPolicy? policy = null,
CancellationToken cancellationToken = default)
{
policy ??= HttpRequestPolicy.Default;
var started = DateTimeOffset.UtcNow;
using var proxyClient = CreateProxyClientIfNeeded();
var client = proxyClient ?? _client;
var normalizedMethod = string.IsNullOrWhiteSpace(method) ? "GET" : method.ToUpperInvariant();
Exception? lastException = null;
for (var attempt = 0; attempt <= Math.Max(0, policy.MaxRetries); attempt++)
{
using var timeoutCts = policy.Timeout == Timeout.InfiniteTimeSpan
? null
: new CancellationTokenSource(policy.Timeout);
using var linkedCts = timeoutCts is null
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
using var request = CreateRequest(uri, normalizedMethod, body, headers);
try
{
using var response = await client.SendAsync(request, linkedCts.Token).ConfigureAwait(false);
var content = await response.Content.ReadAsStringAsync(linkedCts.Token).ConfigureAwait(false);
var elapsed = DateTimeOffset.UtcNow - started;
var responseHeaders = response.Headers
.Concat(response.Content.Headers)
.GroupBy(header => header.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.SelectMany(header => header.Value).ToArray(), StringComparer.OrdinalIgnoreCase);
await WriteLogAsync(
response.IsSuccessStatusCode ? "Information" : "Warning",
"http",
$"HTTP request completed: {(int)response.StatusCode} {response.ReasonPhrase}",
cancellationToken).ConfigureAwait(false);
if (ensureSuccess)
{
response.EnsureSuccessStatusCode();
}
return new HttpServiceResult(response.StatusCode, content, responseHeaders, elapsed);
}
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
{
lastException = new HttpRequestTimeoutException(uri, policy.Timeout, exception);
if (!ShouldRetry(normalizedMethod, lastException, attempt, policy))
{
throw lastException;
}
}
catch (Exception exception) when (ShouldRetry(normalizedMethod, exception, attempt, policy))
{
lastException = exception;
}
if (attempt < policy.MaxRetries)
{
await Task.Delay(policy.RetryDelay ?? TimeSpan.FromMilliseconds(500), cancellationToken).ConfigureAwait(false);
}
}
throw lastException ?? new InvalidOperationException("HTTP request failed before a response was available.");
}
public async Task<string> GetStringAsync(Uri uri, CancellationToken cancellationToken = default)
=> (await GetAsync(uri, cancellationToken).ConfigureAwait(false)).Content;
public void Dispose()
{
if (_disposeClient)
{
_client.Dispose();
}
}
private Task WriteLogAsync(string level, string category, string message, CancellationToken cancellationToken)
{
return _logService?.WriteAsync(level, category, message, cancellationToken: cancellationToken) ?? Task.CompletedTask;
}
private HttpClient? CreateProxyClientIfNeeded()
{
var settings = _settingsService?.Current;
if (settings is not { ProxyEnabled: true } ||
string.Equals(settings.ProxyMode, "system", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (string.Equals(settings.ProxyMode, "direct", StringComparison.OrdinalIgnoreCase))
{
var directClient = new HttpClient(new HttpClientHandler { UseProxy = false }, disposeHandler: true)
{
Timeout = Timeout.InfiniteTimeSpan
};
ConfigureDefaultHeaders(directClient);
return directClient;
}
if (string.IsNullOrWhiteSpace(settings.ProxyHost))
{
return null;
}
var builder = UriBuilderCache.Create(settings.ProxyHost, settings.ProxyPort);
var handler = new HttpClientHandler
{
Proxy = new WebProxy(builder.Uri),
UseProxy = true
};
var client = new HttpClient(handler, disposeHandler: true)
{
Timeout = Timeout.InfiniteTimeSpan
};
ConfigureDefaultHeaders(client);
return client;
}
private static HttpRequestMessage CreateRequest(
Uri uri,
string method,
string? body,
IReadOnlyDictionary<string, string>? headers)
{
var request = new HttpRequestMessage(new HttpMethod(method), uri);
if (body is not null && request.Method != HttpMethod.Get && request.Method != HttpMethod.Head)
{
request.Content = new StringContent(body, Encoding.UTF8, "text/plain");
}
ApplyRequestHeaders(request);
ApplyCustomHeaders(request, headers);
return request;
}
private static bool ShouldRetry(string method, Exception exception, int attempt, HttpRequestPolicy policy)
{
if (attempt >= policy.MaxRetries || method is not ("GET" or "HEAD"))
{
return false;
}
return exception is HttpRequestTimeoutException ||
exception is TimeoutException ||
exception is IOException ||
exception is HttpRequestException { StatusCode: null };
}
private static void ConfigureDefaultHeaders(HttpClient client)
{
if (!client.DefaultRequestHeaders.UserAgent.Any())
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36");
client.DefaultRequestHeaders.UserAgent.ParseAdd("YMhutBox/2.0");
}
if (!client.DefaultRequestHeaders.Accept.Any())
{
client.DefaultRequestHeaders.Accept.ParseAdd("application/json, text/plain, application/xml, text/xml, */*");
}
if (!client.DefaultRequestHeaders.AcceptLanguage.Any())
{
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
}
}
private static void ApplyRequestHeaders(HttpRequestMessage request)
{
if (request.RequestUri is null)
{
return;
}
var host = request.RequestUri.Host;
request.Headers.TryAddWithoutValidation("Cache-Control", "no-cache");
request.Headers.TryAddWithoutValidation("Pragma", "no-cache");
request.Headers.TryAddWithoutValidation("Upgrade-Insecure-Requests", "1");
if (host.Contains("12306.cn", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Referrer = new Uri("https://kyfw.12306.cn/otn/leftTicket/init");
request.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest");
}
if (host.Contains("zhihu.com", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Referrer = new Uri("https://www.zhihu.com/hot");
request.Headers.TryAddWithoutValidation("X-Requested-With", "fetch");
request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "same-origin");
}
if (host.Contains("bilibili.com", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Referrer = new Uri("https://www.bilibili.com/v/popular/all");
request.Headers.TryAddWithoutValidation("Origin", "https://www.bilibili.com");
request.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest");
request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "same-site");
}
if (host.Contains("baidu.com", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Referrer = new Uri("https://top.baidu.com/board?tab=realtime");
request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "same-origin");
}
if (host.Contains("ndrc.gov.cn", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Referrer = new Uri("https://www.ndrc.gov.cn/");
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
}
if (host.Contains("cbooo.cn", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Referrer = new Uri("https://www.cbooo.cn/");
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
}
if (host.Contains("36kr.com", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Referrer = new Uri("https://36kr.com/");
}
if (host.Contains("cctv.com", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Referrer = new Uri("https://news.cctv.com/");
}
}
private static void ApplyCustomHeaders(HttpRequestMessage request, IReadOnlyDictionary<string, string>? headers)
{
if (headers is null || headers.Count == 0)
{
return;
}
foreach (var (name, value) in headers)
{
if (string.IsNullOrWhiteSpace(name) || IsBlockedHeader(name))
{
continue;
}
if (request.Content is not null && request.Content.Headers.TryAddWithoutValidation(name, value))
{
continue;
}
request.Headers.TryAddWithoutValidation(name, value);
}
}
private static bool IsBlockedHeader(string name)
{
return name.Equals("host", StringComparison.OrdinalIgnoreCase) ||
name.Equals("content-length", StringComparison.OrdinalIgnoreCase) ||
name.Equals("connection", StringComparison.OrdinalIgnoreCase) ||
name.Equals("transfer-encoding", StringComparison.OrdinalIgnoreCase);
}
private static class UriBuilderCache
{
public static UriBuilder Create(string host, int port)
{
var normalized = host.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
host.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
? host
: "http://" + host;
var builder = new UriBuilder(normalized);
if (port > 0)
{
builder.Port = port;
}
return builder;
}
}
}
@@ -0,0 +1,32 @@
# IPCheck 网络工具箱内置示例插件
这是 YMhut Box 随程序发布的内置示例插件。插件资源嵌入在 `YMhut.Box.Core.dll` 中,插件系统首次启用或扫描时会复制到用户插件目录;如果用户已经修改同 ID 插件,程序会保留用户版本。
## 文件说明
- `ymhut.plugin.json`:插件声明文件,定义插件 ID、权限、工具入口和工具箱分类。
- `index.html`:插件页面入口,适配主窗口内嵌和独立窗口内容区。
- `style.css`:原创黑白极简点阵界面样式,卡片圆角控制在 8px。
- `main.js`:插件主脚本,负责公网 IP、IPv4/IPv6、Cloudflare Trace、DNS 泄漏、WebRTC、测速、Ping、MTR、Whois/RDAP、MAC 厂商、ASN 连通性、规则测试、可达性检查、本机接口和浏览器指纹检测。
## Bridge 能力示例
页面底部的“Bridge 示例”卡片演示了三个常用能力:
- 写入输出区:`window.ymhut.output.set(report)`,适合报告、日志摘要、可复制结果。
- 保存私有状态:`window.ymhut.storage.set("lastSnapshot", value)`,只写入当前插件命名空间。
- 打开安全链接:`window.ymhut.openExternal(url)`,默认进入 YMhut Box 安全浏览器;如需系统浏览器,必须显式传入 `{ target: "system" }` 并获得权限。
## 权限说明
插件声明 `Http``NetworkDiagnostics``Log``Output``Storage``Clipboard``OpenExternal`。用户启用并授权后,插件可通过 YMhut Bridge 执行必要的公网观测请求、本机网络诊断、日志记录、输出区写入、状态保存、报告复制和安全链接打开。
## 安全与边界
本示例只复刻 IPCheck 类工具的功能覆盖、内容结构和黑白极简风格,不复制受保护页面源码、品牌资产或私有接口。页面本体与工具交互均为内置原创实现;公网 IP、DNS 泄漏、RDAP、测速等必须由远端观测点才能完成的指标,会在插件授权后通过公开端点探测,并在失败时显示清晰降级状态。
插件 UI 不应覆盖宿主标题栏、输出区或系统窗口按钮。需要展示长报告时写入宿主输出区;主操作界面应保留在插件内容区内,避免 fixed 全屏遮罩和超高 z-index 点击层。
## AI 实现提示
给其他 AI 生成插件时,可以把本示例作为最小可运行模板:保留 `ymhut.plugin.json``README.md``index.html``style.css``main.js` 五个核心文件,按需减少权限,并在 README 中解释每个权限的用途。不要依赖远程脚本或修改 YMhut Box 内置资源。
@@ -0,0 +1,307 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IPCheck 网络工具箱</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<!-- 内置示例插件:页面、交互和工具面板全部随 YMhut Box 内嵌发布,不加载第三方页面源码或远程脚本。 -->
<main class="shell">
<section class="hero" id="top">
<div class="brandMark" aria-hidden="true">
<span></span>
</div>
<div class="heroText">
<p class="eyebrow">All in one IP Toolbox</p>
<h1 id="primaryIp">正在检测...</h1>
<p id="primarySummary" class="summary">正在并行检测公网视角、本机网络、DNS、WebRTC、浏览器指纹和链路质量。</p>
</div>
<div class="heroActions">
<button id="rerunBtn" type="button" class="primary">重新检测</button>
<button id="copyBtn" type="button">复制报告</button>
</div>
</section>
<section class="quickStats" id="statusStrip" aria-label="检测摘要"></section>
<nav class="toolNav" aria-label="工具导航">
<a href="#overview">概览</a>
<a href="#leaks">泄漏检测</a>
<a href="#quality">质量</a>
<a href="#advanced">高级工具</a>
<a href="#fingerprint">指纹</a>
</nav>
<section id="overview" class="grid">
<article class="card span2">
<div class="cardHeader">
<div>
<p class="label">网络身份</p>
<h2>IP 地址与地理位置</h2>
</div>
<span id="ipStatus" class="badge pending">检测中</span>
</div>
<dl id="ipDetails" class="facts"></dl>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">协议栈</p>
<h2>IPv4 / IPv6</h2>
</div>
<span id="stackStatus" class="badge pending">检测中</span>
</div>
<div id="stackDetails" class="metricList"></div>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">边缘网络</p>
<h2>Cloudflare Trace</h2>
</div>
<span id="traceStatus" class="badge pending">检测中</span>
</div>
<dl id="traceDetails" class="facts compact"></dl>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">质量判断</p>
<h2>IP 质量与风险</h2>
</div>
<span id="qualityStatus" class="badge pending">检测中</span>
</div>
<div id="qualityDetails" class="metricList"></div>
</article>
</section>
<section id="leaks" class="grid">
<article class="card">
<div class="cardHeader">
<div>
<p class="label">隐私</p>
<h2>WebRTC 泄漏</h2>
</div>
<span id="webrtcStatus" class="badge pending">检测中</span>
</div>
<div id="webrtcDetails" class="monoBlock"></div>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">解析</p>
<h2>DNS 泄漏</h2>
</div>
<span id="dnsStatus" class="badge pending">检测中</span>
</div>
<div id="dnsDetails" class="metricList"></div>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">可见性</p>
<h2>IP 泄漏对照</h2>
</div>
<span id="leakStatus" class="badge pending">检测中</span>
</div>
<div id="leakDetails" class="metricList"></div>
</article>
</section>
<section id="quality" class="grid">
<article class="card">
<div class="cardHeader">
<div>
<p class="label">连通性</p>
<h2>全球延迟</h2>
</div>
<span id="latencyStatus" class="badge pending">检测中</span>
</div>
<div id="latencyDetails" class="metricList"></div>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">吞吐</p>
<h2>网络测速</h2>
</div>
<span id="speedStatus" class="badge pending">检测中</span>
</div>
<div class="speedValue"><span id="speedValue">--</span><small id="speedUnit">Mbps</small></div>
<p id="speedNote" class="muted">下载与上传测速将在授权 HTTP 后执行;无网络时显示本机链路速率。</p>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">规则</p>
<h2>安全检查清单</h2>
</div>
<span id="securityStatus" class="badge pending">检测中</span>
</div>
<div id="securityDetails" class="checkList"></div>
</article>
</section>
<section id="advanced" class="toolPanel">
<div class="sectionHeader">
<div>
<p class="eyebrow">Advanced Tools</p>
<h2>高级网络工具</h2>
</div>
<span id="toolStatus" class="badge pending">待输入</span>
</div>
<div class="toolGrid">
<article class="card toolCard">
<h3>IP 查询</h3>
<div class="formRow">
<input id="lookupInput" type="text" placeholder="输入 IP,例如 1.1.1.1">
<button id="lookupBtn" type="button">查询</button>
</div>
<div id="lookupOutput" class="monoBlock small">等待查询。</div>
</article>
<article class="card toolCard">
<h3>DNS Resolver</h3>
<div class="formRow">
<input id="dnsInput" type="text" placeholder="输入域名,例如 example.com">
<button id="dnsBtn" type="button">解析</button>
</div>
<div id="dnsOutput" class="monoBlock small">等待解析。</div>
</article>
<article class="card toolCard">
<h3>Ping / Global Latency</h3>
<div class="formRow">
<input id="pingInput" type="text" placeholder="输入主机,例如 cloudflare.com">
<button id="pingBtn" type="button">Ping</button>
</div>
<div id="pingOutput" class="monoBlock small">等待测试。</div>
</article>
<article class="card toolCard">
<h3>MTR / Trace Route</h3>
<div class="formRow">
<input id="traceInput" type="text" placeholder="输入主机,例如 1.1.1.1">
<button id="traceBtn" type="button">追踪</button>
</div>
<div id="traceOutput" class="monoBlock small">等待追踪。</div>
</article>
<article class="card toolCard">
<h3>Whois / RDAP</h3>
<div class="formRow">
<input id="whoisInput" type="text" placeholder="输入 IP 或域名">
<button id="whoisBtn" type="button">查询</button>
</div>
<div id="whoisOutput" class="monoBlock small">等待查询。</div>
</article>
<article class="card toolCard">
<h3>MAC 厂商查询</h3>
<div class="formRow">
<input id="macInput" type="text" placeholder="输入 MAC,例如 00:1A:2B:3C:4D:5E">
<button id="macBtn" type="button">识别</button>
</div>
<div id="macOutput" class="monoBlock small">等待识别。</div>
</article>
<article class="card toolCard">
<h3>ASN Connectivity</h3>
<div class="formRow">
<input id="asnInput" type="text" placeholder="输入 IP 或 ASN,默认当前公网 IP">
<button id="asnBtn" type="button">分析</button>
</div>
<div id="asnOutput" class="monoBlock small">等待分析。</div>
</article>
<article class="card toolCard">
<h3>Rule Test</h3>
<div class="formRow">
<input id="ruleTargetInput" type="text" placeholder="测试目标,例如 example.com 或当前 IP">
<button id="ruleBtn" type="button">测试</button>
</div>
<textarea id="ruleInput" spellcheck="false" placeholder="每行一条规则:DOMAIN-SUFFIX,example.com / IP-CIDR,1.1.1.0/24 / GEOIP,CN"></textarea>
<div id="ruleOutput" class="monoBlock small">等待测试。</div>
</article>
<article class="card toolCard">
<h3>Censorship Check</h3>
<div class="formRow">
<input id="censorInput" type="text" placeholder="可选:自定义 URL,默认测试常用站点">
<button id="censorBtn" type="button">检查</button>
</div>
<div id="censorOutput" class="monoBlock small">等待检查。</div>
</article>
<article class="card toolCard">
<h3>Invisibility Test</h3>
<div class="formRow">
<input id="invisibleInput" type="text" placeholder="可选:期望国家代码,例如 CN / US">
<button id="invisibleBtn" type="button">评估</button>
</div>
<div id="invisibleOutput" class="monoBlock small">等待评估。</div>
</article>
</div>
</section>
<section id="fingerprint" class="grid">
<article class="card span2">
<div class="cardHeader">
<div>
<p class="label">本机环境</p>
<h2>浏览器指纹</h2>
</div>
<span class="badge ok">本地</span>
</div>
<dl id="browserDetails" class="facts"></dl>
</article>
<article class="card span2">
<div class="cardHeader">
<div>
<p class="label">接口</p>
<h2>本机网络接口</h2>
</div>
<span id="hostStatus" class="badge pending">检测中</span>
</div>
<div id="interfaceDetails" class="interfaceList"></div>
</article>
</section>
<section id="bridge-demo" class="grid">
<article class="card span2">
<div class="cardHeader">
<div>
<p class="label">Bridge 示例</p>
<h2>输出、存储与安全链接</h2>
</div>
<span id="bridgeStatus" class="badge pending">待操作</span>
</div>
<p class="muted">这些按钮演示插件如何写入宿主输出区、保存插件私有状态,以及默认用安全浏览器打开外链。</p>
<div class="formRow bridgeActions">
<button id="outputDemoBtn" type="button">写入输出区</button>
<button id="storageDemoBtn" type="button">保存快照</button>
<button id="guideDemoBtn" type="button">打开插件规范</button>
</div>
<div id="bridgeOutput" class="monoBlock small">等待 Bridge 操作。</div>
</article>
</section>
<div class="copyState">
<span id="copyStatus" class="badge pending">报告未复制</span>
</div>
</main>
<script src="./main.js"></script>
</body>
</html>
@@ -0,0 +1,907 @@
// 插件主脚本:所有页面、工具逻辑和降级策略均内置;公网 IP/测速/DNS 泄漏等必须依赖远端观测点的项目才通过授权 HTTP 探测。
const $ = (id) => document.getElementById(id);
const state = {
diagnostics: null,
publicIp: null,
trace: null,
browser: {},
webrtc: [],
dnsProbe: null,
latency: {},
speed: null,
lookup: null,
checks: {},
startedAt: null
};
const endpoints = {
ipApis: [
{ name: "ipapi.co", url: "https://ipapi.co/json/" },
{ name: "ipwho.is", url: "https://ipwho.is/" },
{ name: "ip.sb", url: "https://api.ip.sb/geoip" }
],
ipv4: "https://api.ipify.org?format=json",
ipv6: "https://api64.ipify.org?format=json",
trace: "https://speed.cloudflare.com/cdn-cgi/trace",
speedDown: "https://speed.cloudflare.com/__down?bytes=1000000",
speedUp: "https://speed.cloudflare.com/__up",
doh: "https://cloudflare-dns.com/dns-query",
rdapIp: "https://rdap.org/ip/",
rdapDomain: "https://rdap.org/domain/",
mac: "https://api.macvendors.com/"
};
const macVendors = {
"001A2B": "Ayecom Technology",
"001B63": "Apple",
"001C42": "Parallels",
"002248": "Microsoft",
"005056": "VMware",
"080027": "PCS Systemtechnik / VirtualBox",
"3C5A37": "Google",
"F4F5D8": "Google",
"D850E6": "ASUSTek",
"FCFBFB": "Cisco",
"B827EB": "Raspberry Pi"
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function setBadge(id, status, text) {
const el = $(id);
if (!el) return;
el.className = `badge ${status}`;
el.textContent = text;
}
function setFacts(id, rows) {
$(id).innerHTML = rows
.map(([key, value]) => `<dt>${escapeHtml(key)}</dt><dd>${escapeHtml(value || "--")}</dd>`)
.join("");
}
function setMetrics(id, rows) {
$(id).innerHTML = rows
.map(([key, value, tone = ""]) => `
<div class="metric">
<span>${escapeHtml(key)}</span>
<strong class="${tone ? `${tone}Text` : ""}">${escapeHtml(value || "--")}</strong>
</div>`)
.join("");
}
function setChecks(id, rows) {
$(id).innerHTML = rows
.map(([key, value, tone = ""]) => `
<div class="checkItem">
<span>${escapeHtml(key)}</span>
<strong class="${tone ? `${tone}Text` : ""}">${escapeHtml(value || "--")}</strong>
</div>`)
.join("");
}
function setOutput(id, value) {
$(id).textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
function flatten(values) {
return [...new Set((values || []).flat().filter(Boolean))];
}
function activeInterfaces() {
return (state.diagnostics?.interfaces || []).filter((item) => item.status === "Up");
}
function shortJson(value) {
return JSON.stringify(value, null, 2)
.replaceAll("\\u0022", "\"")
.slice(0, 6000);
}
function parseJson(content) {
try {
return JSON.parse(content);
} catch {
return null;
}
}
async function bridgeFetch(request) {
const response = await window.ymhut.http.fetch(request);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response;
}
async function fetchJson(url, options = {}) {
const response = await bridgeFetch({ url, headers: options.headers, method: options.method, body: options.body });
return { data: parseJson(response.content), response };
}
async function firstSuccessful(tasks) {
const errors = [];
for (const task of tasks) {
try {
const value = await task();
return value;
} catch (error) {
errors.push(error.message);
}
}
throw new Error(errors.join("; "));
}
function normalizeIpInfo(source, data) {
if (!data) return null;
if (source === "ipapi.co") {
return {
source,
ip: data.ip,
version: data.version,
city: data.city,
region: data.region,
country: data.country_name || data.country,
countryCode: data.country_code,
timezone: data.timezone,
latitude: data.latitude,
longitude: data.longitude,
asn: data.asn,
isp: data.org,
postal: data.postal,
raw: data
};
}
if (source === "ipwho.is") {
return {
source,
ip: data.ip,
version: data.type,
city: data.city,
region: data.region,
country: data.country,
countryCode: data.country_code,
timezone: data.timezone?.id,
latitude: data.latitude,
longitude: data.longitude,
asn: data.connection?.asn ? `AS${data.connection.asn}` : "",
isp: data.connection?.isp || data.connection?.org,
postal: data.postal,
raw: data
};
}
return {
source,
ip: data.ip || data.address,
version: data.version,
city: data.city,
region: data.region,
country: data.country,
countryCode: data.country_code,
timezone: data.timezone,
latitude: data.latitude,
longitude: data.longitude,
asn: data.asn,
isp: data.organization || data.isp,
postal: data.postal_code,
raw: data
};
}
function parseTrace(text) {
const rows = {};
String(text || "").split(/\r?\n/).forEach((line) => {
const index = line.indexOf("=");
if (index > 0) rows[line.slice(0, index)] = line.slice(index + 1);
});
return rows;
}
async function loadDiagnostics() {
try {
const diagnostics = await window.ymhut.network.diagnostics();
state.diagnostics = diagnostics;
state.checks.host = true;
setBadge("hostStatus", "ok", "本地");
} catch (error) {
state.checks.host = false;
state.diagnostics = { interfaces: [], summary: {}, proxy: {}, note: error.message };
setBadge("hostStatus", "bad", "失败");
}
}
async function loadPublicIp() {
try {
const info = await firstSuccessful(endpoints.ipApis.map((item) => async () => {
const { data } = await fetchJson(item.url);
const normalized = normalizeIpInfo(item.name, data);
if (!normalized?.ip) throw new Error(`${item.name} 未返回 IP`);
return normalized;
}));
state.publicIp = info;
state.checks.publicIp = true;
} catch (error) {
state.publicIp = { error: error.message };
state.checks.publicIp = false;
}
}
async function loadTrace() {
try {
const response = await bridgeFetch({ url: endpoints.trace });
state.trace = parseTrace(response.content);
state.checks.trace = true;
} catch (error) {
state.trace = { error: error.message };
state.checks.trace = false;
}
}
async function loadIpVersions() {
const result = { ipv4: null, ipv6: null };
try {
const { data } = await fetchJson(endpoints.ipv4);
result.ipv4 = data?.ip || null;
} catch (error) {
result.ipv4Error = error.message;
}
try {
const { data } = await fetchJson(endpoints.ipv6);
result.ipv6 = data?.ip || null;
} catch (error) {
result.ipv6Error = error.message;
}
state.ipVersions = result;
}
function renderIdentity() {
const summary = state.diagnostics?.summary || {};
const active = activeInterfaces();
const localIpv4 = flatten(active.map((item) => item.ipv4));
const localIpv6 = flatten(active.map((item) => item.ipv6));
const publicIp = state.publicIp?.ip;
$("primaryIp").textContent = publicIp || localIpv4[0] || localIpv6[0] || "网络未就绪";
$("primarySummary").textContent = publicIp
? `${state.publicIp.isp || "未知 ISP"} · ${[state.publicIp.city, state.publicIp.region, state.publicIp.country].filter(Boolean).join(" / ") || "未知位置"} · ${state.publicIp.asn || "未知 ASN"}`
: "未获得公网观测结果,已展示本机可见网络信息。";
setFacts("ipDetails", [
["公网 IP", publicIp || "未获得"],
["ASN / ISP", [state.publicIp?.asn, state.publicIp?.isp].filter(Boolean).join(" / ")],
["国家地区", [state.publicIp?.city, state.publicIp?.region, state.publicIp?.country].filter(Boolean).join(" / ")],
["经纬度", state.publicIp?.latitude ? `${state.publicIp.latitude}, ${state.publicIp.longitude}` : ""],
["时区", state.publicIp?.timezone || state.diagnostics?.localTimeZone],
["活动接口", active.map((item) => item.name).join(" / ")],
["本机 IPv4", localIpv4.join(" / ")],
["本机 IPv6", localIpv6.join(" / ")],
["默认网关", (summary.defaultGateways || []).join(" / ")],
["DNS 服务器", (summary.dnsServers || []).join(" / ")],
["观测来源", state.publicIp?.source || state.publicIp?.error || "本地"]
]);
setBadge("ipStatus", publicIp ? "ok" : "warn", publicIp ? "完成" : "降级");
}
function renderStack() {
const active = activeInterfaces();
const localIpv4 = flatten(active.map((item) => item.ipv4));
const localIpv6 = flatten(active.map((item) => item.ipv6));
const publicV4 = state.ipVersions?.ipv4;
const publicV6 = state.ipVersions?.ipv6;
setMetrics("stackDetails", [
["公网 IPv4", publicV4 || state.ipVersions?.ipv4Error || "未检测到", publicV4 ? "ok" : "warn"],
["公网 IPv6", publicV6 || state.ipVersions?.ipv6Error || "未检测到", publicV6 ? "ok" : "warn"],
["本机 IPv4", localIpv4.length ? `${localIpv4.length} 个地址` : "未发现", localIpv4.length ? "ok" : "bad"],
["本机 IPv6", localIpv6.length ? `${localIpv6.length} 个地址` : "未发现", localIpv6.length ? "ok" : "warn"],
["双栈状态", (publicV4 || localIpv4.length) && (publicV6 || localIpv6.length) ? "双栈可见" : "非完整双栈", (publicV4 || localIpv4.length) && (publicV6 || localIpv6.length) ? "ok" : "warn"]
]);
setBadge("stackStatus", publicV4 || publicV6 || localIpv4.length || localIpv6.length ? "ok" : "bad", "完成");
}
function renderTrace() {
const trace = state.trace || {};
setFacts("traceDetails", [
["Colo", trace.colo || "--"],
["HTTP", trace.http || "--"],
["TLS", trace.tls || "--"],
["WARP", trace.warp || "--"],
["Gateway", trace.gateway || "--"],
["SNI", trace.sni || "--"],
["IP", trace.ip || "--"],
["错误", trace.error || ""]
]);
setBadge("traceStatus", trace.colo ? "ok" : "warn", trace.colo ? "完成" : "降级");
}
function renderQuality() {
const publicIp = state.publicIp || {};
const proxy = state.diagnostics?.proxy || {};
const trace = state.trace || {};
const active = activeInterfaces();
const localIps = flatten(active.map((item) => [...(item.ipv4 || []), ...(item.ipv6 || [])]));
const hints = [];
if (proxy.enabled) hints.push("系统代理已启用");
if (trace.warp === "on" || trace.warp === "plus") hints.push("检测到 WARP");
if (publicIp.error) hints.push("公网观测失败");
if (localIps.some((ip) => ip.startsWith("10.") || ip.startsWith("192.168.") || ip.startsWith("172."))) hints.push("本机存在私网地址");
const risk = publicIp.error ? "中" : proxy.enabled ? "需复核" : "低";
setMetrics("qualityDetails", [
["质量评级", risk, risk === "低" ? "ok" : "warn"],
["代理/VPN 线索", hints.join(" / ") || "未发现明显线索", hints.length ? "warn" : "ok"],
["ASN 信息", publicIp.asn || "未知", publicIp.asn ? "ok" : "warn"],
["运营商", publicIp.isp || "未知", publicIp.isp ? "ok" : "warn"],
["观测一致性", state.ipVersions?.ipv4 && publicIp.ip && state.ipVersions.ipv4 !== publicIp.ip ? "IPv4 观测不一致" : "未发现冲突", "ok"]
]);
setBadge("qualityStatus", risk === "低" ? "ok" : "warn", risk);
}
function renderBrowser() {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
const debug = gl?.getExtension("WEBGL_debug_renderer_info");
state.browser = {
language: navigator.language,
languages: navigator.languages?.join(" / "),
platform: navigator.platform,
userAgent: navigator.userAgent,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
screen: `${screen.width}x${screen.height} / DPR ${window.devicePixelRatio}`,
online: navigator.onLine ? "在线" : "离线",
cookies: navigator.cookieEnabled ? "启用" : "禁用",
hardwareConcurrency: navigator.hardwareConcurrency,
memory: navigator.deviceMemory ? `${navigator.deviceMemory} GB` : "未暴露",
touch: navigator.maxTouchPoints || 0,
webglVendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : "未暴露",
webglRenderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "未暴露"
};
setFacts("browserDetails", [
["语言", state.browser.language],
["语言列表", state.browser.languages],
["平台", state.browser.platform],
["时区", state.browser.timezone],
["屏幕", state.browser.screen],
["在线状态", state.browser.online],
["Cookie", state.browser.cookies],
["CPU 线程", state.browser.hardwareConcurrency],
["内存", state.browser.memory],
["触控点", state.browser.touch],
["WebGL Vendor", state.browser.webglVendor],
["WebGL Renderer", state.browser.webglRenderer],
["UA", state.browser.userAgent]
]);
}
async function renderWebRtc() {
if (!window.RTCPeerConnection) {
$("webrtcDetails").textContent = "当前 WebView2 环境不支持 RTCPeerConnection。";
setBadge("webrtcStatus", "warn", "不可用");
return;
}
const candidates = new Set();
try {
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
pc.createDataChannel("probe");
pc.onicecandidate = (event) => {
if (event.candidate?.candidate) candidates.add(event.candidate.candidate);
};
await pc.setLocalDescription(await pc.createOffer());
await new Promise((resolve) => setTimeout(resolve, 1800));
pc.close();
state.webrtc = Array.from(candidates);
$("webrtcDetails").textContent = state.webrtc.length
? state.webrtc.join("\n")
: "未暴露候选地址,或当前 WebView2 策略阻止采集。";
const leaksPublic = state.webrtc.some((line) => /(srflx|relay)/i.test(line));
setBadge("webrtcStatus", leaksPublic ? "warn" : "ok", leaksPublic ? "有候选" : "未发现");
} catch (error) {
$("webrtcDetails").textContent = `WebRTC 检测失败:${error.message}`;
setBadge("webrtcStatus", "bad", "失败");
}
}
async function renderDns() {
const active = activeInterfaces();
const rows = active.map((item) => [
item.name,
item.dnsServers?.length ? item.dnsServers.join(" / ") : "未配置",
item.dnsServers?.length ? "ok" : "warn"
]);
if (rows.length === 0) rows.push(["DNS", "未发现活动接口", "bad"]);
try {
const probeName = `ymhut-${Date.now()}.cloudflare.com`;
const query = `${endpoints.doh}?name=${encodeURIComponent(probeName)}&type=A`;
const response = await bridgeFetch({ url: query, headers: { accept: "application/dns-json" } });
state.dnsProbe = parseJson(response.content) || {};
rows.push(["DoH 探测", `${response.status} / ${Math.round(response.elapsedMs)} ms`, "ok"]);
rows.push(["泄漏判断", "已列出本机 DNS;远端递归出口需专用回显域名才能精确归因", "warn"]);
} catch (error) {
rows.push(["DoH 探测", error.message, "warn"]);
}
setMetrics("dnsDetails", rows);
setBadge("dnsStatus", rows.some((row) => row[2] === "ok") ? "ok" : "warn", "完成");
}
function renderLeakComparison() {
const local = flatten(activeInterfaces().map((item) => [...(item.ipv4 || []), ...(item.ipv6 || [])]));
const publicValues = [state.publicIp?.ip, state.ipVersions?.ipv4, state.ipVersions?.ipv6, state.trace?.ip].filter(Boolean);
const webrtcValues = state.webrtc.join("\n");
const leakedLocal = local.filter((ip) => webrtcValues.includes(ip));
setMetrics("leakDetails", [
["公网观测", publicValues.join(" / ") || "未获得", publicValues.length ? "ok" : "warn"],
["WebRTC 本机地址", leakedLocal.join(" / ") || "未暴露完整本机地址", leakedLocal.length ? "warn" : "ok"],
["代理一致性", state.diagnostics?.proxy?.enabled ? "系统代理已启用,建议复核浏览器出口" : "未启用系统代理", state.diagnostics?.proxy?.enabled ? "warn" : "ok"],
["Trace 对照", state.trace?.ip && state.publicIp?.ip && state.trace.ip !== state.publicIp.ip ? "不同观测点结果不一致" : "未发现明显冲突", "ok"]
]);
setBadge("leakStatus", leakedLocal.length ? "warn" : "ok", leakedLocal.length ? "需注意" : "正常");
}
async function renderLatency() {
const targets = [
["Cloudflare", "1.1.1.1"],
["Google DNS", "8.8.8.8"],
["Quad9", "9.9.9.9"]
];
const rows = [];
for (const [name, host] of targets) {
try {
const result = await window.ymhut.network.ping({ host, count: 3, timeoutMs: 1800 });
state.latency[name] = result;
rows.push([name, result.avgMs >= 0 ? `${result.avgMs} ms / 丢包 ${result.lossPercent}%` : "无响应", result.avgMs >= 0 ? "ok" : "warn"]);
} catch (error) {
rows.push([name, error.message, "warn"]);
}
}
rows.push(["DOM 响应", `${Math.round(performance.now() - state.startedAt)} ms`, "ok"]);
setMetrics("latencyDetails", rows);
setBadge("latencyStatus", rows.some((row) => row[2] === "ok") ? "ok" : "warn", "完成");
}
async function renderSpeed() {
const active = activeInterfaces();
const maxLink = Math.max(0, ...active.map((item) => Number(item.speedMbps || 0)));
try {
const downStart = performance.now();
const down = await bridgeFetch({ url: endpoints.speedDown });
const downSeconds = Math.max(0.001, (performance.now() - downStart) / 1000);
const downMbps = (Number(down.content.length || 1000000) * 8 / downSeconds / 1000000);
const upPayload = "0".repeat(250000);
const upStart = performance.now();
await bridgeFetch({ url: endpoints.speedUp, method: "POST", body: upPayload, headers: { "content-type": "text/plain" } });
const upSeconds = Math.max(0.001, (performance.now() - upStart) / 1000);
const upMbps = (upPayload.length * 8 / upSeconds / 1000000);
state.speed = { downloadMbps: downMbps, uploadMbps: upMbps };
$("speedValue").textContent = downMbps.toFixed(1);
$("speedUnit").textContent = "Mbps down";
$("speedNote").textContent = `上传 ${upMbps.toFixed(1)} Mbps;本机最大链路 ${maxLink ? `${maxLink.toLocaleString()} Mbps` : "未知"}`;
setBadge("speedStatus", "ok", "完成");
} catch (error) {
$("speedValue").textContent = maxLink ? maxLink.toLocaleString() : "--";
$("speedUnit").textContent = "Mbps link";
$("speedNote").textContent = maxLink
? `测速失败:${error.message}。当前显示本机网卡报告链路速率。`
: `测速失败:${error.message},且未发现可用链路速率。`;
setBadge("speedStatus", maxLink ? "warn" : "bad", maxLink ? "链路" : "失败");
}
}
function renderSecurity() {
const rows = [
["HTTPS / TLS", state.trace?.tls ? `TLS ${state.trace.tls}` : "未获得 Trace", state.trace?.tls ? "ok" : "warn"],
["Cloudflare WARP", state.trace?.warp || "未知", state.trace?.warp === "off" ? "ok" : "warn"],
["系统代理", state.diagnostics?.proxy?.enabled ? `${state.diagnostics.proxy.mode} ${state.diagnostics.proxy.host || ""}` : "未启用", state.diagnostics?.proxy?.enabled ? "warn" : "ok"],
["WebRTC 泄漏", state.webrtc.length ? "存在候选地址,需检查是否暴露真实地址" : "未发现候选地址", state.webrtc.length ? "warn" : "ok"],
["DNS 配置", (state.diagnostics?.summary?.dnsServers || []).length ? "已发现 DNS 服务器" : "未发现 DNS", (state.diagnostics?.summary?.dnsServers || []).length ? "ok" : "warn"],
["浏览器指纹", "已采集 UA、语言、屏幕、WebGL、硬件线程等本地指标", "info"]
];
setChecks("securityDetails", rows);
setBadge("securityStatus", rows.some((row) => row[2] === "bad") ? "bad" : rows.some((row) => row[2] === "warn") ? "warn" : "ok", "完成");
}
function renderInterfaces() {
const items = state.diagnostics?.interfaces || [];
if (items.length === 0) {
$("interfaceDetails").innerHTML = '<div class="metric"><span>接口</span><strong class="badText">未发现</strong></div>';
return;
}
$("interfaceDetails").innerHTML = items.map((item) => `
<div class="interfaceItem">
<div>
<strong>${escapeHtml(item.name)}</strong>
<span>${escapeHtml(item.description)}</span>
</div>
<dl class="facts compact">
<dt>状态</dt><dd>${escapeHtml(item.status)} · ${escapeHtml(item.type)}</dd>
<dt>速率</dt><dd>${escapeHtml(item.speedMbps ? `${item.speedMbps} Mbps` : "--")}</dd>
<dt>IPv4</dt><dd>${escapeHtml((item.ipv4 || []).join(" / ") || "--")}</dd>
<dt>IPv6</dt><dd>${escapeHtml((item.ipv6 || []).join(" / ") || "--")}</dd>
<dt>网关</dt><dd>${escapeHtml((item.gateways || []).join(" / ") || "--")}</dd>
<dt>DNS</dt><dd>${escapeHtml((item.dnsServers || []).join(" / ") || "--")}</dd>
</dl>
</div>`).join("");
}
function renderStatusStrip() {
const summary = state.diagnostics?.summary || {};
const items = [
["公网 IP", state.publicIp?.ip || "未知"],
["IPv4 / IPv6", `${state.ipVersions?.ipv4 ? "4" : "-"} / ${state.ipVersions?.ipv6 ? "6" : "-"}`],
["活动接口", summary.activeInterfaceCount ?? 0],
["DNS", (summary.dnsServers || []).length]
];
$("statusStrip").innerHTML = items.map(([label, value]) => `
<div class="statusItem">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</div>`).join("");
}
function buildReport() {
const summary = state.diagnostics?.summary || {};
return [
"YMhut Box IPCheck 网络诊断报告",
`生成时间:${new Date().toLocaleString()}`,
`公网 IP${state.publicIp?.ip || "--"}`,
`ASN/ISP${[state.publicIp?.asn, state.publicIp?.isp].filter(Boolean).join(" / ") || "--"}`,
`位置:${[state.publicIp?.city, state.publicIp?.region, state.publicIp?.country].filter(Boolean).join(" / ") || "--"}`,
`IPv4${state.ipVersions?.ipv4 || "--"}`,
`IPv6${state.ipVersions?.ipv6 || "--"}`,
`Cloudflare Trace${state.trace?.colo || "--"} / WARP ${state.trace?.warp || "--"}`,
`活动接口:${summary.activeInterfaceCount ?? 0}`,
`DNS${(summary.dnsServers || []).join(" / ") || "--"}`,
`网关:${(summary.defaultGateways || []).join(" / ") || "--"}`,
`WebRTC${state.webrtc.join(" | ") || "未发现候选地址"}`,
`测速:${state.speed ? `${state.speed.downloadMbps.toFixed(1)} down / ${state.speed.uploadMbps.toFixed(1)} up Mbps` : "--"}`,
`浏览器:${state.browser.userAgent || "--"}`
].join("\n");
}
async function copyReport() {
try {
const report = buildReport();
await window.ymhut.clipboard.writeText(report);
await window.ymhut.output.set(report);
setBadge("copyStatus", "ok", "已复制");
} catch (error) {
setBadge("copyStatus", "bad", "复制失败");
await window.ymhut.log.warn("IPCheck copy failed", error.message);
}
}
async function writeOutputDemo() {
try {
const report = buildReport();
await window.ymhut.output.set(report);
$("bridgeOutput").textContent = "已把当前诊断报告写入宿主输出区。";
setBadge("bridgeStatus", "ok", "输出已写入");
} catch (error) {
$("bridgeOutput").textContent = `输出失败:${error.message}`;
setBadge("bridgeStatus", "bad", "输出失败");
}
}
async function saveSnapshotDemo() {
try {
const snapshot = {
savedAt: new Date().toISOString(),
ip: state.publicIp?.ip || "",
ipv4: state.ipVersions?.ipv4 || "",
ipv6: state.ipVersions?.ipv6 || "",
colo: state.trace?.colo || ""
};
await window.ymhut.storage.set("lastSnapshot", JSON.stringify(snapshot));
const stored = await window.ymhut.storage.get("lastSnapshot");
$("bridgeOutput").textContent = `已保存插件私有状态:\n${stored}`;
setBadge("bridgeStatus", "ok", "快照已保存");
} catch (error) {
$("bridgeOutput").textContent = `保存失败:${error.message}`;
setBadge("bridgeStatus", "bad", "保存失败");
}
}
async function openGuideDemo() {
try {
await window.ymhut.openExternal("https://github.com/YMhut/box-winUI3#plugins");
$("bridgeOutput").textContent = "已请求使用安全浏览器打开插件规范链接。";
setBadge("bridgeStatus", "ok", "链接已打开");
} catch (error) {
$("bridgeOutput").textContent = `打开链接失败:${error.message}`;
setBadge("bridgeStatus", "bad", "链接失败");
}
}
function cleanHost(value) {
return String(value || "")
.trim()
.replace(/^https?:\/\//i, "")
.split(/[/?#]/)[0];
}
function isIp(value) {
return /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(":");
}
function ipToNumber(ip) {
const parts = String(ip).split(".").map(Number);
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) return null;
return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0;
}
function domainMatches(target, domain) {
const host = cleanHost(target).toLowerCase();
const needle = String(domain || "").toLowerCase();
return host === needle || host.endsWith(`.${needle}`);
}
function cidrMatches(ip, cidr) {
const [base, bitsText] = String(cidr || "").split("/");
const bits = Number(bitsText);
const ipNumber = ipToNumber(ip);
const baseNumber = ipToNumber(base);
if (ipNumber === null || baseNumber === null || Number.isNaN(bits) || bits < 0 || bits > 32) return false;
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
return (ipNumber & mask) === (baseNumber & mask);
}
async function runLookup() {
const value = cleanHost($("lookupInput").value || $("primaryIp").textContent);
if (!value) return;
setBadge("toolStatus", "pending", "查询中");
setOutput("lookupOutput", "正在查询 IP 信息...");
try {
const url = isIp(value) ? `https://ipapi.co/${encodeURIComponent(value)}/json/` : `https://ipapi.co/${encodeURIComponent(value)}/json/`;
const { data } = await fetchJson(url);
state.lookup = normalizeIpInfo("ipapi.co", data);
setOutput("lookupOutput", shortJson(state.lookup || data));
setBadge("toolStatus", "ok", "完成");
} catch (error) {
setOutput("lookupOutput", `查询失败:${error.message}`);
setBadge("toolStatus", "bad", "失败");
}
}
async function runDnsLookup() {
const value = cleanHost($("dnsInput").value || "example.com");
setOutput("dnsOutput", "正在解析...");
try {
const bridge = await window.ymhut.network.dnsLookup({ host: value });
let doh = null;
try {
const { data } = await fetchJson(`${endpoints.doh}?name=${encodeURIComponent(value)}&type=A`, { headers: { accept: "application/dns-json" } });
doh = data;
} catch {
doh = null;
}
setOutput("dnsOutput", shortJson({ systemResolver: bridge, cloudflareDoh: doh }));
} catch (error) {
setOutput("dnsOutput", `解析失败:${error.message}`);
}
}
async function runPing() {
const value = cleanHost($("pingInput").value || "1.1.1.1");
setOutput("pingOutput", "正在 Ping...");
try {
const result = await window.ymhut.network.ping({ host: value, count: 5, timeoutMs: 2500 });
setOutput("pingOutput", shortJson(result));
} catch (error) {
setOutput("pingOutput", `Ping 失败:${error.message}`);
}
}
async function runTraceRoute() {
const value = cleanHost($("traceInput").value || "1.1.1.1");
setOutput("traceOutput", "正在追踪路由...");
try {
const result = await window.ymhut.network.traceRoute({ host: value, maxHops: 16, timeoutMs: 2200 });
setOutput("traceOutput", shortJson(result));
} catch (error) {
setOutput("traceOutput", `追踪失败:${error.message}`);
}
}
async function runWhois() {
const value = cleanHost($("whoisInput").value || $("primaryIp").textContent);
setOutput("whoisOutput", "正在查询 RDAP...");
try {
const url = `${isIp(value) ? endpoints.rdapIp : endpoints.rdapDomain}${encodeURIComponent(value)}`;
const { data } = await fetchJson(url);
setOutput("whoisOutput", shortJson(data));
} catch (error) {
setOutput("whoisOutput", `RDAP 查询失败:${error.message}`);
}
}
async function runMacLookup() {
const raw = $("macInput").value.trim();
const oui = raw.replace(/[^0-9a-f]/gi, "").slice(0, 6).toUpperCase();
if (oui.length < 6) {
setOutput("macOutput", "请输入至少 6 位十六进制 OUI。");
return;
}
if (macVendors[oui]) {
setOutput("macOutput", `${raw}\nOUI: ${oui}\n厂商: ${macVendors[oui]}\n来源: 内置常用 OUI 表`);
return;
}
setOutput("macOutput", "正在查询厂商...");
try {
const response = await bridgeFetch({ url: `${endpoints.mac}${encodeURIComponent(raw)}` });
setOutput("macOutput", `${raw}\nOUI: ${oui}\n厂商: ${response.content.trim()}`);
} catch (error) {
setOutput("macOutput", `${raw}\nOUI: ${oui}\n未在内置表命中,在线查询失败:${error.message}`);
}
}
async function runAsnConnectivity() {
const value = cleanHost($("asnInput").value || state.publicIp?.ip || $("primaryIp").textContent);
setOutput("asnOutput", "正在分析 ASN 连通性...");
try {
const ipValue = value.toUpperCase().startsWith("AS") ? state.publicIp?.ip : value;
const rdap = ipValue ? (await fetchJson(`${endpoints.rdapIp}${encodeURIComponent(ipValue)}`)).data : null;
const cidrs = (rdap?.cidr0_cidrs || []).map((item) => `${item.v4prefix || item.v6prefix}/${item.length}`);
const pings = [];
for (const target of ["1.1.1.1", "8.8.8.8", "9.9.9.9"]) {
try {
const ping = await window.ymhut.network.ping({ host: target, count: 2, timeoutMs: 1800 });
pings.push({ target, avgMs: ping.avgMs, lossPercent: ping.lossPercent });
} catch (error) {
pings.push({ target, error: error.message });
}
}
setOutput("asnOutput", shortJson({
input: value,
publicIp: state.publicIp?.ip,
asn: state.publicIp?.asn,
isp: state.publicIp?.isp,
rdapName: rdap?.name,
country: rdap?.country,
cidrs,
reachability: pings
}));
} catch (error) {
setOutput("asnOutput", `ASN 分析失败:${error.message}`);
}
}
async function runRuleTest() {
const target = cleanHost($("ruleTargetInput").value || state.publicIp?.ip || "example.com");
const ip = isIp(target) ? target : state.publicIp?.ip;
const country = (state.publicIp?.countryCode || "").toUpperCase();
const rules = ($("ruleInput").value || "DOMAIN-SUFFIX,example.com\nIP-CIDR,1.1.1.0/24\nGEOIP,CN")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const results = rules.map((line) => {
const [typeRaw, valueRaw] = line.split(",").map((part) => part?.trim());
const type = (typeRaw || "").toUpperCase();
const value = valueRaw || "";
let matched = false;
if (type === "DOMAIN" || type === "DOMAIN-SUFFIX") matched = domainMatches(target, value);
if (type === "IP-CIDR") matched = ip ? cidrMatches(ip, value) : false;
if (type === "GEOIP") matched = country === value.toUpperCase();
if (type === "KEYWORD") matched = target.toLowerCase().includes(value.toLowerCase());
return { rule: line, matched };
});
setOutput("ruleOutput", shortJson({ target, ip, country, results, firstMatch: results.find((item) => item.matched) || null }));
}
async function runCensorshipCheck() {
const custom = $("censorInput").value.trim();
const targets = custom ? [custom] : [
"https://www.cloudflare.com/cdn-cgi/trace",
"https://www.google.com/generate_204",
"https://www.wikipedia.org/",
"https://www.github.com/"
];
setOutput("censorOutput", "正在检查可达性...");
const rows = [];
for (const target of targets) {
const url = /^https?:\/\//i.test(target) ? target : `https://${target}`;
try {
const started = performance.now();
const response = await window.ymhut.http.fetch({ url });
rows.push({ url, ok: response.ok, status: response.status, elapsedMs: Math.round(performance.now() - started) });
} catch (error) {
rows.push({ url, ok: false, error: error.message });
}
}
setOutput("censorOutput", shortJson(rows));
}
async function runInvisibilityTest() {
const expected = $("invisibleInput").value.trim().toUpperCase();
const local = activeInterfaces();
const dns = flatten(local.map((item) => item.dnsServers));
const localIps = flatten(local.map((item) => [...(item.ipv4 || []), ...(item.ipv6 || [])]));
const publicIp = state.publicIp?.ip;
const country = (state.publicIp?.countryCode || "").toUpperCase();
const findings = [
{ item: "公网 IP", value: publicIp || "未知", status: publicIp ? "ok" : "warn" },
{ item: "期望国家", value: expected || "未指定", status: expected && country && expected !== country ? "warn" : "ok" },
{ item: "WebRTC 候选", value: state.webrtc.length ? `${state.webrtc.length}` : "未发现", status: state.webrtc.length ? "warn" : "ok" },
{ item: "本机私网地址", value: localIps.filter((ip) => ip.startsWith("10.") || ip.startsWith("192.168.") || ip.startsWith("172.")).join(" / ") || "未发现", status: "ok" },
{ item: "DNS 服务器", value: dns.join(" / ") || "未知", status: dns.length ? "ok" : "warn" },
{ item: "系统代理", value: state.diagnostics?.proxy?.enabled ? state.diagnostics.proxy.mode : "未启用", status: state.diagnostics?.proxy?.enabled ? "warn" : "ok" },
{ item: "Trace WARP", value: state.trace?.warp || "未知", status: state.trace?.warp === "off" ? "ok" : "warn" }
];
setOutput("invisibleOutput", shortJson({
score: findings.filter((item) => item.status === "warn").length === 0 ? "隐私暴露线索较少" : "存在需要复核的暴露线索",
country,
expectedCountry: expected || null,
findings
}));
}
async function runAll() {
state.startedAt = performance.now();
["ipStatus", "stackStatus", "traceStatus", "hostStatus", "webrtcStatus", "dnsStatus", "latencyStatus", "speedStatus", "qualityStatus", "securityStatus", "leakStatus"].forEach((id) => setBadge(id, "pending", "检测中"));
$("primaryIp").textContent = "正在检测...";
$("primarySummary").textContent = "正在并行检测公网视角、本机网络、DNS、WebRTC、浏览器指纹和链路质量。";
await loadDiagnostics();
await Promise.allSettled([loadPublicIp(), loadTrace(), loadIpVersions()]);
renderBrowser();
renderIdentity();
renderStack();
renderTrace();
renderQuality();
await renderWebRtc();
await renderDns();
renderLeakComparison();
await renderLatency();
await renderSpeed();
renderSecurity();
renderInterfaces();
renderStatusStrip();
await window.ymhut.log.info("IPCheck diagnostics completed", JSON.stringify({
publicIp: state.publicIp?.ip,
ipv4: state.ipVersions?.ipv4,
ipv6: state.ipVersions?.ipv6,
colo: state.trace?.colo
}));
}
function wireAdvancedTools() {
$("lookupBtn").addEventListener("click", runLookup);
$("dnsBtn").addEventListener("click", runDnsLookup);
$("pingBtn").addEventListener("click", runPing);
$("traceBtn").addEventListener("click", runTraceRoute);
$("whoisBtn").addEventListener("click", runWhois);
$("macBtn").addEventListener("click", runMacLookup);
$("asnBtn").addEventListener("click", runAsnConnectivity);
$("ruleBtn").addEventListener("click", runRuleTest);
$("censorBtn").addEventListener("click", runCensorshipCheck);
$("invisibleBtn").addEventListener("click", runInvisibilityTest);
["lookupInput", "dnsInput", "pingInput", "traceInput", "whoisInput", "macInput", "asnInput", "ruleTargetInput", "censorInput", "invisibleInput"].forEach((id) => {
$(id).addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
const buttonId = {
ruleTargetInput: "ruleBtn",
censorInput: "censorBtn",
invisibleInput: "invisibleBtn"
}[id] || id.replace("Input", "Btn");
$(buttonId)?.click();
});
});
}
document.addEventListener("DOMContentLoaded", () => {
$("rerunBtn").addEventListener("click", runAll);
$("copyBtn").addEventListener("click", copyReport);
$("outputDemoBtn").addEventListener("click", writeOutputDemo);
$("storageDemoBtn").addEventListener("click", saveSnapshotDemo);
$("guideDemoBtn").addEventListener("click", openGuideDemo);
wireAdvancedTools();
runAll();
});
@@ -0,0 +1,519 @@
/* 原创黑白极简网络仪表盘:点阵背景、紧凑卡片和状态徽章,适配独立插件窗口。 */
:root {
color-scheme: light;
--bg: #f7f7f4;
--ink: #101010;
--muted: #606064;
--line: #deded8;
--panel: rgba(255, 255, 255, 0.92);
--panel-strong: #ffffff;
--panel-soft: #efefeb;
--ok: #0b7a3b;
--warn: #946100;
--bad: #b42318;
--info: #245aa5;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
background:
radial-gradient(#d0d0ca 1px, transparent 1px) 0 0 / 22px 22px,
linear-gradient(180deg, #fbfbf8 0%, var(--bg) 100%);
}
button,
input {
font: inherit;
}
button {
border: 1px solid var(--ink);
background: #ffffff;
color: var(--ink);
min-height: 38px;
padding: 0 14px;
border-radius: 6px;
font-weight: 700;
cursor: pointer;
}
button.primary,
button:hover {
background: var(--ink);
color: #ffffff;
}
button:disabled {
cursor: progress;
opacity: 0.58;
}
input {
width: 100%;
min-height: 38px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0 11px;
background: #ffffff;
color: var(--ink);
}
textarea {
width: 100%;
min-height: 84px;
margin-top: 10px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px 11px;
resize: vertical;
background: #ffffff;
color: var(--ink);
font: 12px/1.5 "Cascadia Mono", Consolas, monospace;
}
input:focus,
textarea:focus,
button:focus-visible,
a:focus-visible {
outline: 2px solid #111111;
outline-offset: 2px;
}
a {
color: inherit;
}
.shell {
width: min(1220px, calc(100vw - 36px));
margin: 0 auto;
padding: 28px 0 34px;
}
.hero {
min-height: 172px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 20px;
align-items: end;
padding: 26px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.brandMark {
width: 58px;
height: 58px;
position: relative;
align-self: start;
display: grid;
place-items: center;
}
.brandMark::before,
.brandMark::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid var(--ink);
border-radius: 50%;
opacity: 0.16;
}
.brandMark::after {
inset: 11px;
opacity: 0.36;
}
.brandMark span {
width: 18px;
height: 18px;
border: 2px solid var(--ink);
border-radius: 50%;
background: #ffffff;
}
.heroText {
min-width: 0;
}
.eyebrow,
.label {
margin: 0 0 6px;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0;
font-weight: 800;
}
h1,
h2,
h3 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: clamp(34px, 5vw, 66px);
line-height: 1.02;
word-break: break-all;
}
h2 {
font-size: 18px;
}
h3 {
font-size: 16px;
}
.summary,
.muted {
margin: 10px 0 0;
color: var(--muted);
line-height: 1.58;
}
.heroActions,
.formRow {
display: flex;
gap: 10px;
}
.bridgeActions {
margin-top: 12px;
flex-wrap: wrap;
}
.heroActions {
flex-wrap: wrap;
justify-content: flex-end;
}
.quickStats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 12px 0;
}
.statusItem {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: rgba(255, 255, 255, 0.78);
}
.statusItem span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.statusItem strong {
display: block;
font-size: 22px;
margin-top: 4px;
overflow-wrap: anywhere;
}
.toolNav {
position: sticky;
top: 0;
z-index: 3;
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 10px 0 12px;
backdrop-filter: blur(12px);
}
.toolNav a {
min-height: 32px;
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.86);
color: var(--muted);
text-decoration: none;
font-size: 13px;
font-weight: 750;
}
.toolNav a:hover {
color: var(--ink);
border-color: var(--ink);
}
.grid,
.toolGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.toolGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 0;
}
.card,
.toolPanel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.card {
min-height: 248px;
padding: 18px;
}
.toolCard {
min-height: 260px;
}
.toolPanel {
padding: 18px;
margin-bottom: 12px;
}
.span2 {
grid-column: span 2;
}
.cardHeader,
.sectionHeader {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 26px;
border-radius: 999px;
border: 1px solid var(--line);
padding: 0 10px;
white-space: nowrap;
font-size: 12px;
font-weight: 800;
}
.badge.ok {
color: var(--ok);
border-color: rgba(11, 122, 59, 0.35);
background: rgba(11, 122, 59, 0.08);
}
.badge.warn,
.badge.pending {
color: var(--warn);
border-color: rgba(148, 97, 0, 0.35);
background: rgba(148, 97, 0, 0.08);
}
.badge.bad {
color: var(--bad);
border-color: rgba(180, 35, 24, 0.35);
background: rgba(180, 35, 24, 0.08);
}
.badge.info {
color: var(--info);
border-color: rgba(36, 90, 165, 0.35);
background: rgba(36, 90, 165, 0.08);
}
.facts {
display: grid;
grid-template-columns: 134px minmax(0, 1fr);
gap: 10px 14px;
margin: 0;
}
.facts.compact {
grid-template-columns: 96px minmax(0, 1fr);
}
dt {
color: var(--muted);
font-weight: 700;
}
dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
}
.metricList,
.checkList {
display: grid;
gap: 10px;
}
.metric,
.checkItem {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.metric:last-child,
.checkItem:last-child {
border-bottom: 0;
}
.metric strong,
.checkItem strong {
overflow-wrap: anywhere;
text-align: right;
}
.okText {
color: var(--ok);
}
.warnText {
color: var(--warn);
}
.badText {
color: var(--bad);
}
.monoBlock {
min-height: 148px;
padding: 12px;
border-radius: 6px;
background: var(--panel-soft);
color: #222222;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 12px;
line-height: 1.55;
overflow: auto;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.monoBlock.small {
min-height: 156px;
max-height: 260px;
margin-top: 12px;
}
.speedValue {
display: flex;
align-items: baseline;
gap: 8px;
font-weight: 850;
font-size: 52px;
}
.speedValue small {
color: var(--muted);
font-size: 18px;
}
.interfaceList {
display: grid;
gap: 12px;
}
.interfaceItem {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
}
.interfaceItem strong,
.interfaceItem span {
display: block;
}
.interfaceItem span {
margin-top: 3px;
color: var(--muted);
overflow-wrap: anywhere;
}
.copyState {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
@media (max-width: 980px) {
.hero,
.quickStats,
.grid,
.toolGrid {
grid-template-columns: 1fr;
}
.span2 {
grid-column: auto;
}
.heroActions {
justify-content: flex-start;
}
}
@media (max-width: 620px) {
.shell {
width: min(100vw - 22px, 1220px);
padding-top: 16px;
}
.hero {
grid-template-columns: 1fr;
padding: 18px;
}
.brandMark {
width: 44px;
height: 44px;
}
.formRow {
flex-direction: column;
}
.facts,
.facts.compact,
.metric,
.checkItem {
grid-template-columns: 1fr;
}
.metric strong,
.checkItem strong {
text-align: left;
}
}
@@ -0,0 +1,52 @@
{
"id": "ipcheck-demo",
"name": "IPCheck 网络工具箱",
"version": "1.2.0",
"author": "YMhut Box",
"builtIn": true,
"description": "内置示例插件:以独立窗口运行的 IP、地理位置、DNS、WebRTC、浏览器指纹、测速、Ping、MTR、Whois/RDAP 和网络质量检测工具。",
"entry": "index.html",
"permissions": [
"Http",
"Log",
"Output",
"Storage",
"Clipboard",
"OpenExternal",
"NetworkDiagnostics"
],
"surfaces": [
{
"kind": "ToolboxTool",
"id": "ipcheck",
"name": "IPCheck 网络检测",
"description": "内置 IP 工具箱:公网 IP、ASN/ISP、地理位置、IPv4/IPv6、DNS/WebRTC 泄漏、测速、Ping、MTR、Whois/RDAP、MAC 厂商、规则测试、可达性与浏览器指纹。",
"entry": "index.html",
"category": "plugin",
"keywords": [
"ip",
"network",
"dns",
"webrtc",
"ipv4",
"ipv6",
"latency",
"speed",
"whois",
"rdap",
"mtr",
"rule",
"censorship",
"asn",
"fingerprint"
],
"iconGlyph": "\uE968"
}
],
"resources": [
"index.html",
"style.css",
"main.js",
"README.md"
]
}
@@ -0,0 +1,206 @@
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Settings;
using System.Security.Cryptography;
using System.Text;
namespace YMhut.Box.Core.Plugins;
public interface IBuiltInPluginInstallerService
{
Task EnsureInstalledAsync(CancellationToken cancellationToken = default);
}
public sealed class BuiltInPluginInstallerService(
AppPaths paths,
ILogService? logService = null,
ISettingsService? settingsService = null) : IBuiltInPluginInstallerService
{
private const string ResourcePrefix = "YMhut.Box.Core.Plugins.BuiltIn.";
private const string FingerprintFileName = ".ymhut-built-in.sha256";
private static readonly string[] KnownRootFiles =
[
"README.md",
"ymhut.plugin.json",
"index.html",
"style.css",
"main.js"
];
private readonly SemaphoreSlim _gate = new(1, 1);
public string PluginsRoot => PluginRegistryService.ResolvePluginsRoot(paths, settingsService?.Current.PluginRootPath);
public async Task EnsureInstalledAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
Directory.CreateDirectory(PluginsRoot);
var plugins = DiscoverEmbeddedPlugins();
if (plugins.Count == 0)
{
await WriteLogAsync("Warning", "No built-in plugin resources found", PluginsRoot, cancellationToken).ConfigureAwait(false);
return;
}
foreach (var plugin in plugins)
{
var targetRoot = Path.Combine(PluginsRoot, plugin.FolderName);
if (Directory.Exists(targetRoot))
{
if (await IsUnmodifiedBuiltInPluginAsync(targetRoot, cancellationToken).ConfigureAwait(false))
{
Directory.Delete(targetRoot, recursive: true);
await ExtractEmbeddedPluginAsync(plugin, targetRoot, cancellationToken).ConfigureAwait(false);
await WriteLogAsync("Information", "Built-in plugin refreshed", targetRoot, cancellationToken).ConfigureAwait(false);
}
continue;
}
await ExtractEmbeddedPluginAsync(plugin, targetRoot, cancellationToken).ConfigureAwait(false);
await WriteLogAsync("Information", "Built-in plugin installed", targetRoot, cancellationToken).ConfigureAwait(false);
}
}
finally
{
_gate.Release();
}
}
private static async Task<bool> IsUnmodifiedBuiltInPluginAsync(string targetRoot, CancellationToken cancellationToken)
{
var fingerprintPath = Path.Combine(targetRoot, FingerprintFileName);
if (!File.Exists(fingerprintPath))
{
return false;
}
try
{
var saved = (await File.ReadAllTextAsync(fingerprintPath, cancellationToken).ConfigureAwait(false)).Trim();
if (string.IsNullOrWhiteSpace(saved))
{
return false;
}
var current = await ComputeDirectoryFingerprintAsync(targetRoot, cancellationToken).ConfigureAwait(false);
return string.Equals(saved, current, StringComparison.OrdinalIgnoreCase);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
private static async Task ExtractEmbeddedPluginAsync(EmbeddedBuiltInPlugin plugin, string targetRoot, CancellationToken cancellationToken)
{
var assembly = typeof(BuiltInPluginInstallerService).Assembly;
Directory.CreateDirectory(targetRoot);
foreach (var resource in plugin.Resources)
{
cancellationToken.ThrowIfCancellationRequested();
var target = Path.GetFullPath(Path.Combine(targetRoot, resource.RelativePath));
if (!PluginRegistryService.IsInside(targetRoot, target))
{
throw new InvalidDataException($"Built-in plugin resource escapes target directory: {resource.ResourceName}");
}
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
await using var input = assembly.GetManifestResourceStream(resource.ResourceName)
?? throw new InvalidOperationException($"Built-in plugin resource cannot be opened: {resource.ResourceName}");
await using var output = File.Create(target);
await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
}
var fingerprint = await ComputeDirectoryFingerprintAsync(targetRoot, cancellationToken).ConfigureAwait(false);
await File.WriteAllTextAsync(Path.Combine(targetRoot, FingerprintFileName), fingerprint, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
}
private static async Task<string> ComputeDirectoryFingerprintAsync(string targetRoot, CancellationToken cancellationToken)
{
using var sha = SHA256.Create();
var files = Directory.EnumerateFiles(targetRoot, "*", SearchOption.AllDirectories)
.Where(path => !string.Equals(Path.GetFileName(path), FingerprintFileName, StringComparison.OrdinalIgnoreCase))
.Select(path => Path.GetFullPath(path))
.Order(StringComparer.OrdinalIgnoreCase);
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
var relative = file.Substring(Path.GetFullPath(targetRoot).Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Replace('\\', '/');
var nameBytes = Encoding.UTF8.GetBytes(relative);
sha.TransformBlock(nameBytes, 0, nameBytes.Length, null, 0);
sha.TransformBlock([0], 0, 1, null, 0);
var content = await File.ReadAllBytesAsync(file, cancellationToken).ConfigureAwait(false);
sha.TransformBlock(content, 0, content.Length, null, 0);
sha.TransformBlock([0], 0, 1, null, 0);
}
sha.TransformFinalBlock([], 0, 0);
return Convert.ToHexString(sha.Hash ?? []);
}
private Task WriteLogAsync(string level, string message, string detail, CancellationToken cancellationToken)
{
return logService?.WriteAsync(level, "plugin", message, detail, cancellationToken) ?? Task.CompletedTask;
}
private static IReadOnlyList<EmbeddedBuiltInPlugin> DiscoverEmbeddedPlugins()
{
var assembly = typeof(BuiltInPluginInstallerService).Assembly;
var resources = assembly.GetManifestResourceNames()
.Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal))
.Select(ParseResource)
.OfType<EmbeddedBuiltInResource>()
.GroupBy(resource => resource.PluginToken, StringComparer.OrdinalIgnoreCase)
.Select(group => new EmbeddedBuiltInPlugin(
DecodePluginFolder(group.Key),
group.OrderBy(resource => resource.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray()))
.Where(plugin => plugin.Resources.Any(resource => string.Equals(resource.RelativePath, PluginManifest.FileName, StringComparison.OrdinalIgnoreCase)))
.OrderBy(plugin => plugin.FolderName, StringComparer.OrdinalIgnoreCase)
.ToArray();
return resources;
}
private static EmbeddedBuiltInResource? ParseResource(string resourceName)
{
var relativeName = resourceName[ResourcePrefix.Length..];
foreach (var fileName in KnownRootFiles)
{
var suffix = "." + fileName;
if (!relativeName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var token = relativeName[..^suffix.Length];
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
return new EmbeddedBuiltInResource(token, fileName, resourceName);
}
return null;
}
private static string DecodePluginFolder(string token)
{
return token.Replace('_', '-');
}
private sealed record EmbeddedBuiltInPlugin(
string FolderName,
IReadOnlyList<EmbeddedBuiltInResource> Resources);
private sealed record EmbeddedBuiltInResource(
string PluginToken,
string RelativePath,
string ResourceName);
}
@@ -0,0 +1,163 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace YMhut.Box.Core.Plugins;
public static class PluginHostProtocol
{
public const string Version = "1";
public const string Ready = "ready";
public const string Ping = "ping";
public const string Pong = "pong";
public const string GetSnapshot = "getSnapshot";
public const string Reload = "reload";
public const string SetPluginEnabled = "setPluginEnabled";
public const string SetPermission = "setPermission";
public const string SetSurfaceMounted = "setSurfaceMounted";
public const string BridgeCall = "bridgeCall";
public const string SnapshotChanged = "snapshotChanged";
public const string Shutdown = "shutdown";
public const string Error = "error";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter<PluginPermission>(), new JsonStringEnumConverter<PluginSurfaceKind>() }
};
public static string Serialize(PluginHostMessage message)
{
return JsonSerializer.Serialize(message, JsonOptions);
}
public static PluginHostMessage? Deserialize(string line)
{
return JsonSerializer.Deserialize<PluginHostMessage>(line, JsonOptions);
}
}
public enum PluginHostStatus
{
Stopped,
Starting,
Ready,
Failed
}
public sealed record PluginHostMessage(
string Type,
string RequestId = "",
string? Version = null,
string? PluginId = null,
string? SurfaceId = null,
PluginPermission? Permission = null,
bool? Enabled = null,
bool? Granted = null,
bool? Mounted = null,
PluginSnapshot? Snapshot = null,
PluginBridgeRequest? BridgeRequest = null,
PluginBridgeResponse? BridgeResponse = null,
string? Error = null);
public sealed record PluginSnapshot(
bool PluginsEnabled,
string PluginsRoot,
IReadOnlyList<LoadedPluginDto> Plugins,
IReadOnlyList<PluginToolDto> EnabledTools,
DateTimeOffset LoadedAt);
public sealed record LoadedPluginDto(
PluginManifest Manifest,
string RootPath,
PluginRuntimeStateDto State,
IReadOnlyList<string> Errors)
{
public bool IsValid => Errors.Count == 0;
public LoadedPlugin ToLoadedPlugin()
{
return new LoadedPlugin(
Manifest,
RootPath,
State.ToRuntimeState(),
Errors);
}
public static LoadedPluginDto FromLoadedPlugin(LoadedPlugin plugin)
{
return new LoadedPluginDto(
plugin.Manifest,
plugin.RootPath,
PluginRuntimeStateDto.FromRuntimeState(plugin.State),
plugin.Errors.ToArray());
}
}
public sealed record PluginRuntimeStateDto(
string PluginId,
bool Enabled,
IReadOnlyList<PluginPermission> GrantedPermissions,
IReadOnlyList<string> MountedSurfaceIds,
DateTimeOffset? LastRunAt)
{
public PluginRuntimeState ToRuntimeState()
{
return new PluginRuntimeState(
PluginId,
Enabled,
GrantedPermissions.ToHashSet(),
MountedSurfaceIds.ToHashSet(StringComparer.OrdinalIgnoreCase),
LastRunAt);
}
public static PluginRuntimeStateDto FromRuntimeState(PluginRuntimeState state)
{
return new PluginRuntimeStateDto(
state.PluginId,
state.Enabled,
state.GrantedPermissions.ToArray(),
state.MountedSurfaceIds.ToArray(),
state.LastRunAt);
}
}
public sealed record PluginToolDto(
string PluginId,
string SurfaceId,
string ToolId,
PluginManifest Manifest,
PluginSurface Surface,
string RootPath,
PluginRuntimeStateDto State)
{
public PluginToolModule ToToolModule()
{
var plugin = new LoadedPlugin(Manifest, RootPath, State.ToRuntimeState(), []);
return new PluginToolModule(plugin, Surface);
}
public static PluginToolDto FromPluginToolModule(PluginToolModule module)
{
return new PluginToolDto(
module.Plugin.Manifest.Id,
module.Surface.Id,
module.Id,
module.Plugin.Manifest,
module.Surface,
module.Plugin.RootPath,
PluginRuntimeStateDto.FromRuntimeState(module.Plugin.State));
}
}
public sealed record PluginBridgeRequest(
string PluginId,
string SurfaceId,
string Method,
string PayloadJson);
public sealed record PluginBridgeResponse(
bool Ok,
string? ValueJson = null,
string? Error = null,
string? UiAction = null);
@@ -0,0 +1,24 @@
using YMhut.Box.Core.Logging;
namespace YMhut.Box.Core.Plugins;
public sealed class PluginLogService(ILogService logService)
{
public Task WriteAsync(
string pluginId,
string level,
string message,
string? detail = null,
CancellationToken cancellationToken = default)
{
return logService.WriteAsync(level, $"plugin:{pluginId}", message, detail, cancellationToken);
}
public Task<IReadOnlyList<LogEntry>> ReadAsync(
string pluginId,
int take = 200,
CancellationToken cancellationToken = default)
{
return logService.ReadAsync(category: $"plugin:{pluginId}", take: take, cancellationToken: cancellationToken);
}
}
+127
View File
@@ -0,0 +1,127 @@
using System.Text.Json.Serialization;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.Core.Plugins;
public enum PluginPermission
{
Input,
Output,
Log,
Storage,
Http,
Clipboard,
FilePicker,
RunTool,
OpenExternal,
NetworkDiagnostics
}
public enum PluginSurfaceKind
{
ToolboxTool,
NavPage
}
public sealed record PluginManifest(
string Id,
string Name,
string Version,
string Author,
string Description,
string Entry,
IReadOnlyList<PluginPermission> Permissions,
IReadOnlyList<PluginSurface> Surfaces,
IReadOnlyList<string> Resources)
{
public static readonly string FileName = "ymhut.plugin.json";
}
public sealed record PluginSurface(
PluginSurfaceKind Kind,
string Id,
string Name,
string Description,
string Entry = "",
string Category = "dev",
IReadOnlyList<string>? Keywords = null,
string IconGlyph = "\uECAA")
{
public string EffectiveEntry(string pluginEntry) => string.IsNullOrWhiteSpace(Entry) ? pluginEntry : Entry;
}
public sealed record PluginRuntimeState(
string PluginId,
bool Enabled,
IReadOnlySet<PluginPermission> GrantedPermissions,
IReadOnlySet<string> MountedSurfaceIds,
DateTimeOffset? LastRunAt);
public sealed record LoadedPlugin(
PluginManifest Manifest,
string RootPath,
PluginRuntimeState State,
IReadOnlyList<string> Errors)
{
public bool IsValid => Errors.Count == 0;
}
public sealed class PluginToolModule(LoadedPlugin plugin, PluginSurface surface) : IToolModule
{
public LoadedPlugin Plugin { get; } = plugin;
public PluginSurface Surface { get; } = surface;
public string Id { get; } = PluginIds.ToolId(plugin.Manifest.Id, surface.Id);
public ToolMetadata Metadata { get; } = new(
PluginIds.ToolId(plugin.Manifest.Id, surface.Id),
surface.Name,
surface.Description,
ToolCategory.Plugin,
(surface.Keywords ?? []).Concat(["plugin", plugin.Manifest.Id, plugin.Manifest.Name]).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
false,
string.IsNullOrWhiteSpace(surface.IconGlyph) ? "\uECAA" : surface.IconGlyph);
public object CreateViewModel() => new ToolModuleViewModel(Metadata);
}
public static class PluginIds
{
public const string Prefix = "plugin:";
public static bool IsPluginToolId(string id) => id.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase);
public static string ToolId(string pluginId, string surfaceId) => $"{Prefix}{Normalize(pluginId)}:{Normalize(surfaceId)}";
public static string Normalize(string id) => id.Trim().ToLowerInvariant();
public static bool IsSafeId(string id)
{
return !string.IsNullOrWhiteSpace(id) &&
id.Length <= 64 &&
id.All(ch => char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_' or '.');
}
public static ToolCategory ParseCategory(string? category)
{
return category?.Trim().ToLowerInvariant() switch
{
"plugin" => ToolCategory.Plugin,
"network" => ToolCategory.Network,
"security" => ToolCategory.Security,
"data" => ToolCategory.Data,
"calculator" => ToolCategory.Calculator,
"text" => ToolCategory.Text,
"image" => ToolCategory.Image,
"design" => ToolCategory.Design,
"life" => ToolCategory.Life,
"system" => ToolCategory.System,
_ => ToolCategory.Dev
};
}
}
[JsonSerializable(typeof(PluginManifest))]
[JsonSerializable(typeof(PluginSurface))]
internal sealed partial class PluginJsonContext : JsonSerializerContext;
@@ -0,0 +1,223 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.Core.Plugins;
public interface IPluginRegistryService
{
string PluginsRoot { get; }
Task<IReadOnlyList<LoadedPlugin>> LoadPluginsAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<PluginToolModule>> LoadEnabledToolModulesAsync(CancellationToken cancellationToken = default);
}
public sealed class PluginRegistryService(
AppPaths paths,
IPluginStateStore stateStore,
ILogService? logService = null,
ISettingsService? settingsService = null,
IBuiltInPluginInstallerService? builtInInstaller = null) : IPluginRegistryService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter<PluginPermission>(), new JsonStringEnumConverter<PluginSurfaceKind>() }
};
public string PluginsRoot => ResolvePluginsRoot(paths, settingsService?.Current.PluginRootPath);
public static string DefaultPluginsRoot(AppPaths paths) => Path.Combine(paths.Root, "Plugins");
public static string ResolvePluginsRoot(AppPaths paths, string? configuredPath)
{
return string.IsNullOrWhiteSpace(configuredPath)
? DefaultPluginsRoot(paths)
: Path.GetFullPath(Environment.ExpandEnvironmentVariables(configuredPath.Trim()));
}
public async Task<IReadOnlyList<LoadedPlugin>> LoadPluginsAsync(CancellationToken cancellationToken = default)
{
if (settingsService is not null && !settingsService.Current.PluginsEnabled)
{
return [];
}
if (builtInInstaller is not null)
{
await builtInInstaller.EnsureInstalledAsync(cancellationToken).ConfigureAwait(false);
}
Directory.CreateDirectory(PluginsRoot);
var plugins = new List<LoadedPlugin>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var builtInIds = ToolCatalog.DefaultModules().Select(module => module.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var directory in Directory.EnumerateDirectories(PluginsRoot).Order(StringComparer.OrdinalIgnoreCase))
{
var manifestPath = Path.Combine(directory, PluginManifest.FileName);
var errors = new List<string>();
PluginManifest? manifest = null;
try
{
if (!File.Exists(manifestPath))
{
errors.Add("Missing ymhut.plugin.json.");
}
else
{
await using var stream = File.OpenRead(manifestPath);
manifest = await JsonSerializer.DeserializeAsync<PluginManifest>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
if (manifest is null)
{
errors.Add("Manifest is empty or invalid.");
}
}
}
catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException)
{
errors.Add(exception.Message);
}
manifest ??= new PluginManifest(Path.GetFileName(directory), Path.GetFileName(directory), "0.0.0", string.Empty, string.Empty, string.Empty, [], [], []);
ValidateManifest(manifest, directory, seen, builtInIds, errors);
seen.Add(manifest.Id);
var state = await stateStore.GetStateAsync(manifest.Id, cancellationToken).ConfigureAwait(false);
var loaded = new LoadedPlugin(manifest, directory, state, errors);
plugins.Add(loaded);
if (errors.Count > 0)
{
await (logService?.WriteAsync("Warning", $"plugin:{manifest.Id}", "Plugin validation failed", string.Join(Environment.NewLine, errors), cancellationToken) ?? Task.CompletedTask)
.ConfigureAwait(false);
}
}
return plugins;
}
public async Task<IReadOnlyList<PluginToolModule>> LoadEnabledToolModulesAsync(CancellationToken cancellationToken = default)
{
if (settingsService is not null && !settingsService.Current.PluginsEnabled)
{
return [];
}
var plugins = await LoadPluginsAsync(cancellationToken).ConfigureAwait(false);
return plugins
.Where(plugin => plugin.IsValid && plugin.State.Enabled)
.SelectMany(plugin => plugin.Manifest.Surfaces
.Where(surface => surface.Kind == PluginSurfaceKind.ToolboxTool &&
(plugin.State.MountedSurfaceIds.Count == 0 || plugin.State.MountedSurfaceIds.Contains(surface.Id)))
.Select(surface => new PluginToolModule(plugin, surface)))
.ToArray();
}
private static void ValidateManifest(
PluginManifest manifest,
string root,
ISet<string> seen,
ISet<string> builtInIds,
IList<string> errors)
{
if (!PluginIds.IsSafeId(manifest.Id) || PluginIds.IsPluginToolId(manifest.Id))
{
errors.Add("Plugin id must be a safe local id and cannot start with plugin:.");
}
if (seen.Contains(manifest.Id))
{
errors.Add("Duplicate plugin id.");
}
if (string.IsNullOrWhiteSpace(manifest.Name))
{
errors.Add("Plugin name is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Version))
{
errors.Add("Plugin version is required.");
}
if (!IsSafeRelativeFile(root, manifest.Entry))
{
errors.Add("Plugin entry must point to a file inside the plugin directory.");
}
if (!HasReadme(root))
{
errors.Add("Plugin package must include README.md, README.txt, or 说明.md.");
}
var surfaceIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var surface in manifest.Surfaces)
{
if (!PluginIds.IsSafeId(surface.Id))
{
errors.Add($"Surface id is invalid: {surface.Id}");
}
if (!surfaceIds.Add(surface.Id))
{
errors.Add($"Duplicate surface id: {surface.Id}");
}
if (builtInIds.Contains(surface.Id) || builtInIds.Contains(PluginIds.ToolId(manifest.Id, surface.Id)))
{
errors.Add($"Surface id conflicts with a built-in tool: {surface.Id}");
}
if (!IsSafeRelativeFile(root, surface.EffectiveEntry(manifest.Entry)))
{
errors.Add($"Surface entry must point to a file inside the plugin directory: {surface.Id}");
}
}
foreach (var resource in manifest.Resources)
{
if (!IsSafeRelativePath(root, resource))
{
errors.Add($"Resource path escapes plugin directory: {resource}");
}
if (resource.Contains("Assets/icons", StringComparison.OrdinalIgnoreCase) ||
resource.Contains("developer", StringComparison.OrdinalIgnoreCase) ||
resource.Contains("about", StringComparison.OrdinalIgnoreCase))
{
errors.Add($"Core asset override is not allowed: {resource}");
}
}
}
public static bool IsSafeRelativeFile(string root, string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return false;
}
var fullPath = Path.GetFullPath(Path.Combine(root, relativePath));
return IsInside(root, fullPath) && File.Exists(fullPath);
}
public static bool IsSafeRelativePath(string root, string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return false;
}
var fullPath = Path.GetFullPath(Path.Combine(root, relativePath));
return IsInside(root, fullPath);
}
public static bool IsInside(string root, string path)
{
var normalizedRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var normalizedPath = Path.GetFullPath(path);
return normalizedPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase);
}
private static bool HasReadme(string root)
{
return File.Exists(Path.Combine(root, "README.md")) ||
File.Exists(Path.Combine(root, "README.txt")) ||
File.Exists(Path.Combine(root, "说明.md"));
}
}
@@ -0,0 +1,290 @@
using Microsoft.Data.Sqlite;
using YMhut.Box.Core.App;
namespace YMhut.Box.Core.Plugins;
public interface IPluginStateStore
{
string DatabasePath { get; }
Task<PluginRuntimeState> GetStateAsync(string pluginId, CancellationToken cancellationToken = default);
Task SetEnabledAsync(string pluginId, bool enabled, CancellationToken cancellationToken = default);
Task SetPermissionAsync(string pluginId, PluginPermission permission, bool granted, CancellationToken cancellationToken = default);
Task SetSurfaceMountedAsync(string pluginId, string surfaceId, bool mounted, CancellationToken cancellationToken = default);
Task MarkRunAsync(string pluginId, CancellationToken cancellationToken = default);
Task<string?> GetValueAsync(string pluginId, string key, CancellationToken cancellationToken = default);
Task SetValueAsync(string pluginId, string key, string value, CancellationToken cancellationToken = default);
Task RemoveValueAsync(string pluginId, string key, CancellationToken cancellationToken = default);
Task<IReadOnlyDictionary<string, string>> ListValuesAsync(string pluginId, CancellationToken cancellationToken = default);
}
public sealed class PluginStateStore : IPluginStateStore
{
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _initialized;
public PluginStateStore(AppPaths paths)
{
paths.EnsureCreated();
DatabasePath = Path.Combine(paths.Data, "plugins.db");
}
public string DatabasePath { get; }
public async Task<PluginRuntimeState> GetStateAsync(string pluginId, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
var enabled = false;
DateTimeOffset? lastRun = null;
await using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT enabled, last_run_at FROM plugin_states WHERE plugin_id = $plugin_id;";
command.Parameters.AddWithValue("$plugin_id", pluginId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
enabled = reader.GetInt32(0) != 0;
lastRun = reader.IsDBNull(1) ? null : DateTimeOffset.Parse(reader.GetString(1));
}
}
var permissions = await ReadStringSetAsync(connection, "plugin_permissions", "permission", pluginId, cancellationToken).ConfigureAwait(false);
var surfaces = await ReadStringSetAsync(connection, "plugin_surfaces", "surface_id", pluginId, cancellationToken).ConfigureAwait(false);
return new PluginRuntimeState(
pluginId,
enabled,
permissions.Select(Enum.Parse<PluginPermission>).ToHashSet(),
surfaces.ToHashSet(StringComparer.OrdinalIgnoreCase),
lastRun);
}
finally
{
_gate.Release();
}
}
public Task SetEnabledAsync(string pluginId, bool enabled, CancellationToken cancellationToken = default)
=> UpsertStateAsync(pluginId, enabled: enabled, markRun: false, cancellationToken);
public async Task SetPermissionAsync(string pluginId, PluginPermission permission, bool granted, CancellationToken cancellationToken = default)
{
await SetStringFlagAsync("plugin_permissions", "permission", pluginId, permission.ToString(), granted, cancellationToken).ConfigureAwait(false);
}
public async Task SetSurfaceMountedAsync(string pluginId, string surfaceId, bool mounted, CancellationToken cancellationToken = default)
{
await SetStringFlagAsync("plugin_surfaces", "surface_id", pluginId, surfaceId, mounted, cancellationToken).ConfigureAwait(false);
}
public Task MarkRunAsync(string pluginId, CancellationToken cancellationToken = default)
=> UpsertStateAsync(pluginId, enabled: null, markRun: true, cancellationToken);
public async Task<string?> GetValueAsync(string pluginId, string key, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT value FROM plugin_kv WHERE plugin_id = $plugin_id AND key = $key;";
command.Parameters.AddWithValue("$plugin_id", pluginId);
command.Parameters.AddWithValue("$key", key);
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return value as string;
}
finally
{
_gate.Release();
}
}
public async Task SetValueAsync(string pluginId, string key, string value, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO plugin_kv(plugin_id, key, value)
VALUES ($plugin_id, $key, $value)
ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value;
""";
command.Parameters.AddWithValue("$plugin_id", pluginId);
command.Parameters.AddWithValue("$key", key);
command.Parameters.AddWithValue("$value", value);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task RemoveValueAsync(string pluginId, string key, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM plugin_kv WHERE plugin_id = $plugin_id AND key = $key;";
command.Parameters.AddWithValue("$plugin_id", pluginId);
command.Parameters.AddWithValue("$key", key);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task<IReadOnlyDictionary<string, string>> ListValuesAsync(string pluginId, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT key, value FROM plugin_kv WHERE plugin_id = $plugin_id ORDER BY key;";
command.Parameters.AddWithValue("$plugin_id", pluginId);
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
values[reader.GetString(0)] = reader.GetString(1);
}
return values;
}
finally
{
_gate.Release();
}
}
private async Task UpsertStateAsync(string pluginId, bool? enabled, bool markRun, CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO plugin_states(plugin_id, enabled, last_run_at)
VALUES ($plugin_id, $enabled, $last_run_at)
ON CONFLICT(plugin_id) DO UPDATE SET
enabled = CASE WHEN $enabled_set = 1 THEN excluded.enabled ELSE plugin_states.enabled END,
last_run_at = CASE WHEN $mark_run = 1 THEN excluded.last_run_at ELSE plugin_states.last_run_at END;
""";
command.Parameters.AddWithValue("$plugin_id", pluginId);
command.Parameters.AddWithValue("$enabled", enabled == true ? 1 : 0);
command.Parameters.AddWithValue("$enabled_set", enabled is null ? 0 : 1);
command.Parameters.AddWithValue("$mark_run", markRun ? 1 : 0);
command.Parameters.AddWithValue("$last_run_at", DateTimeOffset.Now.ToString("O"));
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private async Task SetStringFlagAsync(string table, string column, string pluginId, string value, bool enabled, CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = enabled
? $"INSERT OR IGNORE INTO {table}(plugin_id, {column}) VALUES ($plugin_id, $value);"
: $"DELETE FROM {table} WHERE plugin_id = $plugin_id AND {column} = $value;";
command.Parameters.AddWithValue("$plugin_id", pluginId);
command.Parameters.AddWithValue("$value", value);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private static async Task<IReadOnlyList<string>> ReadStringSetAsync(SqliteConnection connection, string table, string column, string pluginId, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = $"SELECT {column} FROM {table} WHERE plugin_id = $plugin_id;";
command.Parameters.AddWithValue("$plugin_id", pluginId);
var values = new List<string>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
values.Add(reader.GetString(0));
}
return values;
}
private async Task EnsureInitializedAsync(CancellationToken cancellationToken)
{
if (_initialized)
{
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(DatabasePath)!);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
CREATE TABLE IF NOT EXISTS plugin_states (
plugin_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
last_run_at TEXT NULL
);
CREATE TABLE IF NOT EXISTS plugin_permissions (
plugin_id TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY(plugin_id, permission)
);
CREATE TABLE IF NOT EXISTS plugin_surfaces (
plugin_id TEXT NOT NULL,
surface_id TEXT NOT NULL,
PRIMARY KEY(plugin_id, surface_id)
);
CREATE TABLE IF NOT EXISTS plugin_kv (
plugin_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY(plugin_id, key)
);
""";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_initialized = true;
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection($"Data Source={DatabasePath}");
connection.Open();
return connection;
}
}
+36
View File
@@ -0,0 +1,36 @@
using System.Text.RegularExpressions;
namespace YMhut.Box.Core;
public static class SensitiveText
{
public static string Sanitize(string? message, int maxLength = 160)
{
if (string.IsNullOrWhiteSpace(message))
{
return "未知错误";
}
var sanitized = Regex.Replace(
message,
@"https?://[^\s\]\)""'<>]+",
"远程服务",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
sanitized = Regex.Replace(
sanitized,
@"update\.ymhut\.cn[^\s\]\)""'<>]*",
"远程服务",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
sanitized = sanitized
.Replace("update-info.json", "发布服务", StringComparison.OrdinalIgnoreCase)
.Replace("media-types.json", "媒体配置", StringComparison.OrdinalIgnoreCase)
.Replace("api_url", "媒体源", StringComparison.OrdinalIgnoreCase)
.Replace("download_url", "下载源", StringComparison.OrdinalIgnoreCase);
return sanitized.Length <= maxLength
? sanitized
: sanitized[..Math.Max(0, maxLength - 3)] + "...";
}
}
@@ -0,0 +1,169 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
using YMhut.Box.Core.App;
namespace YMhut.Box.Core.Settings;
public static class AgreementDocument
{
public const string CurrentVersion = "2.0.6.2";
public const int CurrentRevision = 1;
public static bool SettingsMatchCurrent(AppSettings settings)
=> settings.UserAgreementAccepted &&
string.Equals(settings.UserAgreementVersion, CurrentVersion, StringComparison.OrdinalIgnoreCase);
}
public sealed record AgreementAcceptance(
string Version,
int Revision,
string AppVersion,
string Language,
DateTimeOffset AcceptedAt);
public interface IAgreementAcceptanceStore
{
Task<AgreementAcceptance?> GetLatestAsync(CancellationToken cancellationToken = default);
Task RecordAcceptedAsync(
string version,
int revision,
string appVersion,
string language,
DateTimeOffset acceptedAt,
CancellationToken cancellationToken = default);
}
public sealed class AgreementAcceptanceStore : IAgreementAcceptanceStore
{
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _initialized;
public AgreementAcceptanceStore(AppPaths paths)
{
paths.EnsureCreated();
DatabasePath = AppDatabasePaths.ResolveMainDatabasePath(paths);
}
public string DatabasePath { get; }
public async Task<AgreementAcceptance?> GetLatestAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT agreement_version, agreement_revision, app_version, language, accepted_at
FROM agreement_acceptances
ORDER BY accepted_at DESC, id DESC
LIMIT 1;
""";
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return new AgreementAcceptance(
reader.GetString(0),
reader.GetInt32(1),
reader.GetString(2),
reader.GetString(3),
DateTimeOffset.TryParse(reader.GetString(4), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var acceptedAt)
? acceptedAt
: DateTimeOffset.MinValue);
}
finally
{
_gate.Release();
}
}
public async Task RecordAcceptedAsync(
string version,
int revision,
string appVersion,
string language,
DateTimeOffset acceptedAt,
CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO agreement_acceptances(
agreement_version,
agreement_revision,
app_version,
language,
accepted_at)
VALUES (
$agreement_version,
$agreement_revision,
$app_version,
$language,
$accepted_at);
""";
command.Parameters.AddWithValue("$agreement_version", version);
command.Parameters.AddWithValue("$agreement_revision", revision);
command.Parameters.AddWithValue("$app_version", appVersion);
command.Parameters.AddWithValue("$language", language);
command.Parameters.AddWithValue("$accepted_at", acceptedAt.ToString("O", CultureInfo.InvariantCulture));
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private async Task EnsureInitializedAsync(CancellationToken cancellationToken)
{
if (_initialized)
{
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(DatabasePath)!);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS agreement_acceptances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agreement_version TEXT NOT NULL,
agreement_revision INTEGER NOT NULL,
app_version TEXT NOT NULL,
language TEXT NOT NULL,
accepted_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_agreement_acceptances_latest
ON agreement_acceptances(accepted_at DESC, id DESC);
""";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_initialized = true;
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection($"Data Source={DatabasePath};Pooling=True");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = """
PRAGMA busy_timeout = 5000;
PRAGMA synchronous = NORMAL;
""";
command.ExecuteNonQuery();
return connection;
}
}
+118
View File
@@ -0,0 +1,118 @@
namespace YMhut.Box.Core.Settings;
public sealed class AppSettings
{
public string Theme { get; set; } = "Light";
public string Language { get; set; } = "zh-CN";
public bool PitchBlack { get; set; }
public string WindowBackdrop { get; set; } = "mica";
public string SettingsPanelMaterial { get; set; } = "solid";
public string TopBarMaterial { get; set; } = "inherit";
public int SeedColor { get; set; } = unchecked((int)0xFF5E6D44);
public double CardOpacity { get; set; } = 0.7;
public string BackgroundImage { get; set; } = string.Empty;
public double BackgroundOpacity { get; set; } = 1.0;
public bool UserAgreementAccepted { get; set; }
public string UserAgreementVersion { get; set; } = string.Empty;
public DateTimeOffset? UserAgreementAcceptedAt { get; set; }
public bool SidebarCollapsed { get; set; }
public bool DesktopTitlebar { get; set; } = true;
public List<string> RecentToolIds { get; set; } = [];
public List<string> PinnedToolIds { get; set; } = [];
public double? WindowX { get; set; }
public double? WindowY { get; set; }
public double? WindowWidth { get; set; }
public double? WindowHeight { get; set; }
public bool WindowMaximized { get; set; }
public bool ProxyEnabled { get; set; }
public string ProxyMode { get; set; } = "system";
public string ProxyHost { get; set; } = string.Empty;
public int ProxyPort { get; set; } = 7890;
public bool AnimationsEnabled { get; set; } = true;
public double FontSize { get; set; } = 1.0;
public string ToolDisplayMode { get; set; } = "grid";
public string ToolboxDefaultScope { get; set; } = "all";
public bool ToolboxCompactCards { get; set; }
public bool ToolboxShowRecentFirst { get; set; } = true;
public bool ShowHardwareBrandLogo { get; set; } = true;
public bool RequireRiskConfirmation { get; set; } = true;
public string HomePageMode { get; set; } = "dashboard";
public string CloseBehavior { get; set; } = "ask";
public bool RestoreWindowPosition { get; set; } = true;
public bool AutoStart { get; set; }
public int LogRetentionCount { get; set; }
public int DataRefreshInterval { get; set; } = 30;
public bool UpdateNotification { get; set; } = true;
public bool HardwareAccelerationEnabled { get; set; } = true;
public int ProxyTestTimeoutSeconds { get; set; } = 6;
public bool PluginsEnabled { get; set; }
public string PluginRootPath { get; set; } = string.Empty;
public string FeedbackDefaultContact { get; set; } = string.Empty;
public string FeedbackDefaultType { get; set; } = "issue";
public string FeedbackDefaultSeverity { get; set; } = "normal";
public bool FeedbackIncludeTodayLogsByDefault { get; set; }
public bool FeedbackIncludeToolStatusByDefault { get; set; }
public bool FeedbackIncludeSystemSummaryByDefault { get; set; }
public bool FeedbackRememberDefaults { get; set; } = true;
public string FeedbackDailySubmissionDate { get; set; } = string.Empty;
public int FeedbackDailySubmissionCount { get; set; }
public bool LegacyImportCompleted { get; set; }
public bool LegacyDatabaseImportCompleted { get; set; }
public string? LegacyDatabaseSnapshotPath { get; set; }
}
@@ -0,0 +1,106 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace YMhut.Box.Core.Settings;
public sealed class AppSettingsService(AppSettingsStore store) : ISettingsService
{
private readonly SemaphoreSlim _gate = new(1, 1);
private AppSettings _current = new();
public event PropertyChangedEventHandler? PropertyChanged;
public AppSettings Current => _current;
public string SettingsPath => store.SettingsPath;
public async Task<AppSettings> LoadAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_current = await store.LoadAsync(cancellationToken).ConfigureAwait(false);
OnPropertyChanged(nameof(Current));
return _current;
}
finally
{
_gate.Release();
}
}
public async Task SaveAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await store.SaveAsync(_current, cancellationToken).ConfigureAwait(false);
OnPropertyChanged(nameof(Current));
}
finally
{
_gate.Release();
}
}
public async Task UpdateAsync(Action<AppSettings> update, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_current is null)
{
_current = await store.LoadAsync(cancellationToken).ConfigureAwait(false);
}
update(_current);
await store.SaveAsync(_current, cancellationToken).ConfigureAwait(false);
OnPropertyChanged(nameof(Current));
}
finally
{
_gate.Release();
}
}
public async Task RecordRecentToolAsync(string toolId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(toolId))
{
return;
}
await UpdateAsync(settings =>
{
settings.RecentToolIds.RemoveAll(id => string.Equals(id, toolId, StringComparison.OrdinalIgnoreCase));
settings.RecentToolIds.Insert(0, toolId);
if (settings.RecentToolIds.Count > 12)
{
settings.RecentToolIds.RemoveRange(12, settings.RecentToolIds.Count - 12);
}
}, cancellationToken).ConfigureAwait(false);
}
public Task SaveWindowBoundsAsync(
double x,
double y,
double width,
double height,
bool maximized,
CancellationToken cancellationToken = default)
{
return UpdateAsync(settings =>
{
settings.WindowX = x;
settings.WindowY = y;
settings.WindowWidth = Math.Max(640, width);
settings.WindowHeight = Math.Max(420, height);
settings.WindowMaximized = maximized;
}, cancellationToken);
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
@@ -0,0 +1,566 @@
using System.Text.Json;
namespace YMhut.Box.Core.Settings;
public sealed class AppSettingsStore
{
private static readonly string LegacyPreferencePrefix = string.Concat("flut", "ter.");
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly string _settingsPath;
public AppSettingsStore(string? root = null)
{
var appData = root ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"YMhut Box",
"WinUI");
Directory.CreateDirectory(appData);
_settingsPath = Path.Combine(appData, "settings.json");
}
public string SettingsPath => _settingsPath;
public async Task<AppSettings> LoadAsync(CancellationToken cancellationToken = default)
{
if (!File.Exists(_settingsPath))
{
var legacy = await TryImportLegacySettingsAsync(cancellationToken);
if (legacy is not null)
{
await TryImportLegacyDatabaseSnapshotAsync(legacy, cancellationToken);
return legacy;
}
var fresh = new AppSettings { LegacyImportCompleted = true };
await TryImportLegacyDatabaseSnapshotAsync(fresh, cancellationToken);
await SaveAsync(fresh, cancellationToken);
return fresh;
}
AppSettings settings;
await using (var stream = File.OpenRead(_settingsPath))
{
settings = await JsonSerializer.DeserializeAsync<AppSettings>(stream, JsonOptions, cancellationToken)
?? new AppSettings();
}
if (!settings.LegacyImportCompleted)
{
settings.LegacyImportCompleted = true;
}
settings.HomePageMode = NormalizeHomePageMode(settings.HomePageMode);
settings.Language = LanguagePreference.Normalize(settings.Language);
settings.ToolDisplayMode = NormalizeToolDisplayMode(settings.ToolDisplayMode);
settings.ToolboxDefaultScope = NormalizeToolboxScope(settings.ToolboxDefaultScope);
settings.WindowBackdrop = NormalizeWindowBackdrop(settings.WindowBackdrop);
settings.SettingsPanelMaterial = NormalizeSettingsPanelMaterial(settings.SettingsPanelMaterial);
settings.TopBarMaterial = NormalizeTopBarMaterial(settings.TopBarMaterial);
settings.FeedbackDefaultType = NormalizeFeedbackType(settings.FeedbackDefaultType);
settings.FeedbackDefaultSeverity = NormalizeFeedbackSeverity(settings.FeedbackDefaultSeverity);
if (!settings.LegacyDatabaseImportCompleted)
{
await TryImportLegacyDatabaseSnapshotAsync(settings, cancellationToken);
}
await SaveAsync(settings, cancellationToken);
return settings;
}
public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
{
settings.Language = LanguagePreference.Normalize(settings.Language);
Directory.CreateDirectory(Path.GetDirectoryName(_settingsPath)!);
await using var stream = File.Create(_settingsPath);
await JsonSerializer.SerializeAsync(stream, settings, JsonOptions, cancellationToken);
}
public async Task RecordRecentToolAsync(string toolId, CancellationToken cancellationToken = default)
{
var settings = await LoadAsync(cancellationToken);
settings.RecentToolIds.RemoveAll(id => string.Equals(id, toolId, StringComparison.OrdinalIgnoreCase));
settings.RecentToolIds.Insert(0, toolId);
if (settings.RecentToolIds.Count > 12)
{
settings.RecentToolIds.RemoveRange(12, settings.RecentToolIds.Count - 12);
}
await SaveAsync(settings, cancellationToken);
}
private async Task<AppSettings?> TryImportLegacySettingsAsync(CancellationToken cancellationToken)
{
var settingsRoot = Path.GetDirectoryName(_settingsPath);
if (string.IsNullOrWhiteSpace(settingsRoot))
{
return null;
}
foreach (var legacyPath in ResolveLegacySettingsCandidates(settingsRoot))
{
if (!File.Exists(legacyPath))
{
continue;
}
try
{
await using var stream = File.OpenRead(legacyPath);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
var legacy = ParseLegacySettings(document.RootElement);
if (legacy is null)
{
continue;
}
legacy.LegacyImportCompleted = true;
await SaveAsync(legacy, cancellationToken);
return legacy;
}
catch (JsonException)
{
continue;
}
}
return null;
}
private static IEnumerable<string> ResolveLegacySettingsCandidates(string settingsRoot)
{
var legacyBase = Path.GetFullPath(Path.Combine(settingsRoot, ".."));
foreach (var basePath in new[]
{
legacyBase,
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)
}.Where(path => !string.IsNullOrWhiteSpace(path)).Distinct(StringComparer.OrdinalIgnoreCase))
{
foreach (var appFolder in new[] { "YMhut Box", "ymhut_box" })
{
yield return Path.Combine(basePath, appFolder, "settings.json");
yield return Path.Combine(basePath, appFolder, "shared_preferences.json");
}
}
}
private static AppSettings? ParseLegacySettings(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
{
return null;
}
var found = false;
var settings = new AppSettings();
if (TryGetString(root, out var theme, "theme"))
{
settings.Theme = NormalizeTheme(theme);
found = true;
}
AssignString(root, value => settings.Language = LanguagePreference.Normalize(value), ref found, "language", "locale", "ui_language", "uiLanguage", "app_language", "appLanguage");
AssignBool(root, value => settings.PitchBlack = value, ref found, "pitch_black", "pitchBlack");
AssignInt(root, value => settings.SeedColor = value, ref found, "seed_color", "seedColor");
AssignDouble(root, value => settings.CardOpacity = value, ref found, "card_opacity", "cardOpacity");
AssignString(root, value => settings.WindowBackdrop = NormalizeWindowBackdrop(value), ref found, "window_backdrop", "windowBackdrop");
AssignString(root, value => settings.SettingsPanelMaterial = NormalizeSettingsPanelMaterial(value), ref found, "settings_panel_material", "settingsPanelMaterial");
AssignString(root, value => settings.TopBarMaterial = NormalizeTopBarMaterial(value), ref found, "top_bar_material", "topBarMaterial", "title_bar_material", "titleBarMaterial");
AssignString(root, value => settings.BackgroundImage = value, ref found, "background_image", "backgroundImage");
AssignDouble(root, value => settings.BackgroundOpacity = value, ref found, "background_opacity", "backgroundOpacity");
AssignBool(root, value => settings.UserAgreementAccepted = value, ref found, "user_agreement_accepted", "userAgreementAccepted");
AssignString(root, value => settings.UserAgreementVersion = value, ref found, "user_agreement_version", "userAgreementVersion");
AssignDateTimeOffset(root, value => settings.UserAgreementAcceptedAt = value, ref found, "user_agreement_accepted_at", "userAgreementAcceptedAt");
AssignBool(root, value => settings.SidebarCollapsed = value, ref found, "sidebar_collapsed", "sidebarCollapsed");
AssignBool(root, value => settings.DesktopTitlebar = value, ref found, "desktop_titlebar", "desktopTitlebar");
AssignStringList(root, value => settings.RecentToolIds = value, ref found, "recent_tool_ids", "recentToolIds");
AssignStringList(root, value => settings.PinnedToolIds = value, ref found, "pinned_tool_ids", "pinnedToolIds");
AssignNullableDouble(root, value => settings.WindowX = value, ref found, "window_x", "windowX");
AssignNullableDouble(root, value => settings.WindowY = value, ref found, "window_y", "windowY");
AssignNullableDouble(root, value => settings.WindowWidth = value, ref found, "window_width", "windowWidth");
AssignNullableDouble(root, value => settings.WindowHeight = value, ref found, "window_height", "windowHeight");
AssignBool(root, value => settings.WindowMaximized = value, ref found, "window_maximized", "windowMaximized");
AssignBool(root, value => settings.ProxyEnabled = value, ref found, "proxy_enabled", "proxyEnabled");
AssignString(root, value => settings.ProxyMode = value, ref found, "proxy_mode", "proxyMode");
AssignString(root, value => settings.ProxyHost = value, ref found, "proxy_host", "proxyHost");
AssignInt(root, value => settings.ProxyPort = value, ref found, "proxy_port", "proxyPort");
AssignBool(root, value => settings.AnimationsEnabled = value, ref found, "animations_enabled", "animationsEnabled");
AssignDouble(root, value => settings.FontSize = value, ref found, "font_size", "fontSize");
AssignString(root, value => settings.ToolDisplayMode = NormalizeToolDisplayMode(value), ref found, "tool_display_mode", "toolDisplayMode");
AssignString(root, value => settings.ToolboxDefaultScope = NormalizeToolboxScope(value), ref found, "toolbox_default_scope", "toolboxDefaultScope");
AssignBool(root, value => settings.ToolboxCompactCards = value, ref found, "toolbox_compact_cards", "toolboxCompactCards");
AssignBool(root, value => settings.ToolboxShowRecentFirst = value, ref found, "toolbox_show_recent_first", "toolboxShowRecentFirst");
AssignBool(root, value => settings.ShowHardwareBrandLogo = value, ref found, "show_hardware_brand_logo", "showHardwareBrandLogo", "brand_logo", "brandLogo");
AssignBool(root, value => settings.RequireRiskConfirmation = value, ref found, "require_risk_confirmation", "requireRiskConfirmation");
AssignString(root, value => settings.HomePageMode = NormalizeHomePageMode(value), ref found, "home_page_mode", "homePageMode");
AssignString(root, value => settings.CloseBehavior = value, ref found, "close_behavior", "closeBehavior");
AssignBool(root, value => settings.RestoreWindowPosition = value, ref found, "restore_window_position", "restoreWindowPosition");
AssignBool(root, value => settings.AutoStart = value, ref found, "auto_start", "autoStart");
AssignInt(root, value => settings.LogRetentionCount = value, ref found, "log_retention_count", "logRetentionCount");
AssignInt(root, value => settings.DataRefreshInterval = value, ref found, "data_refresh_interval", "dataRefreshInterval");
AssignBool(root, value => settings.UpdateNotification = value, ref found, "update_notification", "updateNotification");
AssignBool(root, value => settings.HardwareAccelerationEnabled = value, ref found, "hardware_acceleration_enabled", "hardwareAccelerationEnabled");
AssignInt(root, value => settings.ProxyTestTimeoutSeconds = value, ref found, "proxy_test_timeout_seconds", "proxyTestTimeoutSeconds");
AssignBool(root, value => settings.PluginsEnabled = value, ref found, "plugins_enabled", "pluginsEnabled");
AssignString(root, value => settings.PluginRootPath = value, ref found, "plugin_root_path", "pluginRootPath");
AssignString(root, value => settings.FeedbackDefaultContact = value, ref found, "feedback_default_contact", "feedbackDefaultContact");
AssignString(root, value => settings.FeedbackDefaultType = NormalizeFeedbackType(value), ref found, "feedback_default_type", "feedbackDefaultType");
AssignString(root, value => settings.FeedbackDefaultSeverity = NormalizeFeedbackSeverity(value), ref found, "feedback_default_severity", "feedbackDefaultSeverity");
AssignBool(root, value => settings.FeedbackIncludeTodayLogsByDefault = value, ref found, "feedback_include_today_logs_by_default", "feedbackIncludeTodayLogsByDefault");
AssignBool(root, value => settings.FeedbackIncludeToolStatusByDefault = value, ref found, "feedback_include_tool_status_by_default", "feedbackIncludeToolStatusByDefault");
AssignBool(root, value => settings.FeedbackIncludeSystemSummaryByDefault = value, ref found, "feedback_include_system_summary_by_default", "feedbackIncludeSystemSummaryByDefault");
AssignBool(root, value => settings.FeedbackRememberDefaults = value, ref found, "feedback_remember_defaults", "feedbackRememberDefaults");
if (!TryGetString(root, out _, "close_behavior", "closeBehavior") &&
TryGetBool(root, out var minimizeToTray, "minimize_to_tray", "minimizeToTray"))
{
settings.CloseBehavior = minimizeToTray ? "minimize_then_exit" : "exit_directly";
found = true;
}
settings.LegacyImportCompleted = true;
return found ? settings : null;
}
private static string NormalizeTheme(string theme)
{
return theme.Trim().ToLowerInvariant() switch
{
"dark" => "Dark",
"system" => "System",
_ => "Light"
};
}
private static string NormalizeHomePageMode(string mode)
{
return (mode ?? string.Empty).Trim().ToLowerInvariant() switch
{
"solar" or "planets" or "planet" => "solar",
"dashboard" or "overview" or "tools" => "dashboard",
_ => "dashboard"
};
}
private static string NormalizeToolDisplayMode(string mode)
{
return (mode ?? string.Empty).Trim().ToLowerInvariant() switch
{
"list" or "compact" => "list",
_ => "grid"
};
}
private static string NormalizeToolboxScope(string scope)
{
return (scope ?? string.Empty).Trim().ToLowerInvariant() switch
{
"favorite" or "favorites" or "pinned" => "favorites",
"recent" or "history" => "recent",
"external" or "tools" => "external",
"builtin" or "built-in" or "reference" => "builtin",
"plugin" or "plugins" => "plugin",
"risk" or "risky" => "risk",
_ => "all"
};
}
private static string NormalizeWindowBackdrop(string value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"none" or "solid" => "solid",
"micaalt" or "mica_alt" or "mica-alt" => "micaAlt",
"acrylic" or "desktopacrylic" or "desktop_acrylic" => "acrylic",
_ => "mica"
};
}
private static string NormalizeSettingsPanelMaterial(string value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"acrylic" => "acrylic",
"glass" or "frosted" or "frosted_glass" => "glass",
_ => "solid"
};
}
private static string NormalizeTopBarMaterial(string value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"solid" or "none" => "solid",
"mica" => "mica",
"acrylic" => "acrylic",
"glass" or "frosted" or "frosted_glass" => "glass",
_ => "inherit"
};
}
private static string NormalizeFeedbackType(string value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"suggestion" => "suggestion",
"ui" => "ui",
"other" => "other",
_ => "issue"
};
}
private static string NormalizeFeedbackSeverity(string value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"major" => "major",
"blocking" => "blocking",
_ => "normal"
};
}
private static void AssignString(JsonElement root, Action<string> assign, ref bool found, params string[] aliases)
{
if (TryGetString(root, out var value, aliases))
{
assign(value);
found = true;
}
}
private static void AssignBool(JsonElement root, Action<bool> assign, ref bool found, params string[] aliases)
{
if (TryGetBool(root, out var value, aliases))
{
assign(value);
found = true;
}
}
private static void AssignInt(JsonElement root, Action<int> assign, ref bool found, params string[] aliases)
{
if (TryGetInt(root, out var value, aliases))
{
assign(value);
found = true;
}
}
private static void AssignDouble(JsonElement root, Action<double> assign, ref bool found, params string[] aliases)
{
if (TryGetDouble(root, out var value, aliases))
{
assign(value);
found = true;
}
}
private static void AssignNullableDouble(JsonElement root, Action<double?> assign, ref bool found, params string[] aliases)
{
if (TryGetDouble(root, out var value, aliases))
{
assign(value);
found = true;
}
}
private static void AssignStringList(JsonElement root, Action<List<string>> assign, ref bool found, params string[] aliases)
{
if (TryGetStringList(root, out var value, aliases))
{
assign(value);
found = true;
}
}
private static void AssignDateTimeOffset(JsonElement root, Action<DateTimeOffset?> assign, ref bool found, params string[] aliases)
{
if (TryGetString(root, out var value, aliases) &&
DateTimeOffset.TryParse(value, out var parsed))
{
assign(parsed);
found = true;
}
}
private static bool TryGetString(JsonElement root, out string value, params string[] aliases)
{
if (TryGet(root, out var element, aliases))
{
if (element.ValueKind == JsonValueKind.String)
{
value = element.GetString() ?? string.Empty;
return true;
}
if (element.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False)
{
value = element.ToString();
return true;
}
}
value = string.Empty;
return false;
}
private static bool TryGetBool(JsonElement root, out bool value, params string[] aliases)
{
if (TryGet(root, out var element, aliases))
{
if (element.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
value = element.GetBoolean();
return true;
}
if (element.ValueKind == JsonValueKind.String && bool.TryParse(element.GetString(), out var parsed))
{
value = parsed;
return true;
}
}
value = false;
return false;
}
private static bool TryGetInt(JsonElement root, out int value, params string[] aliases)
{
if (TryGet(root, out var element, aliases))
{
if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out value))
{
return true;
}
if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value))
{
return true;
}
}
value = 0;
return false;
}
private static bool TryGetDouble(JsonElement root, out double value, params string[] aliases)
{
if (TryGet(root, out var element, aliases))
{
if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out value))
{
return true;
}
if (element.ValueKind == JsonValueKind.String && double.TryParse(element.GetString(), out value))
{
return true;
}
}
value = 0;
return false;
}
private static bool TryGetStringList(JsonElement root, out List<string> value, params string[] aliases)
{
if (TryGet(root, out var element, aliases))
{
if (element.ValueKind == JsonValueKind.Array)
{
value = element.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString() ?? string.Empty)
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return true;
}
if (element.ValueKind == JsonValueKind.String)
{
value = element.GetString()?
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList() ?? [];
return true;
}
}
value = [];
return false;
}
private static bool TryGet(JsonElement root, out JsonElement value, params string[] aliases)
{
foreach (var alias in aliases)
{
foreach (var key in new[] { alias, LegacyPreferencePrefix + alias })
{
if (root.TryGetProperty(key, out value))
{
return true;
}
}
}
value = default;
return false;
}
private async Task TryImportLegacyDatabaseSnapshotAsync(AppSettings settings, CancellationToken cancellationToken)
{
if (settings.LegacyDatabaseImportCompleted)
{
return;
}
var settingsRoot = Path.GetDirectoryName(_settingsPath);
if (string.IsNullOrWhiteSpace(settingsRoot))
{
return;
}
var snapshotPath = Path.Combine(settingsRoot, "legacy_app_state.db");
foreach (var legacyPath in ResolveLegacyDatabaseCandidates(settingsRoot))
{
if (!File.Exists(legacyPath))
{
continue;
}
Directory.CreateDirectory(settingsRoot);
await using var source = File.Open(legacyPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await using var target = File.Create(snapshotPath);
await source.CopyToAsync(target, cancellationToken);
settings.LegacyDatabaseSnapshotPath = snapshotPath;
settings.LegacyDatabaseImportCompleted = true;
return;
}
settings.LegacyDatabaseImportCompleted = true;
}
private static IEnumerable<string> ResolveLegacyDatabaseCandidates(string settingsRoot)
{
var legacyBase = Path.GetFullPath(Path.Combine(settingsRoot, ".."));
yield return Path.Combine(legacyBase, "YMhut Box", "app_state.db");
yield return Path.Combine(legacyBase, "ymhut_box", "app_state.db");
yield return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ymhut_box",
"app_state.db");
yield return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"YMhut Box",
"app_state.db");
}
}
@@ -0,0 +1,26 @@
using System.ComponentModel;
namespace YMhut.Box.Core.Settings;
public interface ISettingsService : INotifyPropertyChanged
{
AppSettings Current { get; }
string SettingsPath { get; }
Task<AppSettings> LoadAsync(CancellationToken cancellationToken = default);
Task SaveAsync(CancellationToken cancellationToken = default);
Task UpdateAsync(Action<AppSettings> update, CancellationToken cancellationToken = default);
Task RecordRecentToolAsync(string toolId, CancellationToken cancellationToken = default);
Task SaveWindowBoundsAsync(
double x,
double y,
double width,
double height,
bool maximized,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,40 @@
namespace YMhut.Box.Core.Settings;
public static class LanguagePreference
{
public const string Chinese = "zh-CN";
public const string English = "en-US";
public static string Normalize(string? language)
{
var value = (language ?? string.Empty).Trim();
if (value.Length == 0)
{
return Chinese;
}
var normalized = value
.Replace('_', '-')
.Trim()
.ToLowerInvariant();
if (normalized is "en" or "english" ||
normalized.StartsWith("en-", StringComparison.Ordinal))
{
return English;
}
if (normalized is "zh" or "cn" or "chinese" or "simplified-chinese" or "zh-hans" ||
normalized.StartsWith("zh-", StringComparison.Ordinal) ||
normalized.Contains("中文", StringComparison.Ordinal) ||
normalized.Contains("简体", StringComparison.Ordinal))
{
return Chinese;
}
return Chinese;
}
public static bool IsEnglish(string? language)
=> string.Equals(Normalize(language), English, StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,42 @@
namespace YMhut.Box.Core.Settings;
internal static class PinnedToolSettings
{
public const int MaxPinnedTools = 64;
public static void Toggle(IList<string> pinnedToolIds, string toolId, bool wasPinned)
{
if (string.IsNullOrWhiteSpace(toolId))
{
return;
}
for (var index = pinnedToolIds.Count - 1; index >= 0; index--)
{
if (string.Equals(pinnedToolIds[index], toolId, StringComparison.OrdinalIgnoreCase))
{
pinnedToolIds.RemoveAt(index);
}
}
if (!wasPinned)
{
pinnedToolIds.Insert(0, toolId);
}
Trim(pinnedToolIds);
}
public static void Trim(IList<string> pinnedToolIds)
{
if (pinnedToolIds.Count <= MaxPinnedTools)
{
return;
}
for (var index = pinnedToolIds.Count - 1; index >= MaxPinnedTools; index--)
{
pinnedToolIds.RemoveAt(index);
}
}
}
@@ -0,0 +1,23 @@
namespace YMhut.Box.Core.SolarSystem;
public sealed record SolarEphemerisPayload(
string Type,
string Source,
DateTimeOffset EpochUtc,
IReadOnlyList<SolarEphemerisPlanet> Planets);
public sealed record SolarEphemerisPlanet(
string Id,
HeliocentricAu HeliocentricAu);
public sealed record HeliocentricAu(
double X,
double Y,
double Z);
public interface ISolarEphemerisService
{
Task<SolarEphemerisPayload> GetCurrentAsync(CancellationToken cancellationToken = default);
Task<SolarEphemerisPayload?> GetPreciseCurrentAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,569 @@
using System.Globalization;
using System.Text.Json;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Net;
namespace YMhut.Box.Core.SolarSystem;
public sealed partial class SolarEphemerisService(
AppPaths paths,
IHttpService httpService,
ILogService? logService = null) : ISolarEphemerisService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private static readonly string[] PlanetIds =
[
"mercury",
"venus",
"earth",
"mars",
"jupiter",
"saturn",
"uranus",
"neptune"
];
private static readonly Dictionary<string, string> HorizonsIds = new(StringComparer.OrdinalIgnoreCase)
{
["mercury"] = "199",
["venus"] = "299",
["earth"] = "399",
["mars"] = "499",
["jupiter"] = "599",
["saturn"] = "699",
["uranus"] = "799",
["neptune"] = "899"
};
private static readonly Dictionary<string, string> MiriadeNames = new(StringComparer.OrdinalIgnoreCase)
{
["mercury"] = "Mercury",
["venus"] = "Venus",
["earth"] = "Earth",
["mars"] = "Mars",
["jupiter"] = "Jupiter",
["saturn"] = "Saturn",
["uranus"] = "Uranus",
["neptune"] = "Neptune"
};
public async Task<SolarEphemerisPayload> GetCurrentAsync(CancellationToken cancellationToken = default)
{
var epoch = DateTimeOffset.UtcNow;
if (TryReadCache(epoch, out var cached, preciseOnly: false))
{
return cached;
}
_ = cancellationToken;
return CreateChinaApproximatePayload(epoch);
}
public static bool TryParseMiriadePayload(string json, out HeliocentricAu vector)
{
return TryParseMiriadeVector(json, out vector);
}
public async Task<SolarEphemerisPayload?> GetPreciseCurrentAsync(CancellationToken cancellationToken = default)
{
var epoch = DateTimeOffset.UtcNow;
if (TryReadCache(epoch, out var cached, preciseOnly: true))
{
return cached;
}
SolarEphemerisPayload? payload = null;
try
{
payload = await FetchChinaRemoteAsync(epoch, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
await WriteLogAsync("Warning", "solar", "China ephemeris sync failed", exception.Message, cancellationToken).ConfigureAwait(false);
}
if (payload is null)
{
try
{
payload = await FetchMiriadeAsync(epoch, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
await WriteLogAsync("Warning", "solar", "IMCCE Miriade sync failed", exception.Message, cancellationToken).ConfigureAwait(false);
}
}
if (payload is null)
{
try
{
payload = await FetchHorizonsAsync(epoch, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
await WriteLogAsync("Warning", "solar", "JPL Horizons sync failed", exception.Message, cancellationToken).ConfigureAwait(false);
}
}
if (payload is not null)
{
WriteCache(payload);
}
return payload;
}
private static Task<SolarEphemerisPayload?> FetchChinaRemoteAsync(DateTimeOffset epoch, CancellationToken cancellationToken)
{
_ = epoch;
_ = cancellationToken;
return Task.FromResult<SolarEphemerisPayload?>(null);
}
private async Task<SolarEphemerisPayload?> FetchHorizonsAsync(DateTimeOffset epoch, CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
var tasks = PlanetIds.Select(id => FetchHorizonsPlanetAsync(id, epoch, timeout.Token)).ToArray();
var planets = await Task.WhenAll(tasks).ConfigureAwait(false);
return planets.All(planet => planet is not null)
? new SolarEphemerisPayload("ephemeris:sync", "JPL Horizons", epoch, planets!)
: null;
}
private async Task<SolarEphemerisPayload?> FetchMiriadeAsync(DateTimeOffset epoch, CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(8));
var tasks = PlanetIds.Select(id => FetchMiriadePlanetAsync(id, epoch, timeout.Token)).ToArray();
var planets = await Task.WhenAll(tasks).ConfigureAwait(false);
return planets.All(planet => planet is not null)
? new SolarEphemerisPayload("ephemeris:sync", "IMCCE Miriade", epoch, planets!)
: null;
}
private async Task<SolarEphemerisPlanet?> FetchHorizonsPlanetAsync(string id, DateTimeOffset epoch, CancellationToken cancellationToken)
{
var command = Uri.EscapeDataString($"'{HorizonsIds[id]}'");
var start = Uri.EscapeDataString(epoch.UtcDateTime.ToString("yyyy-MMM-dd HH:mm", CultureInfo.InvariantCulture));
var stop = Uri.EscapeDataString(epoch.UtcDateTime.AddHours(1).ToString("yyyy-MMM-dd HH:mm", CultureInfo.InvariantCulture));
var uri = new Uri($"https://ssd.jpl.nasa.gov/api/horizons.api?format=json&COMMAND={command}&OBJ_DATA=NO&MAKE_EPHEM=YES&EPHEM_TYPE=VECTORS&CENTER=@sun&START_TIME={start}&STOP_TIME={stop}&STEP_SIZE=1h&OUT_UNITS=AU-D");
var response = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
return TryParseHorizonsVector(response, out var vector)
? new SolarEphemerisPlanet(id, vector)
: null;
}
private async Task<SolarEphemerisPlanet?> FetchMiriadePlanetAsync(string id, DateTimeOffset epoch, CancellationToken cancellationToken)
{
var julianDate = ToJulianDate(epoch.UtcDateTime).ToString("F6", CultureInfo.InvariantCulture);
var name = Uri.EscapeDataString($"p:{MiriadeNames[id]}");
var uri = new Uri($"https://ssp.imcce.fr/webservices/miriade/api/ephemcc.php?-name={name}&-type=Planet&-ep={julianDate}&-nbd=1&-step=1d&-observer=@sun&-tcoor=2&-rplane=2&-mime=json");
var response = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
return TryParseMiriadeVector(response, out var vector)
? new SolarEphemerisPlanet(id, vector)
: null;
}
private static bool TryParseHorizonsVector(string json, out HeliocentricAu vector)
{
vector = new HeliocentricAu(0, 0, 0);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("result", out var result) ||
result.ValueKind != JsonValueKind.String)
{
return false;
}
var text = result.GetString() ?? string.Empty;
var start = text.IndexOf("$$SOE", StringComparison.Ordinal);
var end = text.IndexOf("$$EOE", StringComparison.Ordinal);
if (start < 0 || end <= start)
{
return false;
}
var block = text[(start + 5)..end];
var x = ExtractHorizonsNumber(block, "X");
var y = ExtractHorizonsNumber(block, "Y");
var z = ExtractHorizonsNumber(block, "Z");
if (x is null || y is null || z is null)
{
return false;
}
vector = new HeliocentricAu(x.Value, y.Value, z.Value);
return true;
}
private static double? ExtractHorizonsNumber(string text, string key)
{
var index = text.IndexOf(key + " =", StringComparison.Ordinal);
if (index < 0)
{
index = text.IndexOf(key + "=", StringComparison.Ordinal);
}
if (index < 0)
{
return null;
}
var equals = text.IndexOf('=', index);
if (equals < 0)
{
return null;
}
var span = text[(equals + 1)..];
var token = span.Split([' ', '\r', '\n', '\t'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
return double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : null;
}
private static bool TryParseMiriadeVector(string json, out HeliocentricAu vector)
{
vector = new HeliocentricAu(0, 0, 0);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Array &&
root.GetArrayLength() > 0 &&
TryGetVectorFromObject(root[0], out vector))
{
return true;
}
if (root.ValueKind == JsonValueKind.Object)
{
if (TryGetVectorFromObject(root, out vector))
{
return true;
}
if (root.TryGetProperty("data", out var data) &&
data.ValueKind == JsonValueKind.Array &&
data.GetArrayLength() > 0 &&
(TryGetVectorFromObject(data[0], out vector) || TryGetSphericalVector(data[0], out vector)))
{
return true;
}
foreach (var property in root.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.Array && property.Value.GetArrayLength() > 0 &&
(TryGetVectorFromObject(property.Value[0], out vector) || TryGetSphericalVector(property.Value[0], out vector)))
{
return true;
}
}
}
return false;
}
private static bool TryGetVectorFromObject(JsonElement element, out HeliocentricAu vector)
{
vector = new HeliocentricAu(0, 0, 0);
if (element.ValueKind != JsonValueKind.Object)
{
return false;
}
var x = TryGetDouble(element, "x", "X", "px", "PX");
var y = TryGetDouble(element, "y", "Y", "py", "PY");
var z = TryGetDouble(element, "z", "Z", "pz", "PZ");
if (x is null || y is null || z is null)
{
return false;
}
vector = new HeliocentricAu(x.Value, y.Value, z.Value);
return true;
}
private static double? TryGetDouble(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (!element.TryGetProperty(name, out var value))
{
continue;
}
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number))
{
return number;
}
if (value.ValueKind == JsonValueKind.String &&
double.TryParse(value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out number))
{
return number;
}
}
return null;
}
private static bool TryGetSphericalVector(JsonElement element, out HeliocentricAu vector)
{
vector = new HeliocentricAu(0, 0, 0);
if (element.ValueKind != JsonValueKind.Object)
{
return false;
}
if (!element.TryGetProperty("RA", out var raElement) ||
!element.TryGetProperty("DEC", out var decElement))
{
return false;
}
var distance = TryGetDouble(element, "Dobs", "delta", "distance");
if (distance is null ||
!TryParseRightAscension(raElement.GetString(), out var raRadians) ||
!TryParseDeclination(decElement.GetString(), out var decRadians))
{
return false;
}
var cosDec = Math.Cos(decRadians);
vector = new HeliocentricAu(
distance.Value * cosDec * Math.Cos(raRadians),
distance.Value * cosDec * Math.Sin(raRadians),
distance.Value * Math.Sin(decRadians));
return true;
}
private static bool TryParseRightAscension(string? value, out double radians)
{
radians = 0;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var parts = value.Trim().Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 3 &&
double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var hours) &&
double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var minutes) &&
double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds))
{
var sign = hours < 0 ? -1 : 1;
var totalHours = sign * (Math.Abs(hours) + minutes / 60d + seconds / 3600d);
radians = ToRadians(totalHours * 15d);
return true;
}
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var decimalHours))
{
radians = ToRadians(decimalHours * 15d);
return true;
}
return false;
}
private static bool TryParseDeclination(string? value, out double radians)
{
radians = 0;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
var sign = trimmed.StartsWith("-", StringComparison.Ordinal) ? -1d : 1d;
trimmed = trimmed.TrimStart('+', '-');
var parts = trimmed.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 3 &&
double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var degrees) &&
double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var minutes) &&
double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds))
{
radians = ToRadians(sign * (Math.Abs(degrees) + minutes / 60d + seconds / 3600d));
return true;
}
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var decimalDegrees))
{
radians = ToRadians(decimalDegrees);
return true;
}
return false;
}
private SolarEphemerisPayload CreateLocalPayload(DateTimeOffset epoch)
{
var planets = PlanetIds
.Select(id => new SolarEphemerisPlanet(id, ApproximatePosition(id, epoch)))
.ToArray();
return new SolarEphemerisPayload("ephemeris:sync", "local", epoch, planets);
}
private SolarEphemerisPayload CreateChinaApproximatePayload(DateTimeOffset epoch)
{
var planets = PlanetIds
.Select(id => new SolarEphemerisPlanet(id, ApproximatePosition(id, epoch)))
.ToArray();
return new SolarEphemerisPayload("ephemeris:sync", "China Ephemeris Approx", epoch, planets);
}
private static HeliocentricAu ApproximatePosition(string id, DateTimeOffset epoch)
{
var element = ApproximateElements[id];
var jd = ToJulianDate(epoch.UtcDateTime);
var t = (jd - 2451545.0) / 36525.0;
var a = element.A + element.ARate * t;
var e = element.E + element.ERate * t;
var inclination = ToRadians(NormalizeDegrees(element.I + element.IRate * t));
var meanLongitude = NormalizeDegrees(element.L + element.LRate * t);
var perihelion = NormalizeDegrees(element.Perihelion + element.PerihelionRate * t);
var node = ToRadians(NormalizeDegrees(element.Node + element.NodeRate * t));
var argumentOfPerihelion = ToRadians(perihelion) - node;
var meanAnomaly = ToRadians(NormalizeDegrees(meanLongitude - perihelion));
var eccentricAnomaly = SolveKepler(meanAnomaly, e);
var xv = a * (Math.Cos(eccentricAnomaly) - e);
var yv = a * Math.Sqrt(1 - e * e) * Math.Sin(eccentricAnomaly);
var trueAnomaly = Math.Atan2(yv, xv);
var radius = Math.Sqrt(xv * xv + yv * yv);
var angle = argumentOfPerihelion + trueAnomaly;
return new HeliocentricAu(
radius * (Math.Cos(node) * Math.Cos(angle) - Math.Sin(node) * Math.Sin(angle) * Math.Cos(inclination)),
radius * (Math.Sin(node) * Math.Cos(angle) + Math.Cos(node) * Math.Sin(angle) * Math.Cos(inclination)),
radius * (Math.Sin(angle) * Math.Sin(inclination)));
}
private bool TryReadCache(DateTimeOffset epoch, out SolarEphemerisPayload payload, bool preciseOnly)
{
payload = null!;
var path = CachePath();
if (!File.Exists(path))
{
return false;
}
try
{
var text = File.ReadAllText(path);
var cached = JsonSerializer.Deserialize<SolarEphemerisPayload>(text, JsonOptions);
if (cached is null || cached.Planets.Count == 0)
{
return false;
}
if (preciseOnly && IsLocalApproximateSource(cached.Source))
{
return false;
}
if (epoch - cached.EpochUtc > TimeSpan.FromHours(6))
{
return false;
}
payload = cached;
return true;
}
catch
{
return false;
}
}
private static bool IsLocalApproximateSource(string source)
{
return string.Equals(source, "local", StringComparison.OrdinalIgnoreCase) ||
source.Contains("Approx", StringComparison.OrdinalIgnoreCase);
}
private void WriteCache(SolarEphemerisPayload payload)
{
try
{
var path = CachePath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, JsonSerializer.Serialize(payload, JsonOptions));
}
catch
{
}
}
private string CachePath()
{
return Path.Combine(paths.Cache, "SolarEphemeris", "current.json");
}
private Task WriteLogAsync(string level, string category, string message, string detail, CancellationToken cancellationToken)
{
return logService?.WriteAsync(level, category, message, detail, cancellationToken) ?? Task.CompletedTask;
}
private static double ToJulianDate(DateTime utc)
{
return new DateTimeOffset(DateTime.SpecifyKind(utc, DateTimeKind.Utc)).ToUnixTimeMilliseconds() / 86400000d + 2440587.5d;
}
private static double SolveKepler(double meanAnomaly, double eccentricity)
{
var eccentricAnomaly = eccentricity < 0.8 ? meanAnomaly : Math.PI;
for (var index = 0; index < 12; index++)
{
var delta = (eccentricAnomaly - eccentricity * Math.Sin(eccentricAnomaly) - meanAnomaly) /
(1 - eccentricity * Math.Cos(eccentricAnomaly));
eccentricAnomaly -= delta;
if (Math.Abs(delta) < 1e-10)
{
break;
}
}
return eccentricAnomaly;
}
private static double NormalizeDegrees(double value)
{
var normalized = value % 360;
return normalized < 0 ? normalized + 360 : normalized;
}
private static double ToRadians(double degrees) => degrees * Math.PI / 180d;
private sealed record Elements(
double A,
double ARate,
double E,
double ERate,
double I,
double IRate,
double L,
double LRate,
double Perihelion,
double PerihelionRate,
double Node,
double NodeRate);
private static readonly Dictionary<string, Elements> ApproximateElements = new(StringComparer.OrdinalIgnoreCase)
{
["mercury"] = new(0.38709927, 0.00000037, 0.20563593, 0.00001906, 7.00497902, -0.00594749, 252.25032350, 149472.67411175, 77.45779628, 0.16047689, 48.33076593, -0.12534081),
["venus"] = new(0.72333566, 0.00000390, 0.00677672, -0.00004107, 3.39467605, -0.00078890, 181.97909950, 58517.81538729, 131.60246718, 0.00268329, 76.67984255, -0.27769418),
["earth"] = new(1.00000261, 0.00000562, 0.01671123, -0.00004392, -0.00001531, -0.01294668, 100.46457166, 35999.37244981, 102.93768193, 0.32327364, 0, 0),
["mars"] = new(1.52371034, 0.00001847, 0.09339410, 0.00007882, 1.84969142, -0.00813131, -4.55343205, 19140.30268499, -23.94362959, 0.44441088, 49.55953891, -0.29257343),
["jupiter"] = new(5.20288700, -0.00011607, 0.04838624, -0.00013253, 1.30439695, -0.00183714, 34.39644051, 3034.74612775, 14.72847983, 0.21252668, 100.47390909, 0.20469106),
["saturn"] = new(9.53667594, -0.00125060, 0.05386179, -0.00050991, 2.48599187, 0.00193609, 49.95424423, 1222.49362201, 92.59887831, -0.41897216, 113.66242448, -0.28867794),
["uranus"] = new(19.18916464, -0.00196176, 0.04725744, -0.00004397, 0.77263783, -0.00242939, 313.23810451, 428.48202785, 170.95427630, 0.40805281, 74.01692503, 0.04240589),
["neptune"] = new(30.06992276, 0.00026291, 0.00859048, 0.00005105, 1.77004347, 0.00035372, -55.12002969, 218.45945325, 44.96476227, -0.32241464, 131.78422574, -0.00508664)
};
}
@@ -0,0 +1,830 @@
using System.Globalization;
using System.Text.Json;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.Core.Startup;
public interface IInstallIntegrityCheckService
{
Task<StartupCheckReport> RunFastPreflightAsync(CancellationToken cancellationToken = default);
Task<StartupCheckReport> RunMonthlyIntegrityCheckAsync(CancellationToken cancellationToken = default);
Task<StartupCheckReport> RunManualIntegrityCheckAsync(CancellationToken cancellationToken = default);
Task<StartupCheckReport?> GetLatestReportAsync(CancellationToken cancellationToken = default);
Task<StartupCheckReport?> GetLatestReportAsync(StartupCheckKind kind, CancellationToken cancellationToken = default);
Task<StartupCheckReport?> GetLatestCurrentReportAsync(CancellationToken cancellationToken = default);
Task<StartupCheckReport?> GetLatestCurrentReportAsync(StartupCheckKind kind, CancellationToken cancellationToken = default);
Task<StartupCheckReport?> GetReportAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<StartupCheckReportSummary>> GetHistorySummariesAsync(int take = 30, CancellationToken cancellationToken = default);
Task<IReadOnlyList<StartupCheckReport>> GetHistoryAsync(int take = 30, CancellationToken cancellationToken = default);
Task<IReadOnlyList<StartupCheckReportSummary>> GetCurrentHistoryAsync(int take = 30, CancellationToken cancellationToken = default);
Task<StartupCheckItem?> RecheckItemAsync(Guid reportId, string itemId, CancellationToken cancellationToken = default);
}
public sealed class InstallIntegrityCheckService(
AppPaths paths,
IStartupCheckStore store,
ILogService? logService = null,
string? baseDirectory = null,
Func<DateTimeOffset>? clock = null) : IInstallIntegrityCheckService
{
private static readonly TimeSpan MonthlyInterval = TimeSpan.FromDays(30);
private static readonly string[] SupportedCultures = ["zh-CN", "en-US"];
private static readonly string[] LaunchableExtensions = [".exe", ".bat", ".cmd", ".lnk", ".msc", ".ps1", ".vbs"];
private static readonly string[] CriticalRelativeFiles =
[
"YMhutBox.exe",
"YMhutBox.dll",
"resources.pri"
];
private readonly string? _baseDirectory = baseDirectory;
private readonly Func<DateTimeOffset> _clock = clock ?? (() => DateTimeOffset.Now);
public Task<StartupCheckReport> RunFastPreflightAsync(CancellationToken cancellationToken = default)
=> RunIntegrityCoreAsync(StartupCheckKind.FastPreflight, includeFullPayloadChecks: false, cancellationToken);
public async Task<StartupCheckReport> RunMonthlyIntegrityCheckAsync(CancellationToken cancellationToken = default)
{
var context = ResolveCurrentContext();
var latest = await GetLatestCurrentReportAsync(StartupCheckKind.MonthlyIntegrity, cancellationToken).ConfigureAwait(false);
if (latest is not null &&
latest.CompletedAt + MonthlyInterval > _clock() &&
string.Equals(latest.InstallIdentity, context.InstallIdentity, StringComparison.OrdinalIgnoreCase))
{
return latest;
}
return await RunIntegrityCoreAsync(StartupCheckKind.MonthlyIntegrity, includeFullPayloadChecks: true, cancellationToken).ConfigureAwait(false);
}
public Task<StartupCheckReport> RunManualIntegrityCheckAsync(CancellationToken cancellationToken = default)
=> RunIntegrityCoreAsync(StartupCheckKind.ManualIntegrity, includeFullPayloadChecks: true, cancellationToken);
public Task<StartupCheckReport?> GetLatestReportAsync(CancellationToken cancellationToken = default)
=> GetLatestReportCoreAsync(null, cancellationToken);
public Task<StartupCheckReport?> GetLatestReportAsync(StartupCheckKind kind, CancellationToken cancellationToken = default)
=> GetLatestReportCoreAsync(kind, cancellationToken);
public Task<StartupCheckReport?> GetLatestCurrentReportAsync(CancellationToken cancellationToken = default)
=> GetLatestCurrentReportCoreAsync(null, cancellationToken);
public Task<StartupCheckReport?> GetLatestCurrentReportAsync(StartupCheckKind kind, CancellationToken cancellationToken = default)
=> GetLatestCurrentReportCoreAsync(kind, cancellationToken);
public async Task<StartupCheckReport?> GetReportAsync(Guid id, CancellationToken cancellationToken = default)
{
var report = await store.GetReportAsync(id, cancellationToken).ConfigureAwait(false);
return report is null ? null : MarkCurrentInstall(report, ResolveCurrentContext());
}
public async Task<IReadOnlyList<StartupCheckReportSummary>> GetHistorySummariesAsync(int take = 30, CancellationToken cancellationToken = default)
{
var context = ResolveCurrentContext();
var summaries = await store.GetHistorySummariesAsync(take, cancellationToken).ConfigureAwait(false);
return summaries.Select(summary => MarkCurrentInstall(summary, context)).ToArray();
}
public async Task<IReadOnlyList<StartupCheckReport>> GetHistoryAsync(int take = 30, CancellationToken cancellationToken = default)
{
var context = ResolveCurrentContext();
var reports = await store.GetHistoryAsync(take, cancellationToken).ConfigureAwait(false);
return reports.Select(report => MarkCurrentInstall(report, context)).ToArray();
}
public async Task<IReadOnlyList<StartupCheckReportSummary>> GetCurrentHistoryAsync(int take = 30, CancellationToken cancellationToken = default)
{
var context = ResolveCurrentContext();
var history = await store.GetHistoryAsync(Math.Max(take, 200), cancellationToken).ConfigureAwait(false);
return history
.Select(report => MarkCurrentInstall(report, context))
.Where(report => report.IsCurrentInstall)
.Take(Math.Clamp(take, 1, 200))
.Select(StartupCheckReportSummary.FromReport)
.ToArray();
}
public async Task<StartupCheckItem?> RecheckItemAsync(Guid reportId, string itemId, CancellationToken cancellationToken = default)
{
var report = await GetReportAsync(reportId, cancellationToken).ConfigureAwait(false);
var item = report?.Items.FirstOrDefault(candidate => string.Equals(candidate.Id, itemId, StringComparison.OrdinalIgnoreCase));
return item is null ? null : RecheckItem(item, ResolveCurrentContext());
}
private async Task<StartupCheckReport> RunIntegrityCoreAsync(
StartupCheckKind kind,
bool includeFullPayloadChecks,
CancellationToken cancellationToken)
{
var startedAt = _clock();
var context = ResolveCurrentContext();
var installRoot = context.InstallRoot;
var manifest = TryReadManifest(installRoot, out var manifestPath, out var manifestError);
var rootCultureFallback = HasAnySupportedRootCultureResources(installRoot);
var items = new List<StartupCheckItem>();
CheckWritableDirectory(items, "user-root", "用户数据目录", paths.Root, StartupCheckSeverity.Critical);
CheckWritableDirectory(items, "logs", "日志目录", paths.Logs, StartupCheckSeverity.Critical);
CheckWritableDirectory(items, "data", "数据目录", paths.Data, StartupCheckSeverity.Critical);
CheckWritableDirectory(items, "sqlite-parent", "主 SQLite 存储目录", Path.GetDirectoryName(AppDatabasePaths.ResolveMainDatabasePath(paths)) ?? paths.Logs, StartupCheckSeverity.Critical);
AddDirectoryCheck(items, "install-root", "安装目录", installRoot, StartupCheckSeverity.Critical);
if (manifest is null)
{
items.Add(Skipped(
"manifest",
"安装清单",
manifestError ?? "当前安装目录未包含安装清单;按当前目录实际文件执行降噪检查。",
manifestPath));
}
else
{
AddFileCheck(items, "manifest", "安装清单", manifestPath, StartupCheckSeverity.Info);
}
foreach (var relativePath in CriticalRelativeFiles)
{
AddRelativeFileCheck(items, installRoot, relativePath, CriticalFileSeverity(relativePath, manifest, rootCultureFallback));
}
AddPureLanguageChecks(items, installRoot, strictPureLang: manifest is not null, allowRootCultureFallback: rootCultureFallback);
AddAppDataPayloadChecks(items);
AddToolsChecks(items, installRoot, includeFullPayloadChecks ? manifest : null);
AddMetadataChecks(items, installRoot);
if (includeFullPayloadChecks)
{
if (manifest is not null)
{
AddManifestFileChecks(items, installRoot, manifest);
}
}
var report = StartupCheckReport.Create(
kind,
startedAt,
installRoot,
context.ManifestIdentity,
context.InstallIdentity,
context.CheckBasis,
items);
await SaveAndLogAsync(report, cancellationToken).ConfigureAwait(false);
return report;
}
private static InstallManifest? TryReadManifest(string installRoot, out string manifestPath, out string? error)
{
manifestPath = Path.Combine(installRoot, InstallManifest.RelativePath.Replace('/', Path.DirectorySeparatorChar));
error = null;
try
{
if (!File.Exists(manifestPath))
{
error = "未找到安装清单。开发构建可继续使用,正式安装包应包含该文件。";
return null;
}
return InstallManifest.Parse(File.ReadAllText(manifestPath));
}
catch (Exception exception)
{
error = $"安装清单读取失败:{Sanitize(exception.Message)}";
return null;
}
}
private static void CheckWritableDirectory(
ICollection<StartupCheckItem> items,
string id,
string name,
string directory,
StartupCheckSeverity severity)
{
try
{
Directory.CreateDirectory(directory);
var probe = Path.Combine(directory, $".ymhut-probe-{Guid.NewGuid():N}.tmp");
File.WriteAllText(probe, "ok");
File.Delete(probe);
items.Add(Passed(id, name, $"可写:{directory}", directory));
}
catch (Exception exception)
{
items.Add(Failed(id, name, severity, $"目录不可写:{Sanitize(exception.Message)}", directory));
}
}
private static void AddDirectoryCheck(
ICollection<StartupCheckItem> items,
string id,
string name,
string directory,
StartupCheckSeverity severity)
{
items.Add(Directory.Exists(directory)
? Passed(id, name, $"目录存在:{directory}", directory)
: Missing(id, name, severity, "目录缺失", directory));
}
private static void AddFileCheck(
ICollection<StartupCheckItem> items,
string id,
string name,
string path,
StartupCheckSeverity severity,
string? missingDetail = null)
{
items.Add(File.Exists(path)
? Passed(id, name, $"文件存在:{path}", path)
: Missing(id, name, severity, missingDetail ?? "文件缺失", path));
}
private static void AddRelativeFileCheck(
ICollection<StartupCheckItem> items,
string installRoot,
string relativePath,
StartupCheckSeverity severity)
{
var normalized = Normalize(relativePath);
var path = Path.Combine(installRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
if (normalized.Equals("resources.pri", StringComparison.OrdinalIgnoreCase) && !File.Exists(path))
{
var appPri = Path.Combine(installRoot, "YMhutBox.pri");
if (File.Exists(appPri))
{
items.Add(Passed(
$"file:{normalized}",
"resources.pri / YMhutBox.pri",
"检测到当前 WinUI 开发/运行输出使用 YMhutBox.pri,资源索引可用。",
appPri));
return;
}
}
AddFileCheck(
items,
$"file:{normalized}",
relativePath,
path,
severity);
}
private static StartupCheckSeverity CriticalFileSeverity(string relativePath, InstallManifest? manifest, bool allowRootCultureFallback)
{
if (manifest is null &&
allowRootCultureFallback &&
string.Equals(Normalize(relativePath), "resources.pri", StringComparison.OrdinalIgnoreCase))
{
return StartupCheckSeverity.Warning;
}
return StartupCheckSeverity.Critical;
}
private static void AddPureLanguageChecks(
ICollection<StartupCheckItem> items,
string installRoot,
bool strictPureLang,
bool allowRootCultureFallback)
{
var resourcesLang = Path.Combine(installRoot, "resources", "lang");
if (Directory.Exists(resourcesLang))
{
items.Add(Failed(
"lang:legacy-resources",
"旧语言压缩目录",
StartupCheckSeverity.Critical,
"检测到 resources\\lang。正式发布布局应只使用 lang\\zh-CN 和 lang\\en-US。",
resourcesLang));
}
else
{
items.Add(Passed("lang:legacy-resources", "旧语言压缩目录", "未检测到 resources\\lang。", resourcesLang));
}
foreach (var culture in SupportedCultures)
{
var cultureRoot = Path.Combine(installRoot, "lang", culture);
var hasRootCultureFallback = allowRootCultureFallback && HasRootCultureResources(installRoot, culture);
var missingSeverity = strictPureLang || !hasRootCultureFallback
? StartupCheckSeverity.Critical
: StartupCheckSeverity.Warning;
if (!Directory.Exists(cultureRoot))
{
items.Add(hasRootCultureFallback
? Passed(
$"lang:{culture}",
$"语言资源 {culture}",
"检测到当前开发/运行输出的根目录语言资源,语言资源可用。",
Path.Combine(installRoot, culture))
: Missing(
$"lang:{culture}",
$"语言资源 {culture}",
missingSeverity,
"语言目录缺失",
cultureRoot));
continue;
}
var hasSatellite = Directory.EnumerateFiles(cultureRoot, "*.mui", SearchOption.AllDirectories).Any() ||
Directory.EnumerateFiles(cultureRoot, "*.resources.dll", SearchOption.AllDirectories).Any();
items.Add(hasSatellite
? Passed($"lang:{culture}", $"语言资源 {culture}", "语言资源已展开。", cultureRoot)
: hasRootCultureFallback
? Passed(
$"lang:{culture}",
$"语言资源 {culture}",
"lang 目录未展开 MUI 或 resources.dll,但根目录语言资源可用。",
Path.Combine(installRoot, culture))
: Missing(
$"lang:{culture}",
$"语言资源 {culture}",
missingSeverity,
"语言资源目录中没有 MUI 或 resources.dll。",
cultureRoot));
}
var rootCultures = (Directory.Exists(installRoot)
? Directory.EnumerateDirectories(installRoot)
.Select(Path.GetFileName)
.Where(name => !string.IsNullOrWhiteSpace(name) && LooksLikeCultureName(name!))
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToArray()
: Array.Empty<string>());
if (rootCultures.Length > 0)
{
items.Add(Skipped(
"lang:root-culture-summary",
"根目录语言资源",
$"检测到 {rootCultures.Length} 个运行时/框架语言资源目录,已识别为当前安装目录的正常卫星资源,不计入问题。",
installRoot));
}
}
private static bool HasAnySupportedRootCultureResources(string installRoot)
=> SupportedCultures.Any(culture => HasRootCultureResources(installRoot, culture));
private static bool HasRootCultureResources(string installRoot, string culture)
{
try
{
var cultureRoot = Path.Combine(installRoot, culture);
if (!Directory.Exists(cultureRoot))
{
return false;
}
return Directory.EnumerateFiles(cultureRoot, "*.mui", SearchOption.AllDirectories).Any() ||
Directory.EnumerateFiles(cultureRoot, "*.resources.dll", SearchOption.AllDirectories).Any();
}
catch
{
return false;
}
}
private void AddAppDataPayloadChecks(ICollection<StartupCheckItem> items)
{
var removed = paths.CleanupUserPayloadDirectories()
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var relative in AppPaths.UserPayloadDirectoryNames.Distinct(StringComparer.OrdinalIgnoreCase))
{
var path = Path.Combine(paths.Root, relative);
if (Directory.Exists(path))
{
items.Add(new StartupCheckItem(
$"userdata:{relative.ToLowerInvariant()}",
$"User data {relative}",
StartupCheckSeverity.Info,
StartupCheckStatus.Skipped,
"Legacy user payload remains, but current install health is based only on the active install directory.",
path));
continue;
}
items.Add(Passed(
$"userdata:{relative.ToLowerInvariant()}",
$"User data {relative}",
removed.Contains(path)
? "Removed a legacy payload copy from the user data folder."
: "No legacy payload copy found in the user data folder.",
path));
}
}
private static void AddManifestFileChecks(ICollection<StartupCheckItem> items, string installRoot, InstallManifest manifest)
{
foreach (var relativePath in manifest.RequiredFiles.Distinct(StringComparer.OrdinalIgnoreCase))
{
AddRelativeFileCheck(items, installRoot, relativePath, StartupCheckSeverity.Critical);
}
foreach (var relativePath in manifest.Files
.Where(ShouldVerifyPayloadFile)
.Distinct(StringComparer.OrdinalIgnoreCase))
{
var severity = relativePath.StartsWith("Tools/", StringComparison.OrdinalIgnoreCase) ||
relativePath.StartsWith("Metadata/", StringComparison.OrdinalIgnoreCase)
? StartupCheckSeverity.Warning
: StartupCheckSeverity.Critical;
AddRelativeFileCheck(items, installRoot, relativePath, severity);
}
}
private static bool ShouldVerifyPayloadFile(string relativePath)
{
var normalized = Normalize(relativePath);
return normalized.StartsWith("lang/", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("Tools/", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("Metadata/", StringComparison.OrdinalIgnoreCase) ||
normalized.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ||
normalized.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ||
normalized.EndsWith(".mui", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("resources.pri", StringComparison.OrdinalIgnoreCase);
}
private static void AddToolsChecks(ICollection<StartupCheckItem> items, string installRoot, InstallManifest? manifest)
{
var toolsRoot = Path.Combine(installRoot, "Tools");
if (!Directory.Exists(toolsRoot))
{
items.Add(Missing("tools:root", "随包工具目录", StartupCheckSeverity.Warning, "Tools 目录缺失,外部工具将不可用。", toolsRoot));
return;
}
var launchables = Directory.EnumerateFiles(toolsRoot, "*", SearchOption.AllDirectories)
.Where(path => LaunchableExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase))
.Take(5000)
.ToArray();
items.Add(launchables.Length > 0
? Passed("tools:launchables", "外部工具启动项", $"检测到 {launchables.Length} 个启动项。", toolsRoot)
: Missing("tools:launchables", "外部工具启动项", StartupCheckSeverity.Warning, "Tools 目录中没有可启动文件。", toolsRoot));
if (manifest is null)
{
return;
}
foreach (var relativePath in manifest.Files
.Where(path => Normalize(path).StartsWith("Tools/", StringComparison.OrdinalIgnoreCase) &&
LaunchableExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
AddRelativeFileCheck(items, installRoot, relativePath, StartupCheckSeverity.Warning);
}
}
private static void AddMetadataChecks(ICollection<StartupCheckItem> items, string installRoot)
{
var metadataRoot = Path.Combine(installRoot, "Metadata");
if (!Directory.Exists(metadataRoot))
{
items.Add(Missing("metadata:root", "工具元数据目录", StartupCheckSeverity.Warning, "Metadata 目录缺失,外部工具说明会降级。", metadataRoot));
return;
}
var jsonFiles = Directory.EnumerateFiles(metadataRoot, "*.json", SearchOption.AllDirectories).Take(500).ToArray();
if (jsonFiles.Length == 0)
{
items.Add(Missing("metadata:json", "工具元数据 JSON", StartupCheckSeverity.Warning, "Metadata 目录中没有 JSON 元数据。", metadataRoot));
return;
}
var invalid = new List<string>();
foreach (var file in jsonFiles)
{
try
{
using var stream = File.OpenRead(file);
using var _ = JsonDocument.Parse(stream);
}
catch
{
invalid.Add(file);
if (invalid.Count >= 5)
{
break;
}
}
}
items.Add(invalid.Count == 0
? Passed("metadata:json", "工具元数据 JSON", $"检测到 {jsonFiles.Length} 个可解析 JSON。", metadataRoot)
: Failed("metadata:json", "工具元数据 JSON", StartupCheckSeverity.Warning, $"发现无法解析的 JSON{string.Join("; ", invalid)}", metadataRoot));
}
private async Task SaveAndLogAsync(StartupCheckReport report, CancellationToken cancellationToken)
{
await store.SaveAsync(report, cancellationToken).ConfigureAwait(false);
if (logService is not null)
{
var level = report.HasCriticalIssues ? "Error" : report.HasVisibleIssues ? "Warning" : "Information";
await logService.WriteAsync(
level,
"startup-check",
"启动自检完成",
$"kind={report.Kind}; issues={report.IssueCount}; visible={report.VisibleIssueCount}; critical={report.CriticalIssueCount}; root={report.InstallRoot}",
cancellationToken).ConfigureAwait(false);
}
}
private async Task<StartupCheckReport?> GetLatestReportCoreAsync(StartupCheckKind? kind, CancellationToken cancellationToken)
{
var report = kind is null
? await store.GetLatestReportAsync(cancellationToken).ConfigureAwait(false)
: await store.GetLatestReportAsync(kind.Value, cancellationToken).ConfigureAwait(false);
return report is null ? null : MarkCurrentInstall(report, ResolveCurrentContext());
}
private async Task<StartupCheckReport?> GetLatestCurrentReportCoreAsync(StartupCheckKind? kind, CancellationToken cancellationToken)
{
var context = ResolveCurrentContext();
var history = await store.GetHistoryAsync(200, cancellationToken).ConfigureAwait(false);
return history
.Select(report => MarkCurrentInstall(report, context))
.Where(report => report.IsCurrentInstall && (kind is null || report.Kind == kind.Value))
.OrderByDescending(report => report.CompletedAt)
.FirstOrDefault();
}
private InstallRootContext ResolveCurrentContext()
=> InstallLayoutPaths.ResolveInstallRootContext(_baseDirectory);
private static StartupCheckReport MarkCurrentInstall(StartupCheckReport report, InstallRootContext context)
{
var installIdentity = string.IsNullOrWhiteSpace(report.InstallIdentity)
? InstallRootContext.BuildInstallIdentity(report.InstallRoot, report.ManifestIdentity)
: report.InstallIdentity;
var isCurrent = string.Equals(installIdentity, context.InstallIdentity, StringComparison.OrdinalIgnoreCase);
var items = isCurrent ? NormalizeCurrentInstallLegacyItems(report.Items, context) : report.Items;
return report with
{
Items = items,
InstallIdentity = installIdentity,
IsCurrentInstall = isCurrent,
SupersededByCurrentInstall = !isCurrent,
CheckBasis = string.IsNullOrWhiteSpace(report.CheckBasis)
? $"installRoot={report.InstallRoot}; manifest={report.ManifestIdentity}"
: report.CheckBasis
};
}
private static IReadOnlyList<StartupCheckItem> NormalizeCurrentInstallLegacyItems(
IReadOnlyList<StartupCheckItem> items,
InstallRootContext context)
{
if (items.Count == 0)
{
return items;
}
return items.Select(item => NormalizeCurrentInstallLegacyItem(item, context)).ToArray();
}
private static StartupCheckItem NormalizeCurrentInstallLegacyItem(StartupCheckItem item, InstallRootContext context)
{
if (!item.IsIssue)
{
return item;
}
if (item.Id.StartsWith("lang:root:", StringComparison.OrdinalIgnoreCase))
{
var culture = item.Id["lang:root:".Length..];
var culturePath = Path.Combine(context.InstallRoot, culture);
if (HasRootCultureResources(context.InstallRoot, culture))
{
return item with
{
Severity = StartupCheckSeverity.Info,
Status = StartupCheckStatus.Skipped,
Detail = "旧版报告将根目录运行时语言资源记录为异常;当前版本已识别为正常卫星资源,旧记录仅供参考。",
Path = culturePath
};
}
}
if (item.Id is "lang:zh-CN" or "lang:en-US")
{
var culture = item.Id["lang:".Length..];
if (HasRootCultureResources(context.InstallRoot, culture))
{
return item with
{
Severity = StartupCheckSeverity.Info,
Status = StartupCheckStatus.Passed,
Detail = "检测到当前开发/运行输出的根目录语言资源,语言资源可用;旧记录仅供参考。",
Path = Path.Combine(context.InstallRoot, culture)
};
}
}
if (item.Id.Equals("file:resources.pri", StringComparison.OrdinalIgnoreCase))
{
var appPri = Path.Combine(context.InstallRoot, "YMhutBox.pri");
if (File.Exists(appPri))
{
return item with
{
Severity = StartupCheckSeverity.Info,
Status = StartupCheckStatus.Passed,
Name = "resources.pri / YMhutBox.pri",
Detail = "检测到当前 WinUI 开发/运行输出使用 YMhutBox.pri,资源索引可用;旧记录仅供参考。",
Path = appPri
};
}
}
if (item.Id.Equals("manifest", StringComparison.OrdinalIgnoreCase) &&
!File.Exists(context.ManifestPath) &&
context.ManifestIdentity.StartsWith("no-manifest:", StringComparison.OrdinalIgnoreCase))
{
return item with
{
Severity = StartupCheckSeverity.Info,
Status = StartupCheckStatus.Skipped,
Detail = "当前安装目录未包含安装清单;按当前目录实际文件执行降噪检查,旧记录仅供参考。",
Path = context.ManifestPath
};
}
return item;
}
private static StartupCheckReportSummary MarkCurrentInstall(StartupCheckReportSummary summary, InstallRootContext context)
{
var installIdentity = string.IsNullOrWhiteSpace(summary.InstallIdentity)
? InstallRootContext.BuildInstallIdentity(summary.InstallRoot, summary.ManifestIdentity)
: summary.InstallIdentity;
var isCurrent = string.Equals(installIdentity, context.InstallIdentity, StringComparison.OrdinalIgnoreCase);
return summary with
{
InstallIdentity = installIdentity,
IsCurrentInstall = isCurrent,
SupersededByCurrentInstall = !isCurrent,
VisibleIssueCount = isCurrent ? summary.VisibleIssueCount : 0,
CheckBasis = string.IsNullOrWhiteSpace(summary.CheckBasis)
? $"installRoot={summary.InstallRoot}; manifest={summary.ManifestIdentity}"
: summary.CheckBasis
};
}
private static StartupCheckItem RecheckItem(StartupCheckItem item, InstallRootContext context)
{
var path = ResolveCurrentItemPath(item, context);
if (string.IsNullOrWhiteSpace(path))
{
return item with
{
Severity = StartupCheckSeverity.Info,
Status = StartupCheckStatus.Skipped,
Detail = "No concrete path is attached to this item."
};
}
var passed = RecheckPathForItem(item.Id, path);
return item with
{
Severity = passed ? StartupCheckSeverity.Info : item.Severity,
Status = passed ? StartupCheckStatus.Passed : item.Status,
Detail = passed
? "Current check passed. The old record is kept for reference only."
: "Current check still does not pass for this path.",
Path = path
};
}
private static string? ResolveCurrentItemPath(StartupCheckItem item, InstallRootContext context)
{
if (item.Id.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var relative = item.Id["file:".Length..].Replace('/', Path.DirectorySeparatorChar);
return Path.Combine(context.InstallRoot, relative);
}
if (item.Id == "manifest")
{
return context.ManifestPath;
}
if (item.Id == "install-root")
{
return context.InstallRoot;
}
if (item.Id.StartsWith("lang:", StringComparison.OrdinalIgnoreCase) &&
!item.Id.StartsWith("lang:legacy", StringComparison.OrdinalIgnoreCase) &&
!item.Id.StartsWith("lang:root:", StringComparison.OrdinalIgnoreCase))
{
return Path.Combine(context.InstallRoot, "lang", item.Id["lang:".Length..]);
}
if (item.Id == "lang:legacy-resources")
{
return Path.Combine(context.InstallRoot, "resources", "lang");
}
if (item.Id.StartsWith("lang:root:", StringComparison.OrdinalIgnoreCase))
{
return Path.Combine(context.InstallRoot, item.Id["lang:root:".Length..]);
}
if (item.Id is "tools:root" or "tools:launchables")
{
return Path.Combine(context.InstallRoot, "Tools");
}
if (item.Id is "metadata:root" or "metadata:json")
{
return Path.Combine(context.InstallRoot, "Metadata");
}
return item.Path;
}
private static bool RecheckPathForItem(string itemId, string path)
{
if (itemId == "lang:legacy-resources" ||
itemId.StartsWith("lang:root:", StringComparison.OrdinalIgnoreCase) ||
itemId.StartsWith("userdata:", StringComparison.OrdinalIgnoreCase))
{
return !Directory.Exists(path);
}
if (itemId.StartsWith("lang:", StringComparison.OrdinalIgnoreCase))
{
return Directory.Exists(path) &&
(Directory.EnumerateFiles(path, "*.mui", SearchOption.AllDirectories).Any() ||
Directory.EnumerateFiles(path, "*.resources.dll", SearchOption.AllDirectories).Any());
}
if (itemId is "install-root" or "tools:root" or "metadata:root")
{
return Directory.Exists(path);
}
if (itemId == "tools:launchables")
{
return Directory.Exists(path) &&
Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)
.Any(file => LaunchableExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase));
}
if (itemId == "metadata:json")
{
return Directory.Exists(path) &&
Directory.EnumerateFiles(path, "*.json", SearchOption.AllDirectories).Any();
}
if (itemId.StartsWith("file:", StringComparison.OrdinalIgnoreCase) || itemId == "manifest")
{
return File.Exists(path);
}
return File.Exists(path) || Directory.Exists(path);
}
private static StartupCheckItem Passed(string id, string name, string detail, string? path = null)
=> new(id, name, StartupCheckSeverity.Info, StartupCheckStatus.Passed, detail, path);
private static StartupCheckItem Missing(string id, string name, StartupCheckSeverity severity, string detail, string? path = null)
=> new(id, name, severity, StartupCheckStatus.Missing, detail, path);
private static StartupCheckItem Failed(string id, string name, StartupCheckSeverity severity, string detail, string? path = null)
=> new(id, name, severity, StartupCheckStatus.Failed, detail, path);
private static StartupCheckItem Skipped(string id, string name, string detail, string? path = null)
=> new(id, name, StartupCheckSeverity.Info, StartupCheckStatus.Skipped, detail, path);
private static string Normalize(string relativePath)
=> relativePath.Trim().Replace('\\', '/').TrimStart('/');
private static bool LooksLikeCultureName(string name)
{
try
{
var culture = CultureInfo.GetCultureInfo(name);
return !string.IsNullOrWhiteSpace(culture.Name) &&
string.Equals(culture.Name, name, StringComparison.OrdinalIgnoreCase);
}
catch (CultureNotFoundException)
{
return false;
}
}
private static string Sanitize(string value)
=> value.ReplaceLineEndings(" ").Trim();
}
@@ -0,0 +1,164 @@
using System.Text.Json;
namespace YMhut.Box.Core.Startup;
public enum StartupCheckKind
{
FastPreflight,
MonthlyIntegrity,
ManualIntegrity
}
public enum StartupCheckSeverity
{
Info,
Warning,
Critical
}
public enum StartupCheckStatus
{
Passed,
Missing,
Failed,
Skipped
}
public sealed record StartupCheckItem(
string Id,
string Name,
StartupCheckSeverity Severity,
StartupCheckStatus Status,
string Detail,
string? Path = null)
{
public bool IsIssue => Status is StartupCheckStatus.Missing or StartupCheckStatus.Failed;
}
public sealed record StartupCheckReport(
Guid Id,
StartupCheckKind Kind,
DateTimeOffset StartedAt,
DateTimeOffset CompletedAt,
string InstallRoot,
string ManifestIdentity,
IReadOnlyList<StartupCheckItem> Items,
string InstallIdentity = "",
bool IsCurrentInstall = true,
bool SupersededByCurrentInstall = false,
string CheckBasis = "")
{
public int IssueCount => Items.Count(item => item.IsIssue);
public int CriticalIssueCount => Items.Count(item => item.IsIssue && item.Severity == StartupCheckSeverity.Critical);
public int WarningIssueCount => Items.Count(item => item.IsIssue && item.Severity == StartupCheckSeverity.Warning);
public int VisibleIssueCount => IsCurrentInstall
? Items.Count(item => item.IsIssue && !item.Id.StartsWith("repaired:", StringComparison.OrdinalIgnoreCase))
: 0;
public int VisibleCriticalIssueCount => IsCurrentInstall
? Items.Count(item => item.IsIssue &&
item.Severity == StartupCheckSeverity.Critical &&
!item.Id.StartsWith("repaired:", StringComparison.OrdinalIgnoreCase))
: 0;
public int VisibleWarningIssueCount => IsCurrentInstall
? Items.Count(item => item.IsIssue &&
item.Severity == StartupCheckSeverity.Warning &&
!item.Id.StartsWith("repaired:", StringComparison.OrdinalIgnoreCase))
: 0;
public bool HasVisibleIssues => VisibleIssueCount > 0;
public bool HasCriticalIssues => CriticalIssueCount > 0;
public static StartupCheckReport Create(
StartupCheckKind kind,
DateTimeOffset startedAt,
string installRoot,
string manifestIdentity,
string installIdentity,
string checkBasis,
IEnumerable<StartupCheckItem> items)
{
return new StartupCheckReport(
Guid.NewGuid(),
kind,
startedAt,
DateTimeOffset.Now,
installRoot,
manifestIdentity,
items.ToArray(),
installIdentity,
IsCurrentInstall: true,
SupersededByCurrentInstall: false,
checkBasis);
}
public string ToJson()
=> JsonSerializer.Serialize(this, StartupCheckJson.Options);
}
public sealed record StartupCheckReportSummary(
Guid Id,
StartupCheckKind Kind,
DateTimeOffset StartedAt,
DateTimeOffset CompletedAt,
string InstallRoot,
string ManifestIdentity,
int ItemCount,
int IssueCount,
int CriticalIssueCount,
int WarningIssueCount,
int VisibleIssueCount,
string InstallIdentity = "",
bool IsCurrentInstall = true,
bool SupersededByCurrentInstall = false,
string CheckBasis = "")
{
public bool HasCriticalIssues => CriticalIssueCount > 0;
public bool HasVisibleIssues => VisibleIssueCount > 0;
public static StartupCheckReportSummary FromReport(StartupCheckReport report)
=> new(
report.Id,
report.Kind,
report.StartedAt,
report.CompletedAt,
report.InstallRoot,
report.ManifestIdentity,
report.Items.Count,
report.IssueCount,
report.CriticalIssueCount,
report.WarningIssueCount,
report.VisibleIssueCount,
report.InstallIdentity,
report.IsCurrentInstall,
report.SupersededByCurrentInstall,
report.CheckBasis);
}
public sealed record StartupInitializationSnapshot(
DateTimeOffset StartedAt,
DateTimeOffset CompletedAt,
StartupCheckReport FastPreflightReport,
int ToolCount,
string InstallRoot)
{
public bool HasStartupIssue => FastPreflightReport.IssueCount > 0;
public bool HasVisibleStartupIssue => FastPreflightReport.HasVisibleIssues;
public bool HasCriticalStartupIssue => FastPreflightReport.HasCriticalIssues;
}
public static class StartupCheckJson
{
public static JsonSerializerOptions Options { get; } = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
}
@@ -0,0 +1,639 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
using YMhut.Box.Core.App;
namespace YMhut.Box.Core.Startup;
public interface IStartupCheckStore
{
string DatabasePath { get; }
Task SaveAsync(StartupCheckReport report, CancellationToken cancellationToken = default);
Task<StartupCheckReport?> GetLatestReportAsync(CancellationToken cancellationToken = default);
Task<StartupCheckReport?> GetLatestReportAsync(StartupCheckKind kind, CancellationToken cancellationToken = default);
Task<StartupCheckReport?> GetReportAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<StartupCheckReportSummary>> GetHistorySummariesAsync(int take = 30, CancellationToken cancellationToken = default);
Task<IReadOnlyList<StartupCheckReport>> GetHistoryAsync(int take = 30, CancellationToken cancellationToken = default);
}
public sealed class StartupCheckStore : IStartupCheckStore
{
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly string _legacyDatabasePath;
private bool _initialized;
public StartupCheckStore(AppPaths paths)
{
paths.EnsureCreated();
DatabasePath = AppDatabasePaths.ResolveMainDatabasePath(paths);
_legacyDatabasePath = Path.Combine(paths.Data, "startup-checks.db");
}
public string DatabasePath { get; }
public async Task SaveAsync(StartupCheckReport report, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var transaction = connection.BeginTransaction();
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = """
INSERT INTO startup_check_reports(
id,
kind,
started_at,
completed_at,
install_root,
manifest_identity,
install_identity,
is_current_install,
superseded_by_current_install,
check_basis,
issue_count,
critical_issue_count,
warning_issue_count,
visible_issue_count)
VALUES (
$id,
$kind,
$started_at,
$completed_at,
$install_root,
$manifest_identity,
$install_identity,
$is_current_install,
$superseded_by_current_install,
$check_basis,
$issue_count,
$critical_issue_count,
$warning_issue_count,
$visible_issue_count);
""";
command.Parameters.AddWithValue("$id", report.Id.ToString("N"));
command.Parameters.AddWithValue("$kind", report.Kind.ToString());
command.Parameters.AddWithValue("$started_at", report.StartedAt.ToString("O", CultureInfo.InvariantCulture));
command.Parameters.AddWithValue("$completed_at", report.CompletedAt.ToString("O", CultureInfo.InvariantCulture));
command.Parameters.AddWithValue("$install_root", report.InstallRoot);
command.Parameters.AddWithValue("$manifest_identity", report.ManifestIdentity);
command.Parameters.AddWithValue("$install_identity", report.InstallIdentity);
command.Parameters.AddWithValue("$is_current_install", report.IsCurrentInstall ? 1 : 0);
command.Parameters.AddWithValue("$superseded_by_current_install", report.SupersededByCurrentInstall ? 1 : 0);
command.Parameters.AddWithValue("$check_basis", report.CheckBasis);
command.Parameters.AddWithValue("$issue_count", report.IssueCount);
command.Parameters.AddWithValue("$critical_issue_count", report.CriticalIssueCount);
command.Parameters.AddWithValue("$warning_issue_count", report.WarningIssueCount);
command.Parameters.AddWithValue("$visible_issue_count", report.VisibleIssueCount);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
foreach (var item in report.Items)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO startup_check_items(
report_id,
item_id,
name,
severity,
status,
detail,
path)
VALUES (
$report_id,
$item_id,
$name,
$severity,
$status,
$detail,
$path);
""";
command.Parameters.AddWithValue("$report_id", report.Id.ToString("N"));
command.Parameters.AddWithValue("$item_id", item.Id);
command.Parameters.AddWithValue("$name", item.Name);
command.Parameters.AddWithValue("$severity", item.Severity.ToString());
command.Parameters.AddWithValue("$status", item.Status.ToString());
command.Parameters.AddWithValue("$detail", item.Detail);
command.Parameters.AddWithValue("$path", (object?)item.Path ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public Task<StartupCheckReport?> GetLatestReportAsync(CancellationToken cancellationToken = default)
=> GetLatestCoreAsync(kind: null, cancellationToken);
public Task<StartupCheckReport?> GetLatestReportAsync(StartupCheckKind kind, CancellationToken cancellationToken = default)
=> GetLatestCoreAsync(kind, cancellationToken);
public async Task<StartupCheckReport?> GetReportAsync(Guid id, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT id, kind, started_at, completed_at, install_root, manifest_identity, install_identity, is_current_install, superseded_by_current_install, check_basis, issue_count, critical_issue_count, warning_issue_count, visible_issue_count
FROM startup_check_reports
WHERE id = $id
LIMIT 1;
""";
command.Parameters.AddWithValue("$id", id.ToString("N"));
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
var header = ReadHeader(reader);
return await ReadReportAsync(connection, header, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task<IReadOnlyList<StartupCheckReportSummary>> GetHistorySummariesAsync(int take = 30, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT reports.id,
reports.kind,
reports.started_at,
reports.completed_at,
reports.install_root,
reports.manifest_identity,
reports.install_identity,
reports.is_current_install,
reports.superseded_by_current_install,
reports.check_basis,
COUNT(items.id) AS item_count,
reports.issue_count,
reports.critical_issue_count,
reports.warning_issue_count,
reports.visible_issue_count
FROM startup_check_reports reports
LEFT JOIN startup_check_items items ON items.report_id = reports.id
GROUP BY reports.id
ORDER BY reports.completed_at DESC
LIMIT $take;
""";
command.Parameters.AddWithValue("$take", Math.Clamp(take, 1, 200));
var summaries = new List<StartupCheckReportSummary>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
summaries.Add(ReadSummary(reader));
}
return summaries;
}
finally
{
_gate.Release();
}
}
public async Task<IReadOnlyList<StartupCheckReport>> GetHistoryAsync(int take = 30, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT id, kind, started_at, completed_at, install_root, manifest_identity, install_identity, is_current_install, superseded_by_current_install, check_basis, issue_count, critical_issue_count, warning_issue_count, visible_issue_count
FROM startup_check_reports
ORDER BY completed_at DESC
LIMIT $take;
""";
command.Parameters.AddWithValue("$take", Math.Clamp(take, 1, 200));
var headers = new List<ReportHeader>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
headers.Add(ReadHeader(reader));
}
var reports = new List<StartupCheckReport>(headers.Count);
foreach (var header in headers)
{
reports.Add(await ReadReportAsync(connection, header, cancellationToken).ConfigureAwait(false));
}
return reports;
}
finally
{
_gate.Release();
}
}
private async Task<StartupCheckReport?> GetLatestCoreAsync(StartupCheckKind? kind, CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = kind is null
? """
SELECT id, kind, started_at, completed_at, install_root, manifest_identity, install_identity, is_current_install, superseded_by_current_install, check_basis, issue_count, critical_issue_count, warning_issue_count, visible_issue_count
FROM startup_check_reports
ORDER BY completed_at DESC
LIMIT 1;
"""
: """
SELECT id, kind, started_at, completed_at, install_root, manifest_identity, install_identity, is_current_install, superseded_by_current_install, check_basis, issue_count, critical_issue_count, warning_issue_count, visible_issue_count
FROM startup_check_reports
WHERE kind = $kind
ORDER BY completed_at DESC
LIMIT 1;
""";
if (kind is not null)
{
command.Parameters.AddWithValue("$kind", kind.Value.ToString());
}
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
var header = ReadHeader(reader);
return await ReadReportAsync(connection, header, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private static async Task<StartupCheckReport> ReadReportAsync(SqliteConnection connection, ReportHeader header, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT item_id, name, severity, status, detail, path
FROM startup_check_items
WHERE report_id = $report_id
ORDER BY id;
""";
command.Parameters.AddWithValue("$report_id", header.Id.ToString("N"));
var items = new List<StartupCheckItem>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
items.Add(new StartupCheckItem(
reader.GetString(0),
reader.GetString(1),
ParseEnum<StartupCheckSeverity>(reader.GetString(2), StartupCheckSeverity.Info),
ParseEnum<StartupCheckStatus>(reader.GetString(3), StartupCheckStatus.Skipped),
reader.GetString(4),
reader.IsDBNull(5) ? null : reader.GetString(5)));
}
return new StartupCheckReport(
header.Id,
header.Kind,
header.StartedAt,
header.CompletedAt,
header.InstallRoot,
header.ManifestIdentity,
items,
header.InstallIdentity,
header.IsCurrentInstall,
header.SupersededByCurrentInstall,
header.CheckBasis);
}
private static ReportHeader ReadHeader(SqliteDataReader reader)
{
return new ReportHeader(
Guid.TryParseExact(reader.GetString(0), "N", out var id) ? id : Guid.NewGuid(),
ParseEnum<StartupCheckKind>(reader.GetString(1), StartupCheckKind.FastPreflight),
ParseDate(reader.GetString(2)),
ParseDate(reader.GetString(3)),
reader.GetString(4),
reader.GetString(5),
reader.IsDBNull(6) ? string.Empty : reader.GetString(6),
ReadBoolean(reader, 7),
ReadBoolean(reader, 8),
reader.IsDBNull(9) ? string.Empty : reader.GetString(9));
}
private static StartupCheckReportSummary ReadSummary(SqliteDataReader reader)
{
return new StartupCheckReportSummary(
Guid.TryParseExact(reader.GetString(0), "N", out var id) ? id : Guid.NewGuid(),
ParseEnum<StartupCheckKind>(reader.GetString(1), StartupCheckKind.FastPreflight),
ParseDate(reader.GetString(2)),
ParseDate(reader.GetString(3)),
reader.GetString(4),
reader.GetString(5),
ReadInt32(reader, 10),
ReadInt32(reader, 11),
ReadInt32(reader, 12),
ReadInt32(reader, 13),
ReadInt32(reader, 14),
reader.IsDBNull(6) ? string.Empty : reader.GetString(6),
ReadBoolean(reader, 7),
ReadBoolean(reader, 8),
reader.IsDBNull(9) ? string.Empty : reader.GetString(9));
}
private static int ReadInt32(SqliteDataReader reader, int ordinal)
=> Convert.ToInt32(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
private static bool ReadBoolean(SqliteDataReader reader, int ordinal)
=> !reader.IsDBNull(ordinal) && Convert.ToInt32(reader.GetValue(ordinal), CultureInfo.InvariantCulture) != 0;
private async Task EnsureInitializedAsync(CancellationToken cancellationToken)
{
if (_initialized)
{
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(DatabasePath)!);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS startup_check_reports (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT NOT NULL,
install_root TEXT NOT NULL,
manifest_identity TEXT NOT NULL,
install_identity TEXT NOT NULL DEFAULT '',
is_current_install INTEGER NOT NULL DEFAULT 1,
superseded_by_current_install INTEGER NOT NULL DEFAULT 0,
check_basis TEXT NOT NULL DEFAULT '',
issue_count INTEGER NOT NULL,
critical_issue_count INTEGER NOT NULL,
warning_issue_count INTEGER NOT NULL,
visible_issue_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS startup_check_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id TEXT NOT NULL,
item_id TEXT NOT NULL,
name TEXT NOT NULL,
severity TEXT NOT NULL,
status TEXT NOT NULL,
detail TEXT NOT NULL,
path TEXT NULL,
FOREIGN KEY(report_id) REFERENCES startup_check_reports(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_startup_check_reports_completed ON startup_check_reports(completed_at DESC);
CREATE INDEX IF NOT EXISTS idx_startup_check_reports_kind_completed ON startup_check_reports(kind, completed_at DESC);
CREATE INDEX IF NOT EXISTS idx_startup_check_items_report ON startup_check_items(report_id);
""";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await EnsureColumnAsync(connection, "startup_check_reports", "install_identity", "TEXT NOT NULL DEFAULT ''", cancellationToken).ConfigureAwait(false);
await EnsureColumnAsync(connection, "startup_check_reports", "is_current_install", "INTEGER NOT NULL DEFAULT 1", cancellationToken).ConfigureAwait(false);
await EnsureColumnAsync(connection, "startup_check_reports", "superseded_by_current_install", "INTEGER NOT NULL DEFAULT 0", cancellationToken).ConfigureAwait(false);
await EnsureColumnAsync(connection, "startup_check_reports", "check_basis", "TEXT NOT NULL DEFAULT ''", cancellationToken).ConfigureAwait(false);
await EnsureColumnAsync(connection, "startup_check_reports", "visible_issue_count", "INTEGER NOT NULL DEFAULT 0", cancellationToken).ConfigureAwait(false);
await ImportLegacyDatabaseAsync(connection, cancellationToken).ConfigureAwait(false);
_initialized = true;
}
private async Task ImportLegacyDatabaseAsync(SqliteConnection connection, CancellationToken cancellationToken)
{
if (!File.Exists(_legacyDatabasePath) ||
string.Equals(Path.GetFullPath(_legacyDatabasePath), Path.GetFullPath(DatabasePath), StringComparison.OrdinalIgnoreCase))
{
return;
}
try
{
await using (var attach = connection.CreateCommand())
{
attach.CommandText = "ATTACH DATABASE $path AS legacy_startup;";
attach.Parameters.AddWithValue("$path", _legacyDatabasePath);
await attach.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
var legacyHasVisibleIssueCount = await LegacyTableHasColumnAsync(
connection,
"startup_check_reports",
"visible_issue_count",
cancellationToken).ConfigureAwait(false);
await using (var command = connection.CreateCommand())
{
var visibleIssueExpression = legacyHasVisibleIssueCount ? "visible_issue_count" : "issue_count";
command.CommandText = $$"""
INSERT OR IGNORE INTO startup_check_reports(
id,
kind,
started_at,
completed_at,
install_root,
manifest_identity,
install_identity,
is_current_install,
superseded_by_current_install,
check_basis,
issue_count,
critical_issue_count,
warning_issue_count,
visible_issue_count)
SELECT id,
kind,
started_at,
completed_at,
install_root,
manifest_identity,
'',
0,
1,
'legacy-import',
issue_count,
critical_issue_count,
warning_issue_count,
{{visibleIssueExpression}}
FROM legacy_startup.startup_check_reports;
INSERT OR IGNORE INTO startup_check_items(
report_id,
item_id,
name,
severity,
status,
detail,
path)
SELECT report_id,
item_id,
name,
severity,
status,
detail,
path
FROM legacy_startup.startup_check_items;
""";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await using (var detach = connection.CreateCommand())
{
detach.CommandText = "DETACH DATABASE legacy_startup;";
await detach.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
DeleteLegacyDatabaseFiles();
}
catch
{
try
{
await using var detach = connection.CreateCommand();
detach.CommandText = "DETACH DATABASE legacy_startup;";
await detach.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
}
}
}
private static async Task<bool> LegacyTableHasColumnAsync(
SqliteConnection connection,
string table,
string column,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = $"PRAGMA legacy_startup.table_info({table});";
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private void DeleteLegacyDatabaseFiles()
{
foreach (var path in new[]
{
_legacyDatabasePath,
_legacyDatabasePath + "-wal",
_legacyDatabasePath + "-shm"
})
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
}
}
}
private static async Task EnsureColumnAsync(
SqliteConnection connection,
string table,
string column,
string definition,
CancellationToken cancellationToken)
{
await using (var command = connection.CreateCommand())
{
command.CommandText = $"PRAGMA table_info({table});";
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase))
{
return;
}
}
}
await using (var command = connection.CreateCommand())
{
command.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {definition};";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection($"Data Source={DatabasePath};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = """
PRAGMA busy_timeout = 5000;
PRAGMA synchronous = NORMAL;
""";
command.ExecuteNonQuery();
return connection;
}
private static DateTimeOffset ParseDate(string value)
=> DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed)
? parsed
: DateTimeOffset.Now;
private static TEnum ParseEnum<TEnum>(string value, TEnum fallback)
where TEnum : struct
=> Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed) ? parsed : fallback;
private sealed record ReportHeader(
Guid Id,
StartupCheckKind Kind,
DateTimeOffset StartedAt,
DateTimeOffset CompletedAt,
string InstallRoot,
string ManifestIdentity,
string InstallIdentity,
bool IsCurrentInstall,
bool SupersededByCurrentInstall,
string CheckBasis);
}
@@ -0,0 +1,152 @@
namespace YMhut.Box.Core.Startup;
public sealed record StartupInitializationStage(
string Id,
string DisplayName,
double Weight,
bool Critical,
Func<StartupInitializationStageContext, CancellationToken, Task> ExecuteAsync);
public sealed record StartupInitializationStageProgress(
string StageId,
string StageName,
int StepIndex,
int StepCount,
double Progress,
string Detail,
StartupCheckSeverity Severity = StartupCheckSeverity.Info,
bool IsRepairStep = false,
bool IsIndeterminate = false);
public sealed class StartupInitializationStageContext
{
private readonly StartupInitializationPipeline _pipeline;
private readonly StartupInitializationStage _stage;
private readonly int _stepIndex;
private readonly int _stepCount;
private readonly double _completedWeight;
private readonly double _totalWeight;
private readonly IProgress<StartupInitializationStageProgress>? _progress;
internal StartupInitializationStageContext(
StartupInitializationPipeline pipeline,
StartupInitializationStage stage,
int stepIndex,
int stepCount,
double completedWeight,
double totalWeight,
IProgress<StartupInitializationStageProgress>? progress)
{
_pipeline = pipeline;
_stage = stage;
_stepIndex = stepIndex;
_stepCount = stepCount;
_completedWeight = completedWeight;
_totalWeight = totalWeight;
_progress = progress;
}
public void Report(
double stageProgress,
string detail,
StartupCheckSeverity severity = StartupCheckSeverity.Info,
bool isRepairStep = false,
bool isIndeterminate = false)
{
var clampedStageProgress = Math.Clamp(stageProgress, 0, 1);
var absolute = _totalWeight <= 0
? 100
: ((_completedWeight + (_stage.Weight * clampedStageProgress)) / _totalWeight) * 100;
_pipeline.Report(
_progress,
new StartupInitializationStageProgress(
_stage.Id,
_stage.DisplayName,
_stepIndex,
_stepCount,
absolute,
detail,
severity,
isRepairStep,
isIndeterminate));
}
}
public sealed class StartupInitializationPipeline
{
private readonly IReadOnlyList<StartupInitializationStage> _stages;
private double _lastProgress;
public StartupInitializationPipeline(IEnumerable<StartupInitializationStage> stages)
{
_stages = stages
.Where(stage => !string.IsNullOrWhiteSpace(stage.Id))
.ToArray();
if (_stages.Count == 0)
{
throw new ArgumentException("Startup initialization pipeline requires at least one stage.", nameof(stages));
}
}
public IReadOnlyList<StartupInitializationStage> Stages => _stages;
public async Task RunAsync(
IProgress<StartupInitializationStageProgress>? progress = null,
CancellationToken cancellationToken = default)
{
_lastProgress = 0;
var totalWeight = _stages.Sum(stage => Math.Max(0.1, stage.Weight));
var completedWeight = 0d;
for (var index = 0; index < _stages.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var stage = _stages[index] with { Weight = Math.Max(0.1, _stages[index].Weight) };
var context = new StartupInitializationStageContext(
this,
stage,
index + 1,
_stages.Count,
completedWeight,
totalWeight,
progress);
context.Report(0, stage.DisplayName, isIndeterminate: false);
try
{
await stage.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
}
catch when (!stage.Critical)
{
context.Report(1, stage.DisplayName, StartupCheckSeverity.Warning);
}
completedWeight += stage.Weight;
context.Report(1, stage.DisplayName);
}
if (_lastProgress < 100)
{
var lastStage = _stages[^1];
Report(
progress,
new StartupInitializationStageProgress(
lastStage.Id,
lastStage.DisplayName,
_stages.Count,
_stages.Count,
100,
lastStage.DisplayName));
}
}
internal void Report(
IProgress<StartupInitializationStageProgress>? progress,
StartupInitializationStageProgress value)
{
var monotonicProgress = Math.Max(_lastProgress, Math.Clamp(value.Progress, 0, 100));
_lastProgress = monotonicProgress;
progress?.Report(value with { Progress = monotonicProgress });
}
}
@@ -0,0 +1,25 @@
using System.Text.Json;
namespace YMhut.Box.Core.Startup;
public sealed record StartupSplashMessage(
string Type,
string Status,
double Progress = 0,
string StageId = "",
string StageName = "",
int StepIndex = 0,
int StepCount = 0,
string? Detail = null,
string Severity = "info",
bool IsRepairStep = false,
bool IsIndeterminate = false,
bool HasIssues = false,
int VisibleIssueCount = 0,
long DurationMs = 0,
string? Theme = null,
bool ReducedMotion = false)
{
public string ToJson()
=> JsonSerializer.Serialize(this, StartupCheckJson.Options);
}
@@ -0,0 +1,174 @@
using System.Runtime.Versioning;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.Core.System;
public sealed record HardwareInfoSummary(
string OsDescription,
string CpuName,
string GpuName,
string TotalMemory,
string PrimaryDisk,
string BoardName,
string WmiStatus,
DateTimeOffset CapturedAt);
public interface IHardwareInfoService
{
Task<HardwareInfoSummary> CaptureAsync(CancellationToken cancellationToken = default);
}
public sealed class HardwareInfoService(ILogService? logService = null) : IHardwareInfoService
{
public async Task<HardwareInfoSummary> CaptureAsync(CancellationToken cancellationToken = default)
{
if (!OperatingSystem.IsWindows())
{
return new HardwareInfoSummary(
global::System.Runtime.InteropServices.RuntimeInformation.OSDescription,
$"{Environment.ProcessorCount} logical processors",
"Unavailable",
"Unavailable",
"Unavailable",
"Unavailable",
"Degraded: WMI is only available on Windows.",
DateTimeOffset.Now);
}
return await CaptureWindowsAsync(cancellationToken).ConfigureAwait(false);
}
[SupportedOSPlatform("windows")]
private async Task<HardwareInfoSummary> CaptureWindowsAsync(CancellationToken cancellationToken)
{
return await Task.Run(async () =>
{
try
{
var summary = new HardwareInfoSummary(
global::System.Runtime.InteropServices.RuntimeInformation.OSDescription,
FirstWmiValue("Win32_Processor", "Name") ?? $"{Environment.ProcessorCount} logical processors",
FirstWmiValue("Win32_VideoController", "Name") ?? "Unknown GPU",
FormatMemory(FirstWmiUlong("Win32_ComputerSystem", "TotalPhysicalMemory")),
BuildDiskSummary(),
BuildBoardSummary(),
"OK",
DateTimeOffset.Now);
await WriteLogAsync("Information", "wmi", "Hardware WMI query succeeded", $"cpu={summary.CpuName}; gpu={summary.GpuName}", cancellationToken).ConfigureAwait(false);
return summary;
}
catch (Exception exception)
{
await WriteLogAsync("Warning", "wmi", "Hardware WMI query degraded", Sanitize(exception.Message), cancellationToken).ConfigureAwait(false);
return new HardwareInfoSummary(
global::System.Runtime.InteropServices.RuntimeInformation.OSDescription,
$"{Environment.ProcessorCount} logical processors",
"Unavailable",
"Unavailable",
"Unavailable",
"Unavailable",
$"Degraded: {Sanitize(exception.Message)}",
DateTimeOffset.Now);
}
}, cancellationToken).ConfigureAwait(false);
}
[SupportedOSPlatform("windows")]
private static string? FirstWmiValue(string className, string propertyName)
{
using var searcher = new global::System.Management.ManagementObjectSearcher($"SELECT {propertyName} FROM {className}");
foreach (global::System.Management.ManagementObject item in searcher.Get())
{
var value = item[propertyName]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
[SupportedOSPlatform("windows")]
private static ulong? FirstWmiUlong(string className, string propertyName)
{
using var searcher = new global::System.Management.ManagementObjectSearcher($"SELECT {propertyName} FROM {className}");
foreach (global::System.Management.ManagementObject item in searcher.Get())
{
if (ulong.TryParse(item[propertyName]?.ToString(), out var value))
{
return value;
}
}
return null;
}
[SupportedOSPlatform("windows")]
private static string BuildDiskSummary()
{
using var searcher = new global::System.Management.ManagementObjectSearcher("SELECT Model, Size FROM Win32_DiskDrive");
var parts = new List<string>();
foreach (global::System.Management.ManagementObject item in searcher.Get())
{
var model = item["Model"]?.ToString()?.Trim();
var size = ulong.TryParse(item["Size"]?.ToString(), out var bytes) ? FormatMemory(bytes) : "Unknown size";
if (!string.IsNullOrWhiteSpace(model))
{
parts.Add($"{model} ({size})");
}
}
return parts.Count == 0 ? "Unknown disk" : string.Join("; ", parts.Take(3));
}
[SupportedOSPlatform("windows")]
private static string BuildBoardSummary()
{
using var searcher = new global::System.Management.ManagementObjectSearcher("SELECT Manufacturer, Product FROM Win32_BaseBoard");
foreach (global::System.Management.ManagementObject item in searcher.Get())
{
var manufacturer = item["Manufacturer"]?.ToString()?.Trim();
var product = item["Product"]?.ToString()?.Trim();
var text = string.Join(" ", new[] { manufacturer, product }.Where(value => !string.IsNullOrWhiteSpace(value)));
if (!string.IsNullOrWhiteSpace(text))
{
return text;
}
}
return "Unknown board";
}
private static string FormatMemory(ulong? bytes)
{
if (bytes is null or 0)
{
return "Unknown";
}
var value = bytes.Value;
var units = new[] { "B", "KB", "MB", "GB", "TB" };
var index = 0;
var size = (double)value;
while (size >= 1024 && index < units.Length - 1)
{
size /= 1024;
index++;
}
return $"{size:0.##} {units[index]}";
}
private Task WriteLogAsync(string level, string category, string message, string? detail, CancellationToken cancellationToken)
{
return logService?.WriteAsync(level, category, message, detail, cancellationToken) ?? Task.CompletedTask;
}
private static string Sanitize(string message)
{
return string.IsNullOrWhiteSpace(message)
? string.Empty
: message.Length > 260 ? message[..260] : message;
}
}
@@ -0,0 +1,257 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
namespace YMhut.Box.Core.System;
public sealed record SystemMetricsSnapshot(
string MachineName,
string OsDescription,
int ProcessorCount,
long WorkingSetBytes,
long ManagedMemoryBytes,
double? CpuUsagePercent,
ulong? TotalMemoryBytes,
ulong? AvailableMemoryBytes,
ulong? UsedMemoryBytes,
double? MemoryUsagePercent,
double? ProcessCpuUsagePercent,
TimeSpan ProcessUptime,
int ProcessThreadCount,
int ProcessHandleCount,
ulong? SystemDriveTotalBytes,
ulong? SystemDriveAvailableBytes,
ulong? SystemDriveUsedBytes,
double? SystemDriveUsagePercent,
bool Is64BitProcess,
string RuntimeDescription,
DateTimeOffset CapturedAt);
public interface ISystemMetricsService
{
SystemMetricsSnapshot Capture();
}
public sealed class SystemMetricsService : ISystemMetricsService
{
private readonly object _cpuLock = new();
private CpuSample? _lastCpuSample;
private readonly object _processCpuLock = new();
private ProcessCpuSample? _lastProcessCpuSample;
public SystemMetricsSnapshot Capture()
{
using var process = Process.GetCurrentProcess();
var capturedAt = DateTimeOffset.Now;
var cpuUsage = CaptureCpuUsage();
var processCpuUsage = CaptureProcessCpuUsage(process, capturedAt);
var memory = CaptureMemoryStatus();
var drive = CaptureSystemDriveStatus();
var processUptime = SafeProcessUptime(process, capturedAt);
return new SystemMetricsSnapshot(
Environment.MachineName,
global::System.Runtime.InteropServices.RuntimeInformation.OSDescription,
Environment.ProcessorCount,
Environment.WorkingSet,
GC.GetTotalMemory(false),
cpuUsage,
memory.Total,
memory.Available,
memory.Used,
memory.UsagePercent,
processCpuUsage,
processUptime,
SafeThreadCount(process),
SafeHandleCount(process),
drive.Total,
drive.Available,
drive.Used,
drive.UsagePercent,
Environment.Is64BitProcess,
global::System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
capturedAt);
}
private double? CaptureCpuUsage()
{
if (!GetSystemTimes(out var idleTime, out var kernelTime, out var userTime))
{
return null;
}
var sample = new CpuSample(ToUInt64(idleTime), ToUInt64(kernelTime), ToUInt64(userTime));
lock (_cpuLock)
{
if (_lastCpuSample is not { } previous)
{
_lastCpuSample = sample;
return null;
}
_lastCpuSample = sample;
var idleDelta = sample.Idle - previous.Idle;
var kernelDelta = sample.Kernel - previous.Kernel;
var userDelta = sample.User - previous.User;
var totalDelta = kernelDelta + userDelta;
if (totalDelta == 0 || idleDelta > totalDelta)
{
return null;
}
return Math.Clamp((double)(totalDelta - idleDelta) / totalDelta * 100, 0, 100);
}
}
private static MemoryStatus CaptureMemoryStatus()
{
var status = new MemoryStatusEx();
status.dwLength = (uint)Marshal.SizeOf<MemoryStatusEx>();
if (!GlobalMemoryStatusEx(ref status) || status.ullTotalPhys == 0)
{
return new MemoryStatus(null, null, null, null);
}
var total = status.ullTotalPhys;
var available = Math.Min(status.ullAvailPhys, total);
var used = total - available;
var percent = Math.Clamp((double)used / total * 100, 0, 100);
return new MemoryStatus(total, available, used, percent);
}
private double? CaptureProcessCpuUsage(Process process, DateTimeOffset capturedAt)
{
TimeSpan totalProcessorTime;
try
{
totalProcessorTime = process.TotalProcessorTime;
}
catch
{
return null;
}
var sample = new ProcessCpuSample(totalProcessorTime, capturedAt);
lock (_processCpuLock)
{
if (_lastProcessCpuSample is not { } previous)
{
_lastProcessCpuSample = sample;
return null;
}
_lastProcessCpuSample = sample;
var elapsed = sample.CapturedAt - previous.CapturedAt;
var cpuDelta = sample.TotalProcessorTime - previous.TotalProcessorTime;
if (elapsed.TotalMilliseconds <= 0 || cpuDelta.TotalMilliseconds < 0)
{
return null;
}
return Math.Clamp(cpuDelta.TotalMilliseconds / (elapsed.TotalMilliseconds * Math.Max(1, Environment.ProcessorCount)) * 100, 0, 100);
}
}
private static DriveStatus CaptureSystemDriveStatus()
{
try
{
var root = Path.GetPathRoot(Environment.SystemDirectory);
if (string.IsNullOrWhiteSpace(root))
{
return new DriveStatus(null, null, null, null);
}
var drive = new DriveInfo(root);
if (!drive.IsReady || drive.TotalSize <= 0)
{
return new DriveStatus(null, null, null, null);
}
var total = (ulong)drive.TotalSize;
var available = (ulong)Math.Clamp(drive.AvailableFreeSpace, 0, drive.TotalSize);
var used = total - available;
var percent = Math.Clamp((double)used / total * 100, 0, 100);
return new DriveStatus(total, available, used, percent);
}
catch
{
return new DriveStatus(null, null, null, null);
}
}
private static TimeSpan SafeProcessUptime(Process process, DateTimeOffset capturedAt)
{
try
{
return capturedAt - new DateTimeOffset(process.StartTime);
}
catch
{
return TimeSpan.Zero;
}
}
private static int SafeThreadCount(Process process)
{
try
{
return process.Threads.Count;
}
catch
{
return 0;
}
}
private static int SafeHandleCount(Process process)
{
try
{
return process.HandleCount;
}
catch
{
return 0;
}
}
private static ulong ToUInt64(FileTime value)
{
return ((ulong)(uint)value.HighDateTime << 32) | (uint)value.LowDateTime;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetSystemTimes(out FileTime idleTime, out FileTime kernelTime, out FileTime userTime);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GlobalMemoryStatusEx(ref MemoryStatusEx buffer);
[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public int LowDateTime;
public int HighDateTime;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct MemoryStatusEx
{
public uint dwLength;
public uint dwMemoryLoad;
public ulong ullTotalPhys;
public ulong ullAvailPhys;
public ulong ullTotalPageFile;
public ulong ullAvailPageFile;
public ulong ullTotalVirtual;
public ulong ullAvailVirtual;
public ulong ullAvailExtendedVirtual;
}
private sealed record CpuSample(ulong Idle, ulong Kernel, ulong User);
private sealed record MemoryStatus(ulong? Total, ulong? Available, ulong? Used, double? UsagePercent);
private sealed record ProcessCpuSample(TimeSpan TotalProcessorTime, DateTimeOffset CapturedAt);
private sealed record DriveStatus(ulong? Total, ulong? Available, ulong? Used, double? UsagePercent);
}
@@ -0,0 +1,309 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.System;
namespace YMhut.Box.Core.Tools;
public interface IBuiltinReferenceToolCatalog
{
IReadOnlyList<BuiltinReferenceToolModule> GetModules();
BuiltinReferenceToolDefinition? GetByModuleId(string moduleId);
}
public interface IBuiltinReferenceToolService
{
Task<ReferenceToolExecutionResult> ExecuteAsync(
BuiltinReferenceToolDefinition tool,
bool confirmed = false,
CancellationToken cancellationToken = default);
}
public sealed class BuiltinReferenceToolCatalog : IBuiltinReferenceToolCatalog
{
private readonly Lazy<IReadOnlyList<BuiltinReferenceToolModule>> _modules = new(() => Definitions
.Select(definition => new BuiltinReferenceToolModule(definition))
.OrderBy(module => module.Metadata.Category)
.ThenBy(module => module.Metadata.Name, StringComparer.CurrentCulture)
.ToArray());
public IReadOnlyList<BuiltinReferenceToolModule> GetModules() => _modules.Value;
public BuiltinReferenceToolDefinition? GetByModuleId(string moduleId)
{
var id = moduleId.StartsWith(BuiltinReferenceToolModule.IdPrefix, StringComparison.OrdinalIgnoreCase)
? moduleId[BuiltinReferenceToolModule.IdPrefix.Length..]
: moduleId;
return Definitions.FirstOrDefault(definition => string.Equals(definition.Id, id, StringComparison.OrdinalIgnoreCase));
}
private static readonly BuiltinReferenceToolDefinition[] Definitions =
[
Tool("cert-block", "证书拦截", "管理证书拦截相关入口,执行前要求确认证书写入或删除风险。", ToolCategory.Security, "\uE72E", BuiltinReferenceToolKind.Command, ReferenceToolRiskLevel.High, "打开证书管理", ["cert", "certificate", "block"]),
Tool("port-viewer", "端口占用", "查看本机 TCP/UDP 端口占用与进程提示。", ToolCategory.Network, "\uE774", BuiltinReferenceToolKind.Command, ReferenceToolRiskLevel.Low, "查看端口占用", ["port", "netstat", "process"]),
Tool("hosts-editor", "Hosts 编辑", "打开系统 Hosts 文件位置,修改前需要管理员权限和二次确认。", ToolCategory.Network, "\uE779", BuiltinReferenceToolKind.SystemEntry, ReferenceToolRiskLevel.High, "打开 Hosts 文件", ["hosts", "dns", "network"]),
Tool("keyboard-test", "键盘测试", "打开 YMhut Box 键盘按键测试入口。", ToolCategory.System, "\uE765", BuiltinReferenceToolKind.Information, ReferenceToolRiskLevel.None, "打开测试说明", ["keyboard", "test"]),
Tool("junk-cleaner", "垃圾清理", "扫描并清理临时文件入口,删除文件前必须再次确认。", ToolCategory.System, "\uE74D", BuiltinReferenceToolKind.Command, ReferenceToolRiskLevel.High, "打开临时目录", ["cleanup", "temp", "delete"]),
Tool("bsod-analysis", "蓝屏分析", "打开 Minidump 目录并提示使用 WinDbg 或事件查看器分析。", ToolCategory.System, "\uE7BA", BuiltinReferenceToolKind.SystemEntry, ReferenceToolRiskLevel.Low, "打开蓝屏转储目录", ["bsod", "dump", "windbg"]),
Tool("winget-installer", "Winget 安装", "打开 Winget 包管理入口,安装软件前写入日志并提示确认。", ToolCategory.System, "\uE896", BuiltinReferenceToolKind.Command, ReferenceToolRiskLevel.Medium, "检查 Winget", ["winget", "install", "package"]),
Tool("battery-report", "电池报告", "生成 Windows battery-report.html 并打开报告。", ToolCategory.System, "\uEBAA", BuiltinReferenceToolKind.Command, ReferenceToolRiskLevel.Low, "生成电池报告", ["battery", "powercfg", "report"]),
Tool("speed-test", "网速测试", "打开系统网络状态与测速说明。", ToolCategory.Network, "\uE968", BuiltinReferenceToolKind.SystemEntry, ReferenceToolRiskLevel.None, "打开网络状态", ["speed", "network", "test"]),
Tool("wifi-password", "WiFi 密码", "查看已保存 WiFi 配置需用户确认,敏感输出不会写入日志。", ToolCategory.Security, "\uE701", BuiltinReferenceToolKind.Command, ReferenceToolRiskLevel.High, "列出 WiFi 配置", ["wifi", "password", "wlan"]),
Tool("disk-space-analyzer", "磁盘分析", "打开 Windows 存储设置并展示磁盘空间分析入口。", ToolCategory.System, "\uEDA2", BuiltinReferenceToolKind.SystemEntry, ReferenceToolRiskLevel.None, "打开存储设置", ["disk", "storage", "space"]),
Tool("lite-monitor", "硬件监控", "通过 WMI/System.Management 读取硬件摘要,失败时降级展示基础信息。", ToolCategory.System, "\uE9D9", BuiltinReferenceToolKind.Information, ReferenceToolRiskLevel.None, "重新加载硬件摘要", ["hardware", "monitor", "wmi"]),
Tool("windows-activation", "Windows 激活状态", "仅提供合规入口:查看激活状态、打开系统激活设置和企业 KMS 配置说明。", ToolCategory.System, "\uE895", BuiltinReferenceToolKind.SystemEntry, ReferenceToolRiskLevel.None, "查看激活状态", ["activation", "license", "kms"]),
Tool("defender-control", "Defender 控制", "打开 Windows 安全中心;修改 Defender 前必须由用户在系统界面确认。", ToolCategory.Security, "\uE72E", BuiltinReferenceToolKind.SystemEntry, ReferenceToolRiskLevel.High, "打开 Windows 安全中心", ["defender", "security"]),
Tool("cpu-ranking", "CPU 天梯图", "打开随包 Metadata/cpu-ranking.json 数据位置和说明。", ToolCategory.System, "\uEEA1", BuiltinReferenceToolKind.Information, ReferenceToolRiskLevel.None, "查看 CPU 数据", ["cpu", "ranking", "hardware"]),
Tool("gpu-ranking", "GPU 天梯图", "打开随包 Metadata/gpu-ranking.json 数据位置和说明。", ToolCategory.System, "\uE9D5", BuiltinReferenceToolKind.Information, ReferenceToolRiskLevel.None, "查看 GPU 数据", ["gpu", "ranking", "hardware"]),
Tool("context-menu-mgr", "右键菜单管理", "打开注册表相关入口,修改右键菜单前必须二次确认。", ToolCategory.System, "\uE8B7", BuiltinReferenceToolKind.SystemEntry, ReferenceToolRiskLevel.High, "打开注册表编辑器", ["context", "menu", "registry"])
];
private static BuiltinReferenceToolDefinition Tool(
string id,
string name,
string description,
ToolCategory category,
string glyph,
BuiltinReferenceToolKind kind,
ReferenceToolRiskLevel risk,
string primaryAction,
IReadOnlyList<string> keywords)
{
return new BuiltinReferenceToolDefinition(id, name, description, category, glyph, kind, risk, keywords, primaryAction, []);
}
}
public sealed class BuiltinReferenceToolService(
ILogService? logService = null,
ISettingsService? settingsService = null,
IHardwareInfoService? hardwareInfoService = null) : IBuiltinReferenceToolService
{
public async Task<ReferenceToolExecutionResult> ExecuteAsync(
BuiltinReferenceToolDefinition tool,
bool confirmed = false,
CancellationToken cancellationToken = default)
{
var start = Stopwatch.StartNew();
if (tool.IsRisky && !confirmed)
{
await WriteLogAsync("Warning", tool.Id, "execute", "confirmation-required", start.ElapsedMilliseconds, null, null, cancellationToken).ConfigureAwait(false);
return ReferenceToolExecutionResult.ConfirmationRequired("此功能需要二次确认后才会执行。", tool.Description);
}
try
{
var result = tool.Id switch
{
"battery-report" => await GenerateBatteryReportAsync(cancellationToken).ConfigureAwait(false),
"port-viewer" => await RunCommandCaptureAsync(tool.Id, "cmd.exe", "/c netstat -ano | more", sensitive: false, cancellationToken).ConfigureAwait(false),
"winget-installer" => await RunCommandCaptureAsync(tool.Id, "winget.exe", "--version", sensitive: false, cancellationToken).ConfigureAwait(false),
"wifi-password" => await RunCommandCaptureAsync(tool.Id, "netsh.exe", "wlan show profiles", sensitive: true, cancellationToken).ConfigureAwait(false),
"lite-monitor" => await CaptureHardwareSummaryAsync(cancellationToken).ConfigureAwait(false),
"windows-activation" => await RunCommandCaptureAsync(tool.Id, "cscript.exe", "//Nologo %windir%\\system32\\slmgr.vbs /xpr", sensitive: false, cancellationToken).ConfigureAwait(false),
"hosts-editor" => OpenPath(GetHostsPath()),
"junk-cleaner" => OpenPath(Path.GetTempPath()),
"bsod-analysis" => OpenPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Minidump")),
"speed-test" => OpenUri("ms-settings:network-status"),
"disk-space-analyzer" => OpenUri("ms-settings:storagesense"),
"defender-control" => OpenUri("windowsdefender:"),
"context-menu-mgr" => OpenProcess("regedit.exe", string.Empty, true),
"cert-block" => OpenProcess("certmgr.msc", string.Empty, false),
"cpu-ranking" => OpenMetadata("cpu-ranking.json"),
"gpu-ranking" => OpenMetadata("gpu-ranking.json"),
"keyboard-test" => ReferenceToolExecutionResult.Ok("键盘测试入口已接入。", "请在 YMhut Box 后续键盘测试子页中按键验证;本次打开已记录日志。"),
_ => ReferenceToolExecutionResult.Fail("暂不支持该内置工具。", tool.Id)
};
if (settingsService is not null && result.Success)
{
await settingsService.RecordRecentToolAsync(BuiltinReferenceToolModule.IdPrefix + tool.Id, cancellationToken).ConfigureAwait(false);
}
await WriteLogAsync(result.Success ? "Information" : result.Cancelled ? "Warning" : "Error", tool.Id, "execute", result.Success ? "success" : result.Cancelled ? "cancelled" : "failed", start.ElapsedMilliseconds, result.Success ? null : result.Message, result.Detail, cancellationToken).ConfigureAwait(false);
return result;
}
catch (Exception exception)
{
await WriteLogAsync("Error", tool.Id, "execute", "failed", start.ElapsedMilliseconds, Sanitize(exception.Message), null, cancellationToken).ConfigureAwait(false);
return ReferenceToolExecutionResult.Fail("内置工具执行失败。", Sanitize(exception.Message));
}
}
private async Task<ReferenceToolExecutionResult> GenerateBatteryReportAsync(CancellationToken cancellationToken)
{
var output = Path.Combine(Path.GetTempPath(), $"ymhut-battery-report-{DateTime.Now:yyyyMMdd-HHmmss}.html");
var result = await RunProcessAsync("powercfg.exe", $"/batteryreport /output \"{output}\"", sensitive: false, cancellationToken).ConfigureAwait(false);
if (result.ExitCode == 0 && File.Exists(output))
{
Process.Start(new ProcessStartInfo { FileName = output, UseShellExecute = true });
return ReferenceToolExecutionResult.Ok("电池报告已生成并打开。", "报告路径已脱敏记录。", output, result.ExitCode);
}
return ReferenceToolExecutionResult.Fail("生成电池报告失败。", result.Output, result.ExitCode);
}
private async Task<ReferenceToolExecutionResult> RunCommandCaptureAsync(string toolId, string fileName, string arguments, bool sensitive, CancellationToken cancellationToken)
{
var result = await RunProcessAsync(fileName, Environment.ExpandEnvironmentVariables(arguments), sensitive, cancellationToken).ConfigureAwait(false);
if (result.ExitCode == 0)
{
var detail = sensitive ? "命令执行成功;敏感输出未写入日志。" : Trim(result.Output, 2000);
return ReferenceToolExecutionResult.Ok("命令执行完成。", detail, exitCode: result.ExitCode);
}
return ReferenceToolExecutionResult.Fail("命令执行失败。", sensitive ? "敏感输出未写入日志。" : Trim(result.Output, 2000), result.ExitCode);
}
private async Task<ReferenceToolExecutionResult> CaptureHardwareSummaryAsync(CancellationToken cancellationToken)
{
if (hardwareInfoService is null)
{
return ReferenceToolExecutionResult.Fail("硬件信息服务不可用。");
}
var summary = await hardwareInfoService.CaptureAsync(cancellationToken).ConfigureAwait(false);
var text = new StringBuilder()
.AppendLine($"OS: {summary.OsDescription}")
.AppendLine($"CPU: {summary.CpuName}")
.AppendLine($"GPU: {summary.GpuName}")
.AppendLine($"Memory: {summary.TotalMemory}")
.AppendLine($"Disk: {summary.PrimaryDisk}")
.AppendLine($"WMI: {summary.WmiStatus}")
.ToString();
return ReferenceToolExecutionResult.Ok("硬件摘要已重新加载。", text);
}
private static ReferenceToolExecutionResult OpenMetadata(string fileName)
{
var path = ResolveMetadataPath(fileName);
if (File.Exists(path))
{
Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true });
return ReferenceToolExecutionResult.Ok("数据文件已打开。", Path.GetFileName(path), path);
}
return ReferenceToolExecutionResult.Fail("未找到随包数据文件。", fileName);
}
private static ReferenceToolExecutionResult OpenPath(string path)
{
if (!File.Exists(path) && !Directory.Exists(path))
{
return ReferenceToolExecutionResult.Fail("目标路径不存在。", path);
}
Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true });
return ReferenceToolExecutionResult.Ok("系统入口已打开。", path);
}
private static ReferenceToolExecutionResult OpenUri(string uri)
{
Process.Start(new ProcessStartInfo { FileName = uri, UseShellExecute = true });
return ReferenceToolExecutionResult.Ok("系统入口已打开。", uri);
}
private static ReferenceToolExecutionResult OpenProcess(string fileName, string arguments, bool runAsAdmin)
{
Process.Start(new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = true,
Verb = runAsAdmin ? "runas" : string.Empty
});
return ReferenceToolExecutionResult.Ok("系统工具已打开。", fileName);
}
private static async Task<(int ExitCode, string Output)> RunProcessAsync(string fileName, string arguments, bool sensitive, CancellationToken cancellationToken)
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
}
};
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
var output = sensitive ? string.Empty : string.Join(Environment.NewLine, [stdout, stderr]).Trim();
return (process.ExitCode, output);
}
private static string GetHostsPath()
{
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "drivers", "etc", "hosts");
}
private static string ResolveMetadataPath(string fileName)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, "Metadata", fileName);
if (File.Exists(candidate))
{
return candidate;
}
candidate = Path.Combine(directory.FullName, "tubatool-参考补充项目", "Metadata", fileName);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
return Path.Combine(AppContext.BaseDirectory, "Metadata", fileName);
}
private Task WriteLogAsync(
string level,
string toolId,
string operation,
string result,
long elapsedMs,
string? error,
string? detail,
CancellationToken cancellationToken)
{
var payload = $"toolId={BuiltinReferenceToolModule.IdPrefix}{toolId}; operation={operation}; result={result}; elapsedMs={elapsedMs}; error={error ?? ""}; detail={Sanitize(detail ?? "")}";
return logService?.WriteAsync(level, "builtin-tool", $"Builtin tool {result}: {toolId}", payload, cancellationToken) ?? Task.CompletedTask;
}
private static string Trim(string? value, int length)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return value.Length > length ? value[..length] : value;
}
private static string Sanitize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var sanitized = value
.Replace(Environment.UserName, "%USER%", StringComparison.OrdinalIgnoreCase)
.Replace(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "%USERPROFILE%", StringComparison.OrdinalIgnoreCase);
return sanitized.Length > 1200 ? sanitized[..1200] : sanitized;
}
}
@@ -0,0 +1,757 @@
using System.Diagnostics;
using System.Drawing;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.Core.Tools;
public interface IExternalToolCatalogService
{
Task<IReadOnlyList<ExternalToolModule>> GetModulesAsync(CancellationToken cancellationToken = default);
string ResolveToolsRoot();
}
public sealed class ExternalToolCatalogService(AppPaths paths, ILogService? logService = null) : IExternalToolCatalogService
{
private static readonly string[] LaunchableExtensions = [".exe", ".bat", ".cmd", ".lnk", ".msc", ".ps1", ".vbs"];
private static readonly string[] ArchSuffixes = ["64", "32", "x64", "x86", "_x64", "_x86", "_64", "_32", "w64", "w32", "_Win64", "_Win32", "ARM64", "_ARM64", "_arm64"];
private static readonly string[] X64Patterns = ["x64", "_x64", "w64", "_Win64"];
private static readonly string[] X86Patterns = ["x86", "_x86", "32", "_32", "w32", "_Win32"];
private static readonly string[] Arm64Patterns = ["ARM64", "_ARM64", "arm64", "_arm64"];
private readonly SemaphoreSlim _gate = new(1, 1);
private IReadOnlyList<ExternalToolModule>? _cached;
public async Task<IReadOnlyList<ExternalToolModule>> GetModulesAsync(CancellationToken cancellationToken = default)
{
if (_cached is not null)
{
return _cached;
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_cached is not null)
{
return _cached;
}
var start = Stopwatch.StartNew();
var root = ResolveToolsRoot();
if (!Directory.Exists(root))
{
await WriteLogAsync("Warning", "tool-scan", "External Tools directory not found", root, cancellationToken).ConfigureAwait(false);
_cached = [];
return _cached;
}
var metadata = ToolMetadataDatabase.Load(ResolveMetadataRoot(root), root, logService);
var modules = Directory.EnumerateDirectories(root)
.SelectMany(categoryRoot => ScanCategory(root, categoryRoot, metadata, cancellationToken))
.GroupBy(module => module.Id, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(module => CategorySortIndex(module.Metadata.Category))
.ThenBy(module => module.Tool.CategoryName, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(module => module.Tool.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
_cached = modules;
start.Stop();
await WriteLogAsync("Information", "tool-scan", "External Tools scanned", $"root={root}; count={modules.Length}; elapsedMs={start.ElapsedMilliseconds}", cancellationToken).ConfigureAwait(false);
return _cached;
}
catch (Exception exception)
{
await WriteLogAsync("Error", "tool-scan", "External Tools scan failed", Sanitize(exception.Message), cancellationToken).ConfigureAwait(false);
_cached = [];
return _cached;
}
finally
{
_gate.Release();
}
}
public string ResolveToolsRoot()
{
foreach (var candidate in CandidateRoots("Tools"))
{
if (Directory.Exists(candidate))
{
return candidate;
}
}
return Path.Combine(AppContext.BaseDirectory, "Tools");
}
private IEnumerable<ExternalToolModule> ScanCategory(string toolsRoot, string categoryRoot, ToolMetadataDatabase metadata, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var categoryName = Path.GetFileName(categoryRoot);
if (string.IsNullOrWhiteSpace(categoryName))
{
yield break;
}
var toolDirs = Directory.EnumerateDirectories(categoryRoot).ToList();
foreach (var toolDir in MergeArchDirectories(toolDirs))
{
cancellationToken.ThrowIfCancellationRequested();
ExternalToolModule? module = null;
try
{
module = CreateModule(toolsRoot, categoryName, categoryRoot, toolDir, metadata);
}
catch (Exception exception)
{
_ = WriteLogAsync("Warning", "tool-scan", "External tool skipped", $"{toolDir}: {Sanitize(exception.Message)}", CancellationToken.None);
}
if (module is not null)
{
yield return module;
}
}
}
private ExternalToolModule? CreateModule(string toolsRoot, string categoryName, string categoryRoot, string toolDir, ToolMetadataDatabase metadata)
{
var launchable = FindPrimaryLaunchable(toolDir, metadata.GetLaunchTarget(toolsRoot, toolDir));
if (launchable is null)
{
return null;
}
var relative = Path.GetRelativePath(toolsRoot, launchable);
var extension = Path.GetExtension(launchable).TrimStart('.').ToUpperInvariant();
var info = metadata.GetMetadata(toolsRoot, launchable, toolDir);
var rawName = GetDisplayName(launchable);
var name = CleanupName(StripArchSuffix(rawName));
if (string.IsNullOrWhiteSpace(name))
{
name = CleanupName(rawName);
}
var arch = FormatArchDisplay(DetectArch(Path.GetFileNameWithoutExtension(launchable)));
var variants = FindAllArchVariants(categoryRoot, toolDir, launchable, metadata);
var risk = DetermineRisk(launchable);
var description = FirstUseful(
info.Description,
ReadFolderDescription(toolDir),
$"{categoryName} 外部工具,来自 YMhut Box 随包 Tools 目录。")
?? $"{categoryName} 外部工具,来自 YMhut Box 随包 Tools 目录。";
var item = new ExternalToolItem(
ExternalToolModule.IdPrefix + Hash(relative),
name,
description,
categoryName,
MapCategory(categoryName),
toolDir,
launchable,
relative,
string.IsNullOrWhiteSpace(extension) ? "FILE" : extension,
info.Publisher,
info.Version,
GetIconGlyph(launchable),
GetCachedOrExtractIcon(launchable),
string.IsNullOrWhiteSpace(arch) ? null : arch,
(info.Tags ?? []).Concat([categoryName, extension]).Where(tag => !string.IsNullOrWhiteSpace(tag)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
variants,
risk,
risk == ReferenceToolRiskLevel.High || LooksAdministrative(launchable));
return new ExternalToolModule(item);
}
private List<ExternalToolVariant> FindAllArchVariants(string categoryRoot, string toolDir, string primaryPath, ToolMetadataDatabase metadata)
{
var variants = new List<ExternalToolVariant>();
var allLaunchables = Directory.EnumerateFiles(toolDir, "*", SearchOption.AllDirectories)
.Where(IsLaunchable)
.ToArray();
foreach (var file in allLaunchables)
{
if (string.Equals(file, primaryPath, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var arch = FormatArchDisplay(DetectArch(Path.GetFileNameWithoutExtension(file)));
if (string.IsNullOrWhiteSpace(arch))
{
continue;
}
variants.Add(new ExternalToolVariant(CleanupName(StripArchSuffix(Path.GetFileNameWithoutExtension(file))), file, arch));
}
foreach (var variant in metadata.GetArchVariants(toolDir))
{
var variantPath = ResolveVariantPath(categoryRoot, toolDir, variant);
if (variantPath is null ||
string.Equals(variantPath, primaryPath, StringComparison.OrdinalIgnoreCase) ||
variants.Any(existing => string.Equals(existing.Path, variantPath, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
variants.Add(new ExternalToolVariant(
CleanupName(StripArchSuffix(Path.GetFileNameWithoutExtension(variantPath))),
variantPath,
variant.Arch));
}
return variants
.OrderBy(variant => variant.Architecture, StringComparer.OrdinalIgnoreCase)
.ThenBy(variant => variant.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
private static string? ResolveVariantPath(string categoryRoot, string toolDir, JsonArchVariantResult variant)
{
if (!string.IsNullOrWhiteSpace(variant.File))
{
var candidate = Path.Combine(toolDir, variant.File);
if (File.Exists(candidate) && IsLaunchable(candidate))
{
return candidate;
}
}
if (!string.IsNullOrWhiteSpace(variant.Dir))
{
var candidateDir = Path.Combine(categoryRoot, variant.Dir);
if (Directory.Exists(candidateDir))
{
return FindPrimaryLaunchable(candidateDir, null);
}
}
return null;
}
private static string? FindPrimaryLaunchable(string toolDir, string? launchTarget)
{
if (!string.IsNullOrWhiteSpace(launchTarget))
{
var directTarget = Path.Combine(toolDir, launchTarget);
if (File.Exists(directTarget) && IsLaunchable(directTarget))
{
return directTarget;
}
var deepTarget = Directory.EnumerateFiles(toolDir, launchTarget, SearchOption.AllDirectories)
.FirstOrDefault(IsLaunchable);
if (deepTarget is not null)
{
return deepTarget;
}
}
var allLaunchables = Directory.EnumerateFiles(toolDir, "*", SearchOption.AllDirectories)
.Where(IsLaunchable)
.ToList();
if (allLaunchables.Count == 0)
{
return null;
}
if (allLaunchables.Count == 1)
{
return allLaunchables[0];
}
var dirName = Path.GetFileName(toolDir);
var directLaunchables = Directory.EnumerateFiles(toolDir).Where(IsLaunchable).ToList();
var exact = directLaunchables.FirstOrDefault(file =>
Path.GetFileNameWithoutExtension(file).Equals(dirName, StringComparison.OrdinalIgnoreCase));
if (exact is not null)
{
return exact;
}
var directArch = directLaunchables
.Where(file => StripArchSuffix(Path.GetFileNameWithoutExtension(file)).Equals(StripArchSuffix(dirName), StringComparison.OrdinalIgnoreCase))
.ToList();
if (directArch.Count > 0)
{
return PickPreferredArch(directArch);
}
var deepArch = allLaunchables
.Where(file => StripArchSuffix(Path.GetFileNameWithoutExtension(file)).Equals(StripArchSuffix(dirName), StringComparison.OrdinalIgnoreCase))
.ToList();
if (deepArch.Count > 0)
{
return PickPreferredArch(deepArch);
}
return directLaunchables.FirstOrDefault() ?? allLaunchables[0];
}
private static List<string> MergeArchDirectories(IReadOnlyList<string> toolDirs)
{
var consumed = new HashSet<int>();
var result = new List<string>();
for (var i = 0; i < toolDirs.Count; i++)
{
if (consumed.Contains(i))
{
continue;
}
var stripped = StripArchSuffix(Path.GetFileName(toolDirs[i]));
result.Add(toolDirs[i]);
for (var j = i + 1; j < toolDirs.Count; j++)
{
if (StripArchSuffix(Path.GetFileName(toolDirs[j])).Equals(stripped, StringComparison.OrdinalIgnoreCase))
{
consumed.Add(j);
}
}
}
return result;
}
private static string PickPreferredArch(IReadOnlyList<string> candidates)
{
if (global::System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == global::System.Runtime.InteropServices.Architecture.Arm64)
{
var arm64 = candidates.FirstOrDefault(path => Arm64Patterns.Any(pattern => Path.GetFileNameWithoutExtension(path).EndsWith(pattern, StringComparison.OrdinalIgnoreCase)));
if (arm64 is not null)
{
return arm64;
}
}
if (Environment.Is64BitOperatingSystem)
{
var x64 = candidates.FirstOrDefault(path => X64Patterns.Any(pattern => Path.GetFileNameWithoutExtension(path).EndsWith(pattern, StringComparison.OrdinalIgnoreCase)));
if (x64 is not null)
{
return x64;
}
}
var x86 = candidates.FirstOrDefault(path => X86Patterns.Any(pattern => Path.GetFileNameWithoutExtension(path).EndsWith(pattern, StringComparison.OrdinalIgnoreCase)));
return x86 ?? candidates[0];
}
private static ReferenceToolRiskLevel DetermineRisk(string path)
{
var extension = Path.GetExtension(path).ToLowerInvariant();
if (extension is ".bat" or ".cmd" or ".ps1" or ".vbs" or ".msc")
{
return ReferenceToolRiskLevel.High;
}
return extension == ".exe" ? ReferenceToolRiskLevel.Medium : ReferenceToolRiskLevel.Low;
}
private static bool LooksAdministrative(string path)
{
var text = $"{Path.GetFileNameWithoutExtension(path)} {Path.GetDirectoryName(path)}";
return text.Contains("defender", StringComparison.OrdinalIgnoreCase) ||
text.Contains("registry", StringComparison.OrdinalIgnoreCase) ||
text.Contains("driver", StringComparison.OrdinalIgnoreCase) ||
text.Contains("clean", StringComparison.OrdinalIgnoreCase) ||
text.Contains("admin", StringComparison.OrdinalIgnoreCase);
}
private string? GetCachedOrExtractIcon(string toolPath)
{
var extension = Path.GetExtension(toolPath);
if (!OperatingSystem.IsWindowsVersionAtLeast(6, 1) ||
(!extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".lnk", StringComparison.OrdinalIgnoreCase)) ||
!File.Exists(toolPath))
{
return null;
}
var cacheRoot = Path.Combine(paths.Cache, "tool-icons");
Directory.CreateDirectory(cacheRoot);
var iconPath = Path.Combine(cacheRoot, $"{Hash(toolPath)}.png");
if (File.Exists(iconPath) && DateTime.UtcNow - File.GetLastWriteTimeUtc(iconPath) < TimeSpan.FromDays(90))
{
return iconPath;
}
try
{
return ExtractIconToCache(toolPath, iconPath);
}
catch (Exception exception)
{
_ = WriteLogAsync("Warning", "icon", "External tool icon extraction failed", $"{Path.GetFileName(toolPath)}: {Sanitize(exception.Message)}", CancellationToken.None);
return null;
}
}
[SupportedOSPlatform("windows6.1")]
private string? ExtractIconToCache(string toolPath, string iconPath)
{
using var icon = Icon.ExtractAssociatedIcon(toolPath);
if (icon is null)
{
return null;
}
using var bitmap = icon.ToBitmap();
bitmap.Save(iconPath, global::System.Drawing.Imaging.ImageFormat.Png);
_ = WriteLogAsync("Information", "icon", "External tool icon extracted", Path.GetFileName(toolPath), CancellationToken.None);
return iconPath;
}
private static string? GetIconGlyph(string toolPath)
{
return Path.GetExtension(toolPath).ToLowerInvariant() switch
{
".bat" or ".cmd" => "\uE756",
".ps1" or ".vbs" => "\uE943",
".msc" => "\uEC7A",
".lnk" => "\uE8A7",
".exe" => null,
_ => "\uE8B7"
};
}
private static ToolCategory MapCategory(string categoryName)
{
if (categoryName.Contains("综合", StringComparison.OrdinalIgnoreCase) ||
categoryName.Contains("其他", StringComparison.OrdinalIgnoreCase))
{
return ToolCategory.System;
}
if (categoryName.Contains("烤鸡", StringComparison.OrdinalIgnoreCase))
{
return ToolCategory.System;
}
if (categoryName.Contains("处理器", StringComparison.OrdinalIgnoreCase) ||
categoryName.Contains("显卡", StringComparison.OrdinalIgnoreCase) ||
categoryName.Contains("硬盘", StringComparison.OrdinalIgnoreCase) ||
categoryName.Contains("内存", StringComparison.OrdinalIgnoreCase) ||
categoryName.Contains("显示器", StringComparison.OrdinalIgnoreCase) ||
categoryName.Contains("外设", StringComparison.OrdinalIgnoreCase))
{
return ToolCategory.System;
}
return ToolCategory.Dev;
}
private IEnumerable<string> CandidateRoots(string folderName)
{
foreach (var root in InstallLayoutPaths.CandidateRoots())
{
yield return Path.Combine(root, folderName);
var directory = new DirectoryInfo(root);
while (directory is not null)
{
yield return Path.Combine(directory.FullName, folderName);
yield return Path.Combine(directory.FullName, "tubatool-参考补充项目", folderName);
directory = directory.Parent;
}
}
}
private string ResolveMetadataRoot(string toolsRoot)
{
foreach (var candidate in CandidateRoots("Metadata"))
{
if (Directory.Exists(candidate))
{
return candidate;
}
}
var parent = Directory.GetParent(toolsRoot)?.Parent?.FullName;
return parent is null ? Path.Combine(AppContext.BaseDirectory, "Metadata") : Path.Combine(parent, "Metadata");
}
private static bool IsLaunchable(string path) => LaunchableExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase);
private static string GetDisplayName(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
return name.Equals("start", StringComparison.OrdinalIgnoreCase)
? Directory.GetParent(path)?.Name ?? name
: name;
}
private static string CleanupName(string name)
{
return name
.Replace("_x64", " x64", StringComparison.OrdinalIgnoreCase)
.Replace("_x86", " x86", StringComparison.OrdinalIgnoreCase)
.Replace("_ARM64", " ARM64", StringComparison.OrdinalIgnoreCase)
.Replace("_arm64", " ARM64", StringComparison.OrdinalIgnoreCase)
.Replace("_", " ", StringComparison.Ordinal)
.Trim();
}
private static string StripArchSuffix(string name)
{
foreach (var suffix in ArchSuffixes)
{
if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return name[..^suffix.Length].TrimEnd('_', '-', ' ');
}
}
return name;
}
private static string? DetectArch(string name)
{
if (Arm64Patterns.Any(pattern => name.EndsWith(pattern, StringComparison.OrdinalIgnoreCase)))
{
return "ARM64";
}
if (X64Patterns.Any(pattern => name.EndsWith(pattern, StringComparison.OrdinalIgnoreCase)))
{
return "x64";
}
if (X86Patterns.Any(pattern => name.EndsWith(pattern, StringComparison.OrdinalIgnoreCase)))
{
return "x86";
}
return null;
}
private static string FormatArchDisplay(string? arch)
{
return arch switch
{
"ARM64" => "ARM64",
"x64" or "Win64" => "x64",
"x86" or "Win32" => "x86",
_ => arch ?? string.Empty
};
}
private static string? ReadFolderDescription(string toolDir)
{
try
{
var readme = Directory.EnumerateFiles(toolDir, "*.*", SearchOption.TopDirectoryOnly)
.FirstOrDefault(path =>
Path.GetFileName(path).Contains("readme", StringComparison.OrdinalIgnoreCase) ||
Path.GetFileName(path).Contains("说明", StringComparison.CurrentCultureIgnoreCase));
return readme is null
? null
: File.ReadLines(readme).FirstOrDefault(line => !string.IsNullOrWhiteSpace(line))?.Trim() is { } line
? line.Length > 180 ? line[..180] : line
: null;
}
catch
{
return null;
}
}
private static string? FirstUseful(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string Hash(string value)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return Convert.ToHexString(bytes)[..16].ToLowerInvariant();
}
private static int CategorySortIndex(ToolCategory category)
{
return category switch
{
ToolCategory.Plugin => 1,
ToolCategory.Dev => 2,
ToolCategory.Network => 3,
ToolCategory.Security => 4,
ToolCategory.Data => 5,
ToolCategory.Calculator => 6,
ToolCategory.Text => 7,
ToolCategory.Image => 8,
ToolCategory.Design => 9,
ToolCategory.Life => 10,
ToolCategory.System => 11,
_ => 0
};
}
private static string Sanitize(string message)
=> message.Length > 240 ? message[..240] : message;
private Task WriteLogAsync(string level, string category, string message, string? detail, CancellationToken cancellationToken)
{
return logService?.WriteAsync(level, category, message, detail, cancellationToken) ?? Task.CompletedTask;
}
private sealed record ToolInfo(
string? Description,
string? Publisher,
string? Version,
IReadOnlyList<string>? Tags);
private sealed record JsonArchVariantResult(string? File, string? Dir, string? Arch);
private sealed class ToolMetadataDatabase
{
private readonly IReadOnlyList<JsonToolMetadata> _items;
private ToolMetadataDatabase(IReadOnlyList<JsonToolMetadata> items)
{
_items = items;
}
public static ToolMetadataDatabase Load(string metadataRoot, string toolsRoot, ILogService? logService)
{
var path = Path.Combine(metadataRoot, "tools.json");
if (!File.Exists(path))
{
return new ToolMetadataDatabase([]);
}
try
{
using var stream = File.OpenRead(path);
var database = JsonSerializer.Deserialize<JsonToolDatabase>(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return new ToolMetadataDatabase(database?.Tools ?? []);
}
catch (Exception exception)
{
_ = logService?.WriteAsync("Warning", "tool-metadata", "External tool metadata load failed", $"{path}: {exception.Message}");
return new ToolMetadataDatabase([]);
}
}
public ToolInfo GetMetadata(string toolsRoot, string launchPath, string toolDir)
{
FileVersionInfo? versionInfo = null;
try
{
if (File.Exists(launchPath))
{
versionInfo = FileVersionInfo.GetVersionInfo(launchPath);
}
}
catch
{
}
var json = FindJsonMetadata(toolsRoot, launchPath, toolDir);
return new ToolInfo(
FirstUseful(json?.Description, versionInfo?.FileDescription, versionInfo?.ProductName),
FirstUseful(json?.Publisher, versionInfo?.CompanyName, versionInfo?.LegalCopyright),
FirstUseful(versionInfo?.ProductVersion, versionInfo?.FileVersion),
json?.Tags);
}
public string? GetLaunchTarget(string toolsRoot, string toolDir)
=> FindJsonMetadataByDir(toolsRoot, toolDir)?.LaunchTarget;
public IReadOnlyList<JsonArchVariantResult> GetArchVariants(string toolDir)
{
var json = FindJsonMetadataByDir(string.Empty, toolDir);
return json?.ArchVariants?
.Select(variant => new JsonArchVariantResult(variant.File, variant.Dir, variant.Arch))
.ToArray() ?? [];
}
private JsonToolMetadata? FindJsonMetadata(string toolsRoot, string launchPath, string toolDir)
{
var fileName = Path.GetFileNameWithoutExtension(launchPath);
var relative = SafeRelative(toolsRoot, launchPath);
var dirName = Path.GetFileName(toolDir);
return _items
.Where(item => !string.IsNullOrWhiteSpace(item.Match) &&
(fileName.Contains(item.Match, StringComparison.CurrentCultureIgnoreCase) ||
relative.Contains(item.Match, StringComparison.CurrentCultureIgnoreCase) ||
MatchesFlexible(dirName, item.Match)))
.OrderByDescending(item => item.Match!.Length)
.FirstOrDefault();
}
private JsonToolMetadata? FindJsonMetadataByDir(string toolsRoot, string toolDir)
{
var dirName = Path.GetFileName(toolDir);
var relative = SafeRelative(toolsRoot, toolDir);
return _items
.Where(item => !string.IsNullOrWhiteSpace(item.Match) &&
(relative.Contains(item.Match, StringComparison.CurrentCultureIgnoreCase) ||
MatchesFlexible(dirName, item.Match)))
.OrderByDescending(item => item.Match!.Length)
.FirstOrDefault();
}
private static string SafeRelative(string root, string path)
{
try
{
return string.IsNullOrWhiteSpace(root) ? path : Path.GetRelativePath(root, path);
}
catch
{
return path;
}
}
private static bool MatchesFlexible(string? source, string match)
{
if (string.IsNullOrWhiteSpace(source))
{
return false;
}
if (source.Contains(match, StringComparison.CurrentCultureIgnoreCase))
{
return true;
}
var normalizedSource = source.Replace(" ", "", StringComparison.Ordinal).Replace("-", "", StringComparison.Ordinal).Replace("_", "", StringComparison.Ordinal);
var normalizedMatch = match.Replace(" ", "", StringComparison.Ordinal).Replace("-", "", StringComparison.Ordinal).Replace("_", "", StringComparison.Ordinal);
return normalizedSource.Contains(normalizedMatch, StringComparison.CurrentCultureIgnoreCase);
}
private sealed class JsonToolDatabase
{
public List<JsonToolMetadata> Tools { get; set; } = [];
}
private sealed class JsonToolMetadata
{
public string? Match { get; set; }
public string? Description { get; set; }
public string? Publisher { get; set; }
public string? LaunchTarget { get; set; }
public List<string>? Tags { get; set; }
public List<JsonArchVariant>? ArchVariants { get; set; }
}
private sealed class JsonArchVariant
{
public string? File { get; set; }
public string? Dir { get; set; }
public string? Arch { get; set; }
}
}
}
@@ -0,0 +1,143 @@
using System.Diagnostics;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Settings;
namespace YMhut.Box.Core.Tools;
public interface IExternalToolLaunchService
{
Task<ReferenceToolExecutionResult> LaunchAsync(
ExternalToolItem tool,
string? launchPath = null,
bool runAsAdministrator = false,
bool confirmed = false,
CancellationToken cancellationToken = default);
Task<ReferenceToolExecutionResult> OpenDirectoryAsync(ExternalToolItem tool, CancellationToken cancellationToken = default);
}
public sealed class ExternalToolLaunchService(
ILogService? logService = null,
ISettingsService? settingsService = null) : IExternalToolLaunchService
{
public async Task<ReferenceToolExecutionResult> LaunchAsync(
ExternalToolItem tool,
string? launchPath = null,
bool runAsAdministrator = false,
bool confirmed = false,
CancellationToken cancellationToken = default)
{
var start = Stopwatch.StartNew();
var target = string.IsNullOrWhiteSpace(launchPath) ? tool.LaunchPath : launchPath;
var operation = runAsAdministrator ? "launch-admin" : "launch";
if (tool.IsRisky && !confirmed)
{
await WriteToolLogAsync("Warning", tool.Id, operation, "confirmation-required", start.ElapsedMilliseconds, null, SafePath(target), cancellationToken).ConfigureAwait(false);
return ReferenceToolExecutionResult.ConfirmationRequired("此工具需要二次确认后才能启动。", $"工具:{tool.Name}\n路径:{SafePath(target)}");
}
try
{
if (!File.Exists(target))
{
await WriteToolLogAsync("Error", tool.Id, operation, "failed", start.ElapsedMilliseconds, "file-not-found", SafePath(target), cancellationToken).ConfigureAwait(false);
return ReferenceToolExecutionResult.Fail("启动文件不存在。", SafePath(target));
}
var extension = Path.GetExtension(target).ToLowerInvariant();
var startInfo = BuildStartInfo(target, extension, runAsAdministrator || tool.RequiresAdministrator);
Process.Start(startInfo);
if (settingsService is not null)
{
await settingsService.RecordRecentToolAsync(tool.Id, cancellationToken).ConfigureAwait(false);
}
await WriteToolLogAsync("Information", tool.Id, operation, "success", start.ElapsedMilliseconds, null, SafePath(target), cancellationToken).ConfigureAwait(false);
return ReferenceToolExecutionResult.Ok("工具已启动。", SafePath(target));
}
catch (Exception exception)
{
await WriteToolLogAsync("Error", tool.Id, operation, "failed", start.ElapsedMilliseconds, Sanitize(exception.Message), SafePath(target), cancellationToken).ConfigureAwait(false);
return ReferenceToolExecutionResult.Fail("工具启动失败。", Sanitize(exception.Message));
}
}
public async Task<ReferenceToolExecutionResult> OpenDirectoryAsync(ExternalToolItem tool, CancellationToken cancellationToken = default)
{
var start = Stopwatch.StartNew();
try
{
if (!Directory.Exists(tool.ToolDirectory))
{
await WriteToolLogAsync("Error", tool.Id, "open-directory", "failed", start.ElapsedMilliseconds, "directory-not-found", SafePath(tool.ToolDirectory), cancellationToken).ConfigureAwait(false);
return ReferenceToolExecutionResult.Fail("工具目录不存在。", SafePath(tool.ToolDirectory));
}
Process.Start(new ProcessStartInfo
{
FileName = tool.ToolDirectory,
UseShellExecute = true
});
await WriteToolLogAsync("Information", tool.Id, "open-directory", "success", start.ElapsedMilliseconds, null, SafePath(tool.ToolDirectory), cancellationToken).ConfigureAwait(false);
return ReferenceToolExecutionResult.Ok("目录已打开。", SafePath(tool.ToolDirectory));
}
catch (Exception exception)
{
await WriteToolLogAsync("Error", tool.Id, "open-directory", "failed", start.ElapsedMilliseconds, Sanitize(exception.Message), SafePath(tool.ToolDirectory), cancellationToken).ConfigureAwait(false);
return ReferenceToolExecutionResult.Fail("打开目录失败。", Sanitize(exception.Message));
}
}
private static ProcessStartInfo BuildStartInfo(string path, string extension, bool runAsAdministrator)
{
if (extension == ".ps1")
{
return new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{path}\"",
WorkingDirectory = Path.GetDirectoryName(path) ?? Environment.CurrentDirectory,
UseShellExecute = true,
Verb = runAsAdministrator ? "runas" : string.Empty
};
}
return new ProcessStartInfo
{
FileName = path,
WorkingDirectory = Path.GetDirectoryName(path) ?? Environment.CurrentDirectory,
UseShellExecute = true,
Verb = runAsAdministrator ? "runas" : string.Empty
};
}
private Task WriteToolLogAsync(
string level,
string toolId,
string operation,
string result,
long elapsedMs,
string? error,
string? detail,
CancellationToken cancellationToken)
{
var payload = $"toolId={toolId}; operation={operation}; result={result}; elapsedMs={elapsedMs}; error={error ?? ""}; detail={detail ?? ""}";
return logService?.WriteAsync(level, "tool-run", $"External tool {result}: {operation}", payload, cancellationToken)
?? Task.CompletedTask;
}
private static string SafePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
var file = Path.GetFileName(path);
var parent = Path.GetFileName(Path.GetDirectoryName(path) ?? string.Empty);
return string.IsNullOrWhiteSpace(parent) ? file : Path.Combine("...", parent, file);
}
private static string Sanitize(string value)
=> value.Length > 260 ? value[..260] : value;
}
+10
View File
@@ -0,0 +1,10 @@
namespace YMhut.Box.Core.Tools;
public interface IToolModule
{
string Id { get; }
ToolMetadata Metadata { get; }
object CreateViewModel();
}
@@ -0,0 +1,238 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using YMhut.Box.Core.Api;
namespace YMhut.Box.Core.Tools;
public sealed record QqProfileResult(
string Qq,
string AvatarUrl,
IReadOnlyDictionary<string, string> Fields,
string SourceName,
bool ProfileAvailable,
string StatusMessage);
public static class QqProfileProvider
{
private const string SourceName = "Uapis QQ User Info";
public static async Task<QqProfileResult> QueryAsync(
string input,
IApiManager? apiManager,
CancellationToken cancellationToken,
string language = "zh-CN")
{
var qq = Regex.Match(input, @"\d{5,12}").Value;
if (string.IsNullOrWhiteSpace(qq))
{
qq = "10000";
}
var avatarUrl = AvatarUrl(qq);
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["QQ"] = qq
};
if (apiManager is null)
{
return new QqProfileResult(
qq,
avatarUrl,
fields,
"qlogo.cn",
false,
T(language, "资料源未配置,已展示默认 QQ 头像。", "Profile source is not configured; default QQ avatar is shown."));
}
try
{
var response = await apiManager.FetchAsync("qq_avatar", qq, cancellationToken).ConfigureAwait(false);
if (!response.Success || string.IsNullOrWhiteSpace(response.Content))
{
return new QqProfileResult(
qq,
avatarUrl,
fields,
SourceName,
false,
response.Error ?? T(language, "资料源未返回可用内容。", "Profile source returned no usable content."));
}
var parsed = ParseProfile(response.Content, qq);
foreach (var pair in parsed.Fields)
{
fields[pair.Key] = pair.Value;
}
if (IsAbsoluteHttpUrl(parsed.AvatarUrl))
{
avatarUrl = parsed.AvatarUrl;
}
if (!fields.ContainsKey("头像") && IsAbsoluteHttpUrl(avatarUrl))
{
fields["头像"] = avatarUrl;
}
var available = fields.Count > 1;
return new QqProfileResult(
qq,
avatarUrl,
fields,
SourceName,
available,
available
? T(language, "资料字段来自新版 QQ 信息源;请求地址已隐藏,响应中的头像链接可展示。", "Profile fields came from the new QQ information source; request URL is hidden and returned avatar links can be displayed.")
: T(language, "资料源未返回昵称、签名、等级或会员信息。", "Profile source did not return nickname, signature, level, or membership fields."));
}
catch (Exception exception)
{
return new QqProfileResult(qq, avatarUrl, fields, SourceName, false, SensitiveText.Sanitize(exception.Message));
}
}
public static string AvatarUrl(string qq) => $"https://q1.qlogo.cn/g?b=qq&nk={Uri.EscapeDataString(qq)}&s=640";
private static ParsedProfile ParseProfile(string content, string qq)
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var avatarUrl = string.Empty;
var text = content.Trim();
if (text.Contains("portraitCallBack", StringComparison.OrdinalIgnoreCase))
{
var nicknameMatch = Regex.Match(text, @$"""{Regex.Escape(qq)}""\s*:\s*\[[^\]]*?""(?<nickname>[^""]+)""");
if (nicknameMatch.Success)
{
fields["昵称"] = nicknameMatch.Groups["nickname"].Value;
}
return new ParsedProfile(fields, avatarUrl);
}
try
{
using var document = JsonDocument.Parse(text);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("data", out var data) &&
data.ValueKind == JsonValueKind.Object)
{
root = data;
}
AddIfPresent(fields, root, "QQ", "qq", "uin");
AddIfPresent(fields, root, "用户 ID", "user_id", "userId");
AddIfPresent(fields, root, "昵称", "nickname", "nick", "name");
AddIfPresent(fields, root, "个性签名", "long_nick", "longNick", "signature", "sign");
AddIfPresent(fields, root, "头像", "avatar_url", "avatarUrl", "avatar", "headimgurl", "figureurl");
AddIfPresent(fields, root, "年龄", "age");
AddIfPresent(fields, root, "性别", "sex", "gender");
AddIfPresent(fields, root, "QID", "qid");
AddIfPresent(fields, root, "QQ 等级", "qq_level", "qqLevel", "level");
AddIfPresent(fields, root, "QQ 等级图标", "qq_level_icons", "qqLevelIcons");
AddIfPresent(fields, root, "位置", "location", "region", "area", "province", "city");
AddIfPresent(fields, root, "邮箱", "email");
AddIfPresent(fields, root, "VIP", "is_vip", "vip");
AddIfPresent(fields, root, "年费 VIP", "is_years_vip", "years_vip", "isYearsVip");
AddIfPresent(fields, root, "SVIP", "is_svip", "svip", "isSvip");
AddIfPresent(fields, root, "QQ 大会员", "is_big_club", "big_club", "isBigClub");
AddIfPresent(fields, root, "会员状态", "vip_status", "vipStatus");
AddIfPresent(fields, root, "会员类型", "vip_type", "vipType");
AddIfPresent(fields, root, "VIP 等级", "vip_level", "vipLevel");
AddIfPresent(fields, root, "QQ 大会员等级", "big_club_level", "bigClubLevel");
AddIfPresent(fields, root, "黄钻等级", "yellow_diamond_level", "yellowDiamondLevel");
AddIfPresent(fields, root, "绿钻等级", "green_diamond_level", "greenDiamondLevel");
AddIfPresent(fields, root, "腾讯视频会员等级", "video_vip_level", "videoVipLevel");
AddIfPresent(fields, root, "情侣会员等级", "lover_vip_level", "loverVipLevel");
AddIfPresent(fields, root, "注册时间", "reg_time", "regTime", "registerTime", "registrationTime", "createdAt");
AddIfPresent(fields, root, "最后更新时间", "last_updated", "lastUpdated", "updatedAt");
if (TryFind(root, "privilege_icons", out var icons) && !string.IsNullOrWhiteSpace(icons))
{
fields["特权图标"] = icons;
}
if (fields.TryGetValue("头像", out var parsedAvatar) && IsAbsoluteHttpUrl(parsedAvatar))
{
avatarUrl = parsedAvatar;
}
}
catch
{
}
return new ParsedProfile(fields, avatarUrl);
}
private static void AddIfPresent(Dictionary<string, string> fields, JsonElement root, string label, params string[] names)
{
foreach (var name in names)
{
if (TryFind(root, name, out var value) && !string.IsNullOrWhiteSpace(value))
{
fields[label] = value;
return;
}
}
}
private static bool TryFind(JsonElement element, string name, out string value)
{
value = string.Empty;
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var property in element.EnumerateObject())
{
if (property.NameEquals(name))
{
value = StringValue(property.Value);
return true;
}
if (TryFind(property.Value, name, out value))
{
return true;
}
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
if (TryFind(item, name, out value))
{
return true;
}
}
}
return false;
}
private static string StringValue(JsonElement value)
{
return value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Number => value.GetRawText(),
JsonValueKind.True => "是",
JsonValueKind.False => "否",
JsonValueKind.Null => string.Empty,
JsonValueKind.Object or JsonValueKind.Array => JsonSerializer.Serialize(value),
_ => value.GetRawText()
};
}
private static bool IsAbsoluteHttpUrl(string value)
{
return Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
uri.Scheme is "http" or "https";
}
private static bool English(string? language) => string.Equals(language, "en-US", StringComparison.OrdinalIgnoreCase);
private static string T(string? language, string zh, string en) => English(language) ? en : zh;
private sealed record ParsedProfile(IReadOnlyDictionary<string, string> Fields, string AvatarUrl);
}
@@ -0,0 +1,135 @@
namespace YMhut.Box.Core.Tools;
public enum ReferenceToolRiskLevel
{
None,
Low,
Medium,
High
}
public enum BuiltinReferenceToolKind
{
Information,
Dialog,
Command,
SystemEntry,
ExternalHelper
}
public sealed record ExternalToolVariant(
string Name,
string Path,
string? Architecture);
public sealed record ExternalToolItem(
string Id,
string Name,
string Description,
string CategoryName,
ToolCategory Category,
string ToolDirectory,
string LaunchPath,
string RelativePath,
string Extension,
string? Publisher,
string? Version,
string? IconGlyph,
string? IconPath,
string? PrimaryArchitecture,
IReadOnlyList<string> Tags,
IReadOnlyList<ExternalToolVariant> Variants,
ReferenceToolRiskLevel RiskLevel,
bool RequiresAdministrator)
{
public bool IsRisky => RiskLevel != ReferenceToolRiskLevel.None || RequiresAdministrator;
}
public sealed class ExternalToolModule(ExternalToolItem tool) : IToolModule
{
public const string IdPrefix = "external:";
public ExternalToolItem Tool { get; } = tool;
public string Id => Tool.Id;
public ToolMetadata Metadata { get; } = new(
tool.Id,
tool.Name,
tool.Description,
tool.Category,
tool.Tags.Concat(["external", "tools", tool.CategoryName, tool.Extension]).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
true,
string.IsNullOrWhiteSpace(tool.IconGlyph) ? "\uE8B7" : tool.IconGlyph);
public object CreateViewModel() => new ToolModuleViewModel(Metadata);
public static bool IsExternalToolId(string id) => id.StartsWith(IdPrefix, StringComparison.OrdinalIgnoreCase);
}
public sealed record BuiltinReferenceToolDefinition(
string Id,
string Name,
string Description,
ToolCategory Category,
string Glyph,
BuiltinReferenceToolKind Kind,
ReferenceToolRiskLevel RiskLevel,
IReadOnlyList<string> Keywords,
string PrimaryAction,
IReadOnlyList<string> SecondaryActions)
{
public bool IsRisky => RiskLevel != ReferenceToolRiskLevel.None;
}
public sealed class BuiltinReferenceToolModule(BuiltinReferenceToolDefinition definition) : IToolModule
{
public const string IdPrefix = "builtin:";
public BuiltinReferenceToolDefinition Definition { get; } = definition;
public string Id { get; } = $"{IdPrefix}{definition.Id}";
public ToolMetadata Metadata { get; } = new(
$"{IdPrefix}{definition.Id}",
definition.Name,
definition.Description,
definition.Category,
definition.Keywords.Concat(["builtin", "reference", "ymhut", definition.Id]).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
true,
definition.Glyph);
public object CreateViewModel() => new ToolModuleViewModel(Metadata);
public static bool IsBuiltinToolId(string id) => id.StartsWith(IdPrefix, StringComparison.OrdinalIgnoreCase);
}
public sealed record ToolExecutionLogDetail(
string ToolId,
string Operation,
string Result,
long ElapsedMilliseconds,
string? ErrorSummary = null,
string? Detail = null);
public sealed record ReferenceToolExecutionResult(
bool Success,
bool Cancelled,
bool RequiresConfirmation,
string Message,
string? Detail = null,
string? OutputPath = null,
int? ExitCode = null)
{
public static ReferenceToolExecutionResult Ok(string message, string? detail = null, string? outputPath = null, int? exitCode = null)
=> new(true, false, false, message, detail, outputPath, exitCode);
public static ReferenceToolExecutionResult Fail(string message, string? detail = null, int? exitCode = null)
=> new(false, false, false, message, detail, null, exitCode);
public static ReferenceToolExecutionResult Cancel(string message, string? detail = null)
=> new(false, true, false, message, detail);
public static ReferenceToolExecutionResult ConfirmationRequired(string message, string? detail = null)
=> new(false, false, true, message, detail);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.Core.Tools;
public interface IRiskConfirmationStore
{
Task<bool> IsRememberedAsync(string toolId, string operation, CancellationToken cancellationToken = default);
Task RememberAsync(string toolId, string operation, CancellationToken cancellationToken = default);
Task ResetAsync(CancellationToken cancellationToken = default);
}
public sealed class RiskConfirmationStore(AppPaths paths, ILogService? logService = null) : IRiskConfirmationStore
{
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
private string StorePath => Path.Combine(paths.Data, "risk-confirmations.json");
public async Task<bool> IsRememberedAsync(string toolId, string operation, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var store = await LoadAsync(cancellationToken).ConfigureAwait(false);
return store.Items.Any(item =>
string.Equals(item.ToolId, toolId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(item.Operation, operation, StringComparison.OrdinalIgnoreCase));
}
finally
{
_gate.Release();
}
}
public async Task RememberAsync(string toolId, string operation, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(toolId) || string.IsNullOrWhiteSpace(operation))
{
return;
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var store = await LoadAsync(cancellationToken).ConfigureAwait(false);
store.Items.RemoveAll(item =>
string.Equals(item.ToolId, toolId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(item.Operation, operation, StringComparison.OrdinalIgnoreCase));
store.Items.Add(new RiskConfirmationItem(toolId, operation, DateTimeOffset.Now));
await SaveAsync(store, cancellationToken).ConfigureAwait(false);
await WriteLogAsync("Information", "risk", "Risk confirmation remembered", $"{toolId} / {operation}", cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task ResetAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await SaveAsync(new RiskConfirmationFile(), cancellationToken).ConfigureAwait(false);
await WriteLogAsync("Information", "risk", "Risk confirmations reset", null, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private async Task<RiskConfirmationFile> LoadAsync(CancellationToken cancellationToken)
{
try
{
if (!File.Exists(StorePath))
{
return new RiskConfirmationFile();
}
await using var stream = File.OpenRead(StorePath);
return await JsonSerializer.DeserializeAsync<RiskConfirmationFile>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false)
?? new RiskConfirmationFile();
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
{
await WriteLogAsync("Warning", "risk", "Risk confirmation store degraded", exception.Message, cancellationToken).ConfigureAwait(false);
return new RiskConfirmationFile();
}
}
private async Task SaveAsync(RiskConfirmationFile store, CancellationToken cancellationToken)
{
Directory.CreateDirectory(paths.Data);
var tempPath = StorePath + ".tmp";
await using (var stream = File.Create(tempPath))
{
await JsonSerializer.SerializeAsync(stream, store, _jsonOptions, cancellationToken).ConfigureAwait(false);
}
if (File.Exists(StorePath))
{
try
{
File.Replace(tempPath, StorePath, null);
return;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
await WriteLogAsync("Warning", "risk", "Atomic risk confirmation save degraded", exception.Message, cancellationToken).ConfigureAwait(false);
File.Delete(StorePath);
}
}
File.Move(tempPath, StorePath);
}
private Task WriteLogAsync(string level, string category, string message, string? detail, CancellationToken cancellationToken)
{
return logService?.WriteAsync(level, category, message, detail, cancellationToken) ?? Task.CompletedTask;
}
private sealed class RiskConfirmationFile
{
public List<RiskConfirmationItem> Items { get; set; } = [];
}
private sealed record RiskConfirmationItem(
string ToolId,
string Operation,
DateTimeOffset RememberedAt);
}
+302
View File
@@ -0,0 +1,302 @@
namespace YMhut.Box.Core.Tools;
public sealed class ToolCatalog
{
private readonly List<IToolModule> _modules;
public ToolCatalog(IEnumerable<IToolModule>? modules = null)
{
var provided = modules?.ToList();
_modules = provided is { Count: > 0 } ? provided : DefaultModules().ToList();
}
public IReadOnlyList<IToolModule> Modules => _modules;
public IEnumerable<IToolModule> Search(string query, ToolCategory category = ToolCategory.All)
{
var normalized = query.Trim();
var source = category == ToolCategory.All
? _modules
: _modules.Where(module => module.Metadata.Category == category);
if (string.IsNullOrWhiteSpace(normalized))
{
return source;
}
return source.Where(module =>
module.Id.Contains(normalized, StringComparison.OrdinalIgnoreCase) ||
module.Metadata.Name.Contains(normalized, StringComparison.OrdinalIgnoreCase) ||
module.Metadata.Description.Contains(normalized, StringComparison.OrdinalIgnoreCase) ||
module.Metadata.Keywords.Any(keyword => keyword.Contains(normalized, StringComparison.OrdinalIgnoreCase)));
}
public IToolModule? GetById(string id)
{
return _modules.FirstOrDefault(module => string.Equals(module.Id, id, StringComparison.OrdinalIgnoreCase));
}
public static IEnumerable<IToolModule> DefaultModules()
{
return RawToolData
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(ParseModule)
.Concat([DevEnvironmentConfigModule()])
.OrderBy(module => CategorySortIndex(module.Metadata.Category))
.ThenBy(module => module.Metadata.Name, StringComparer.CurrentCulture);
}
private static IToolModule DevEnvironmentConfigModule()
{
return new ToolModule(new ToolMetadata(
"dev_environment_config",
"开发环境配置",
"检测本机开发环境,并从官方来源下载、安装或按源码配方构建开发工具。",
ToolCategory.Dev,
["go", "python", "java", "jdk", "docker", "mysql", "node", "dotnet", "git", "rust", "cmake", "download", "install"],
false,
IconForCategory(ToolCategory.Dev)));
}
private static IToolModule ParseModule(string line)
{
var parts = line.Split('\t');
var id = parts.ElementAtOrDefault(0) ?? string.Empty;
var name = parts.ElementAtOrDefault(1) ?? id;
var description = parts.ElementAtOrDefault(2) ?? string.Empty;
var category = ParseCategory(parts.ElementAtOrDefault(3));
var offline = bool.TryParse(parts.ElementAtOrDefault(4), out var parsedOffline) && parsedOffline;
var keywords = (parts.ElementAtOrDefault(5) ?? string.Empty)
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return new ToolModule(new ToolMetadata(id, name, description, category, keywords, offline, IconForCategory(category)));
}
private static ToolCategory ParseCategory(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
"dev" => ToolCategory.Dev,
"plugin" => ToolCategory.Plugin,
"network" => ToolCategory.Network,
"security" => ToolCategory.Security,
"data" => ToolCategory.Data,
"calculator" => ToolCategory.Calculator,
"text" => ToolCategory.Text,
"image" => ToolCategory.Image,
"design" => ToolCategory.Design,
"life" => ToolCategory.Life,
"system" => ToolCategory.System,
_ => ToolCategory.Dev
};
}
private static int CategorySortIndex(ToolCategory category)
{
return category switch
{
ToolCategory.Plugin => 1,
ToolCategory.Dev => 2,
ToolCategory.Network => 3,
ToolCategory.Security => 4,
ToolCategory.Data => 5,
ToolCategory.Calculator => 6,
ToolCategory.Text => 7,
ToolCategory.Image => 8,
ToolCategory.Design => 9,
ToolCategory.Life => 10,
ToolCategory.System => 11,
_ => 0,
};
}
private static string IconForCategory(ToolCategory category)
{
return category switch
{
ToolCategory.Plugin => "\uECAA",
ToolCategory.Dev => "\uE943",
ToolCategory.Network => "\uE774",
ToolCategory.Security => "\uE72E",
ToolCategory.Data => "\uE9D9",
ToolCategory.Calculator => "\uE8EF",
ToolCategory.Text => "\uE8D2",
ToolCategory.Image => "\uEB9F",
ToolCategory.Design => "\uE790",
ToolCategory.Life => "\uE753",
ToolCategory.System => "\uE950",
_ => "\uE8FD",
};
}
private const string RawToolData = """
html_js_playground HTML/JS HTMLCSSJS dev false html;js;webview;plugin;playground
json_formatter JSON JSON dev true json;format;pretty;minify
base64_codec Base64 Base64 UTF-8 dev true base64;encode;decode
compression_codec GZipDeflateBrotli Base64 dev true compress;gzip;deflate;brotli;base64
url_codec URL URL dev true url;encode;decode
html_entity HTML HTML dev true html;entity;escape
html_minifier HTML/ HTMLCSS JavaScript dev true html;css;js;minify
regex_tool dev true regex;regular expression
js_obfuscator JS JavaScript dev true javascript;obfuscate
uuid_generator UUID UUID/GUID dev true uuid;guid
ulid_generator ULID ULID dev true ulid;id
timestamp_converter Unix dev true timestamp;date;time
number_base dev true base;binary;hex
number_base_converter dev true base;converter;binary;hex
csv_json_converter CSV/JSON CSV JSON dev true csv;json;table
column_extractor dev true column;csv;extract
csv_tsv_converter CSV/TSV CSV TSV dev true csv;tsv
yaml_json_converter YAML/JSON JSON dev true yaml;json
xml_formatter XML XML dev true xml;format
sql_formatter SQL SQL dev true sql;format
unicode_codec Unicode Unicode dev true unicode;escape
punycode_codec Punycode/IDN ASCII dev true punycode;idn
jwt_decoder JWT JWT dev true jwt;token
query_builder Query URL Query dev true query;url;params
form_urlencoded_builder x-www-form-urlencoded dev true form;urlencoded
key_value_converter JSONENVQuery dev true kv;json;env
cron_helper Cron Cron dev true cron;schedule
unicode_block_lookup Unicode Unicode dev true unicode;block
charset_lookup dev true charset;encoding
regex_preset_lookup dev true regex;preset
magic_number_lookup dev true magic number;file header
html_text_extractor HTML dev true html;text;extract
slug_generator Slug URL slug dev true slug;url
markdown_table_normalizer Markdown Markdown text true markdown;table
markdown_preview Markdown Markdown text true markdown;preview;commonmark
json_path_helper JSON JSON dev true json;path
hash_generator MD5SHA1SHA256 SHA512 security true hash;md5;sha
hmac_generator HMAC HMAC security true hmac;hash
totp_generator TOTP Base32 security true totp;otp;2fa;mfa;base32
password_generator security true password;random
profanity_check security true profanity;check
hash_manifest_builder SHA256 security true hash;manifest
hash_manifest_verify security true hash;verify
safe_browser WebView2 network false browser;webview
wx_domain_check security false wechat;domain
ip_lookup IP IP network false ip;location
ip_info IP/ IP network false ip;domain
dns_query DNS AMXTXT DNS network false dns;record
domain_price RDAP network false domain;rdap;whois
smart_search network false search;ai
rdap_domain_lookup RDAP RDAP network false rdap;domain
rdap_ip_lookup IP/ASN RDAP RDAP IP ASN network false rdap;ip;asn
http_diagnostic HTTP URL network false http;diagnostic
url_redirect_trace URL URL network false url;redirect;trace;http
url_inspector URL URL network true url;inspect
domain_extractor URL network true domain;extract
cidr_subnet_calculator CIDR IPv4 CIDR network true cidr;subnet
mime_lookup MIME MIME network true mime
http_status_lookup HTTP HTTP network true http;status
port_lookup network true port;service
dns_record_lookup DNS DNS network true dns;record
weather network false weather;city
hotboard data false hot;news
bili_hot B data false bilibili;hot
baidu_hot data false baidu;hot
zhihu_hot data false zhihu;hot
cctv_news data false news;cctv
tech_news data false news;tech
football_news data false football;sports
movie_box_office data false movie;box office
ai_latest_news AI AI data false ai;news;latest
earthquake_info data false earthquake
gold_price data false gold;price
oil_price data false oil;price
train_query data false train;railway
history_today data false history;today
city_route_query 线 data false city;route;travel;cost
car_info data true car;reference
sanguosha_skin data true sanguosha;skin
calculator_tool calculator true calculator;math
unit_converter calculator true unit;convert
bmi_calculator BMI BMI calculator true bmi;health
percentage_calculator calculator true percent
percentage_change_calculator calculator true percent;change
discount_calculator calculator true discount;price
tax_calculator calculator true tax;vat
ratio_simplifier calculator true ratio;gcd
average_calculator calculator true average;mean
median_calculator calculator true median;statistics
gcd_lcm_calculator / GCD LCM calculator true gcd;lcm
permutation_calculator P(n,r) calculator true permutation
combination_calculator C(n,r) calculator true combination
date_difference_calculator life true date;difference
workday_calculator life true workday;calendar
age_calculator life true age;birthday
simple_interest_calculator calculator true interest;simple
compound_interest_calculator calculator true interest;compound
loan_emi_calculator calculator true loan;emi
length_converter calculator true length;unit
weight_converter calculator true weight;mass
temperature_converter calculator true temperature
speed_converter calculator true speed
area_converter calculator true area
volume_converter calculator true volume
data_size_converter BKBMBGBTB calculator true data size
time_duration_converter calculator true duration
text_statistics text true text;statistics
case_converter text true case
chinese_converter text true chinese
text_diff text true diff
random_generator text true random
ai_translation AI AI text false translate;ai
ymhut_uutool_suite UU text true uutool;suite
text_deduplicator text true dedupe
text_sorter text true sort
text_filter text true filter
delimiter_converter text true delimiter
filename_formatter text true filename
text_splitter text true split
text_merger text true merge
text_numbering text true numbering
blank_line_cleaner text true blank line
whitespace_normalizer text true whitespace
prefix_suffix_batch text true prefix;suffix
line_column_transformer text true line;column
list_sampler text true sample
list_chunker text true chunk
list_intersection text true intersection
list_union text true union
list_difference text true difference
list_group_statistics text true group;count
duplicate_detector text true duplicate
line_length_analyzer text true line length
keyword_counter text true keyword;count
template_filler text true template
path_normalizer text true path;normalize
path_breakdown text true path
extension_extractor text true extension
extension_statistics text true extension;statistics
file_manifest_builder text true manifest
batch_rename_preview text true rename
indexed_rename_preview text true rename;index
manifest_deduplicator text true manifest;dedupe
manifest_sorter text true manifest;sort
manifest_template_export JSONCSVTSV text true manifest;template
text_trimmer text true trim
text_reverser text true reverse
markdown_list_cleaner Markdown Markdown text true markdown;list
line_pair_zipper text true zip;pair
ascii_art ASCII ASCII design true ascii;art
color_picker design true color;hex;rgb
color_contrast_checker WCAG design true color;contrast;wcag;accessibility
qr_generator image true qr;qrcode
qrcode_scanner image true qr;scan
archive_tool zip/tar image true archive;zip;tar
image_processor image true image;resize
image_metadata_inspector image true image;metadata;exif;dimensions
media_player image false media;player
qq_avatar QQ QQ life false qq;avatar;profile
random_cinema image false movie;random;image;video
shelf_life life true shelf life
timezone_abbr_lookup UTC life true timezone;utc
system_info CPU system true system;info
system_tool system true system;tool
serial_terminal COM HEX system false serial;com;uart;terminal
pc_benchmark system true benchmark
""";
}
+17
View File
@@ -0,0 +1,17 @@
namespace YMhut.Box.Core.Tools;
public enum ToolCategory
{
All,
Plugin,
Dev,
Network,
Security,
Data,
Calculator,
Text,
Image,
Design,
Life,
System
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,309 @@
using System.Text;
using System.Text.Json;
namespace YMhut.Box.Core.Tools;
public sealed record ToolMarkdownDocument(
string ToolId,
ToolResultKind ResultKind,
string MarkdownText,
string RawText,
string Language,
IReadOnlyDictionary<string, string> Metadata);
public static class ToolMarkdownConverter
{
public static ToolMarkdownDocument FromResult(IToolModule module, ToolExecutionResult result, string language)
{
var raw = result.Ok ? result.Output : result.Error ?? string.Empty;
var document = result.Document ?? ToolResultDocument.FromOutput(module, raw);
var metadata = new Dictionary<string, string>(document.Metadata, StringComparer.OrdinalIgnoreCase)
{
["ok"] = result.Ok.ToString(),
["language"] = language
};
return new ToolMarkdownDocument(
module.Id,
document.ResultKind,
BuildMarkdown(module, document, raw, language),
raw,
language,
metadata);
}
private static string BuildMarkdown(IToolModule module, ToolResultDocument document, string raw, string language)
{
if (string.IsNullOrWhiteSpace(raw))
{
return T(language, "_暂无可展示的结果。_", "_There is no result to display._");
}
if (document.ResultKind == ToolResultKind.JsonTree || LooksLikeJson(raw))
{
return JsonToMarkdown(raw, language);
}
if (document.Blocks.Count == 0)
{
return PlainTextToMarkdown(raw);
}
var builder = new StringBuilder();
builder.AppendLine($"## {EscapeInline(module.Metadata.Name)}");
builder.AppendLine();
foreach (var block in document.Blocks)
{
AppendBlock(builder, block, language);
}
return builder.ToString().Trim();
}
private static void AppendBlock(StringBuilder builder, ToolResultBlock block, string language)
{
switch (block.Kind)
{
case ToolResultBlockKind.KeyValue:
case ToolResultBlockKind.Metric:
AppendHeading(builder, block.Title, T(language, "详情", "Details"));
AppendTable(builder, [T(language, "字段", "Field"), T(language, "值", "Value")], block.Pairs.Select(pair => new[] { pair.Key, pair.Value }));
break;
case ToolResultBlockKind.Table:
case ToolResultBlockKind.LineChart:
AppendHeading(builder, block.Title, T(language, "表格", "Table"));
AppendRowsAsTable(builder, block.Rows, language);
break;
case ToolResultBlockKind.CardList:
case ToolResultBlockKind.RankedList:
case ToolResultBlockKind.NewsList:
AppendHeading(builder, block.Title, T(language, "列表", "List"));
foreach (var item in block.Items)
{
builder.AppendLine($"{NormalizeOrdinal(item.Leading)}. {EscapeInline(item.Title)}{Subtitle(item.Subtitle)}");
}
builder.AppendLine();
break;
case ToolResultBlockKind.Timeline:
AppendHeading(builder, block.Title, T(language, "时间线", "Timeline"));
foreach (var item in block.Items)
{
builder.AppendLine($"- **{EscapeInline(item.Title)}**{Subtitle(item.Subtitle)}");
}
builder.AppendLine();
break;
case ToolResultBlockKind.Diff:
AppendCode(builder, block.Title, string.Join(Environment.NewLine, block.Items.Select(item => item.Title)), "diff");
break;
case ToolResultBlockKind.Status:
case ToolResultBlockKind.Text:
AppendHeading(builder, block.Title, T(language, "内容", "Content"));
foreach (var item in block.Items)
{
builder.AppendLine($"- {EscapeInline(item.Title)}{Subtitle(item.Subtitle)}");
}
builder.AppendLine();
break;
case ToolResultBlockKind.Code:
case ToolResultBlockKind.Json:
case ToolResultBlockKind.JsonTree:
case ToolResultBlockKind.Raw:
AppendCode(builder, block.Title, block.Text, block.Language);
break;
case ToolResultBlockKind.Link:
builder.AppendLine($"- [{EscapeInline(string.IsNullOrWhiteSpace(block.Text) ? block.Uri : block.Text)}]({block.Uri})");
break;
case ToolResultBlockKind.File:
builder.AppendLine($"- **{T(language, "", "File")}**: `{EscapeCode(block.Path)}`");
break;
case ToolResultBlockKind.Image:
case ToolResultBlockKind.Media:
builder.AppendLine($"- **{T(language, "", "Media")}**: [{EscapeInline(block.Text)}]({block.Uri})");
break;
case ToolResultBlockKind.Color:
builder.AppendLine($"- **{T(language, "", "Color")}**: `{EscapeCode(block.Text)}`");
break;
default:
builder.AppendLine(EscapeInline(block.Text));
break;
}
}
private static string JsonToMarkdown(string raw, string language)
{
try
{
using var json = JsonDocument.Parse(raw);
var root = json.RootElement;
var builder = new StringBuilder();
builder.AppendLine($"## {T(language, "JSON ", "JSON Result")}");
builder.AppendLine();
if (root.ValueKind == JsonValueKind.Array)
{
builder.AppendLine($"- **{T(language, "", "Items")}**: {root.GetArrayLength()}");
builder.AppendLine();
var rows = root.EnumerateArray().Take(20).ToArray();
AppendJsonArrayTable(builder, rows, language);
}
else if (root.ValueKind == JsonValueKind.Object)
{
builder.AppendLine($"- **{T(language, "", "Fields")}**: {root.EnumerateObject().Count()}");
builder.AppendLine();
AppendTable(
builder,
[T(language, "字段", "Field"), T(language, "值", "Value")],
root.EnumerateObject().Take(80).Select(property => new[] { property.Name, JsonCell(property.Value) }));
}
AppendCode(builder, T(language, "原始 JSON", "Raw JSON"), JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true }), "json");
return builder.ToString().Trim();
}
catch (JsonException exception)
{
var builder = new StringBuilder();
builder.AppendLine($"## {T(language, "JSON ", "JSON Parse Failed")}");
builder.AppendLine();
builder.AppendLine($"> {EscapeInline(exception.Message)}");
builder.AppendLine();
AppendCode(builder, T(language, "原始内容", "Raw Content"), raw, "json");
return builder.ToString().Trim();
}
}
private static void AppendJsonArrayTable(StringBuilder builder, IReadOnlyList<JsonElement> rows, string language)
{
if (rows.Count == 0)
{
builder.AppendLine(T(language, "_数组为空。_", "_The array is empty._"));
builder.AppendLine();
return;
}
if (rows.Any(row => row.ValueKind != JsonValueKind.Object))
{
AppendTable(builder, [T(language, "序号", "Index"), T(language, "值", "Value")], rows.Select((row, index) => new[] { (index + 1).ToString(), JsonCell(row) }));
return;
}
var headers = rows
.SelectMany(row => row.EnumerateObject().Select(property => property.Name))
.Distinct(StringComparer.Ordinal)
.Take(8)
.ToArray();
AppendTable(builder, headers, rows.Select(row => headers.Select(header =>
row.TryGetProperty(header, out var value) ? JsonCell(value) : string.Empty).ToArray()));
}
private static void AppendRowsAsTable(StringBuilder builder, IReadOnlyList<string[]> rows, string language)
{
if (rows.Count == 0)
{
builder.AppendLine(T(language, "_暂无表格行。_", "_No table rows._"));
builder.AppendLine();
return;
}
var headers = rows[0].Length > 0
? rows[0]
: [T(language, "字段", "Field"), T(language, "值", "Value")];
var body = rows.Skip(1);
AppendTable(builder, headers, body);
}
private static void AppendTable(StringBuilder builder, IReadOnlyList<string> headers, IEnumerable<IReadOnlyList<string>> rows)
{
var normalizedHeaders = headers.Count == 0 ? ["Value"] : headers.Take(8).Select(EscapeTableCell).ToArray();
builder.AppendLine($"| {string.Join(" | ", normalizedHeaders)} |");
builder.AppendLine($"| {string.Join(" | ", normalizedHeaders.Select(_ => "---"))} |");
foreach (var row in rows.Take(80))
{
var cells = normalizedHeaders.Select((_, index) => index < row.Count ? EscapeTableCell(row[index]) : string.Empty);
builder.AppendLine($"| {string.Join(" | ", cells)} |");
}
builder.AppendLine();
}
private static void AppendHeading(StringBuilder builder, string title, string fallback)
{
builder.AppendLine($"### {EscapeInline(string.IsNullOrWhiteSpace(title) ? fallback : title)}");
builder.AppendLine();
}
private static void AppendCode(StringBuilder builder, string title, string code, string language)
{
AppendHeading(builder, title, "Code");
builder.AppendLine($"```{SafeFenceLanguage(language)}");
builder.AppendLine(code.Replace("```", "``\\`", StringComparison.Ordinal));
builder.AppendLine("```");
builder.AppendLine();
}
private static string PlainTextToMarkdown(string raw)
{
if (raw.Contains('\n'))
{
return string.Join(Environment.NewLine, raw.Replace("\r\n", "\n").Split('\n').Select(line => string.IsNullOrWhiteSpace(line) ? string.Empty : EscapeInline(line)));
}
return EscapeInline(raw);
}
private static bool LooksLikeJson(string raw)
{
var trimmed = raw.TrimStart();
return trimmed.StartsWith('{') || trimmed.StartsWith('[');
}
private static string JsonCell(JsonElement value)
{
return value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => value.GetRawText(),
JsonValueKind.Null => "null",
_ => JsonSerializer.Serialize(value)
};
}
private static string Subtitle(string subtitle)
{
return string.IsNullOrWhiteSpace(subtitle) ? string.Empty : $" \n {EscapeInline(subtitle)}";
}
private static int NormalizeOrdinal(string value)
{
return int.TryParse(value, out var number) && number > 0 ? number : 1;
}
private static string T(string language, string zh, string en)
{
return string.Equals(language, "en-US", StringComparison.OrdinalIgnoreCase) ? en : zh;
}
private static string EscapeInline(string value)
{
return value.Replace("|", "\\|", StringComparison.Ordinal);
}
private static string EscapeTableCell(string value)
{
return EscapeInline(value)
.Replace("\r", " ", StringComparison.Ordinal)
.Replace("\n", " ", StringComparison.Ordinal)
.Trim();
}
private static string EscapeCode(string value)
{
return value.Replace("`", "\\`", StringComparison.Ordinal);
}
private static string SafeFenceLanguage(string language)
{
return string.IsNullOrWhiteSpace(language) ? string.Empty : new string(language.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace YMhut.Box.Core.Tools;
public sealed record ToolMetadata(
string Id,
string Name,
string Description,
ToolCategory Category,
IReadOnlyList<string> Keywords,
bool OfflineCapable,
string IconGlyph);
+13
View File
@@ -0,0 +1,13 @@
namespace YMhut.Box.Core.Tools;
public sealed class ToolModule(ToolMetadata metadata) : IToolModule
{
public string Id => Metadata.Id;
public ToolMetadata Metadata { get; } = metadata;
public object CreateViewModel()
{
return new ToolModuleViewModel(Metadata);
}
}
@@ -0,0 +1,8 @@
namespace YMhut.Box.Core.Tools;
public sealed class ToolModuleViewModel(ToolMetadata metadata)
{
public ToolMetadata Metadata { get; } = metadata;
public bool InlinePreviewLoaded { get; set; }
}
+790
View File
@@ -0,0 +1,790 @@
namespace YMhut.Box.Core.Tools;
using YMhut.Box.Core.Settings;
public enum ToolPrimaryInputKind
{
None,
Text,
MultilineText,
Query,
Url,
Token,
Color,
FixedOptions,
FilePath,
DateRange,
Number,
NumberPair,
NumberTriple,
NumberQuad
}
public enum ToolParameterControlKind
{
None,
ComboBox,
RadioButtons,
ToggleSwitch,
NumberBox,
Slider,
CalendarDatePicker,
FilePicker,
TabView
}
public enum ToolRulePresentationKind
{
SelectionCards,
ComboBox,
RadioButtons,
ToggleSwitch,
Expander
}
public enum ToolLayoutKind
{
TextWorkbench,
FormatterWorkbench,
CodecWorkbench,
QueryDashboard,
Dashboard,
CalculatorForm,
UnitConverter,
ReferenceBrowser,
FileWorkflow,
MediaNative,
BrowserNative,
RandomCinema,
SystemLauncher,
SecurityWorkbench
}
public enum ToolAssistPanelMode
{
Hidden,
RulesOnly,
SourceOnly,
ExamplesOnly,
RulesAndSource,
Custom
}
public enum ToolInteractionMode
{
ManualRun,
AutoLoad,
LivePreview,
NativeLauncher,
MediaViewer,
ReferenceBrowser
}
public enum ToolPageExperienceKind
{
LivePreview,
OneShotTransform,
FormCalculator,
LookupBrowser,
RemoteDashboard,
FileWorkflow,
NativeWindow,
SystemLauncher,
MediaViewer
}
public enum ToolPageDensity
{
Compact,
Expanded
}
public enum ToolResultPriority
{
Value,
List,
Table,
Preview,
Document,
Status,
Media
}
public enum ToolResultKind
{
PlainText,
CodePreview,
JsonTree,
KeyValueCards,
Table,
RankedList,
NewsCards,
ImagePreview,
LinkCards,
FileCards,
Diff,
StatusList,
ColorSwatch,
CalculatorTable,
SystemCards,
Media,
ReferenceRows
}
public sealed record ToolTextPair(string Zh, string En)
{
public string ForLanguage(string? language)
{
return LanguagePreference.IsEnglish(language) ? En : Zh;
}
}
public sealed record ToolRuleSpec(
string Key,
string Label,
ToolRulePresentationKind Presentation,
IReadOnlyList<ToolRuleOptionSpec> Options,
bool DefaultCollapsed = true,
bool IsRequired = false,
ToolTextPair? LocalizedLabel = null);
public sealed record ToolRuleOptionSpec(
string Label,
string Value,
string Description = "",
ToolTextPair? LocalizedLabel = null,
ToolTextPair? LocalizedDescription = null);
public sealed record ToolParameterSpec(
string Key,
string Label,
ToolParameterControlKind Control,
string Placeholder,
IReadOnlyList<ToolRuleOptionSpec> Options,
string DefaultValue = "",
bool IsRequired = false,
ToolTextPair? LocalizedLabel = null,
ToolTextPair? LocalizedPlaceholder = null);
public sealed record ToolPageSpec(
string ToolId,
ToolPrimaryInputKind PrimaryInput,
ToolLayoutKind Layout,
ToolResultKind Result,
IReadOnlyList<ToolParameterControlKind> ParameterControls,
bool AutoRunOnOpen,
bool RequiresFilePicker,
bool UseVirtualizedResults,
IReadOnlyList<ToolRuleSpec> Rules,
IReadOnlyList<ToolParameterSpec> Parameters,
string EmptyState,
string ErrorHint,
ToolAssistPanelMode AssistPanelMode,
ToolInteractionMode InteractionMode,
ToolPageExperienceKind PageExperience,
ToolPageDensity PageDensity,
string PrimaryActionLabel,
IReadOnlyList<string> SecondaryActions,
ToolResultPriority ResultPriority,
bool ShowInputHeader,
bool ShowDescription,
string DefaultFocusTarget)
{
public bool UsesComboBox => ParameterControls.Contains(ToolParameterControlKind.ComboBox);
public bool UsesRadioButtons => ParameterControls.Contains(ToolParameterControlKind.RadioButtons);
public bool UsesDatePicker => ParameterControls.Contains(ToolParameterControlKind.CalendarDatePicker);
public bool UsesNumberBox => ParameterControls.Contains(ToolParameterControlKind.NumberBox);
}
public static class ToolPageSpecCatalog
{
public static ToolPageSpec For(IToolModule module)
{
var id = module.Id;
return id switch
{
"safe_browser" => Spec(id, ToolPrimaryInputKind.Url, ToolLayoutKind.BrowserNative, ToolResultKind.LinkCards, controls: [ToolParameterControlKind.ComboBox]),
"media_player" => Spec(id, ToolPrimaryInputKind.FilePath, ToolLayoutKind.MediaNative, ToolResultKind.Media, controls: [ToolParameterControlKind.FilePicker], filePicker: true),
"random_cinema" => Spec(id, ToolPrimaryInputKind.None, ToolLayoutKind.RandomCinema, ToolResultKind.Media, controls: [ToolParameterControlKind.TabView], autoRun: true, virtualized: true),
"system_tool" => Spec(id, ToolPrimaryInputKind.FixedOptions, ToolLayoutKind.SystemLauncher, ToolResultKind.SystemCards, controls: [ToolParameterControlKind.ComboBox], autoRun: false, virtualized: true),
"system_info" or "pc_benchmark" => Spec(id, ToolPrimaryInputKind.None, ToolLayoutKind.Dashboard, ToolResultKind.SystemCards, controls: [ToolParameterControlKind.RadioButtons], autoRun: true),
"serial_terminal" => Spec(id, ToolPrimaryInputKind.None, ToolLayoutKind.SystemLauncher, ToolResultKind.StatusList, controls: [ToolParameterControlKind.None], autoRun: false),
"timestamp_converter" => Spec(id, ToolPrimaryInputKind.Text, ToolLayoutKind.CalculatorForm, ToolResultKind.CalculatorTable, controls: [ToolParameterControlKind.RadioButtons]),
"number_base" or "number_base_converter" => Spec(id, ToolPrimaryInputKind.Text, ToolLayoutKind.UnitConverter, ToolResultKind.CalculatorTable, controls: [ToolParameterControlKind.ComboBox]),
"json_formatter" or "csv_json_converter" or "yaml_json_converter" or "json_path_helper" or "jwt_decoder" =>
Spec(id, TextInput(id), ToolLayoutKind.FormatterWorkbench, ToolResultKind.JsonTree, controls: [ToolParameterControlKind.RadioButtons]),
"xml_formatter" or "sql_formatter" or "html_minifier" or "js_obfuscator" or "html_text_extractor" =>
Spec(id, ToolPrimaryInputKind.MultilineText, ToolLayoutKind.FormatterWorkbench, ToolResultKind.CodePreview, controls: [ToolParameterControlKind.RadioButtons]),
"base64_codec" or "compression_codec" or "url_codec" or "html_entity" or "unicode_codec" or "punycode_codec" or "query_builder" or "form_urlencoded_builder" or "key_value_converter" =>
Spec(id, TextInput(id), ToolLayoutKind.CodecWorkbench, ToolResultKind.KeyValueCards, controls: [ToolParameterControlKind.RadioButtons]),
"regex_tool" or "cron_helper" =>
Spec(id, ToolPrimaryInputKind.MultilineText, ToolLayoutKind.FormatterWorkbench, ToolResultKind.Table, controls: [ToolParameterControlKind.RadioButtons, ToolParameterControlKind.ToggleSwitch]),
"weather" or "train_query" or "dns_query" or "ip_lookup" or "ip_info" or "rdap_domain_lookup" or "rdap_ip_lookup" or "domain_price" or "http_diagnostic" or "url_redirect_trace" or "url_inspector" or "domain_extractor" or "cidr_subnet_calculator" or "wx_domain_check" =>
Spec(id, QueryInput(id), ToolLayoutKind.QueryDashboard, ToolResultKind.KeyValueCards, controls: [ToolParameterControlKind.ComboBox], autoRun: !module.Metadata.OfflineCapable, virtualized: true),
"ai_latest_news" =>
Spec(id, ToolPrimaryInputKind.Token, ToolLayoutKind.QueryDashboard, ToolResultKind.NewsCards, controls: [ToolParameterControlKind.None], autoRun: false, virtualized: true),
"city_route_query" =>
Spec(id, ToolPrimaryInputKind.Query, ToolLayoutKind.QueryDashboard, ToolResultKind.KeyValueCards, controls: [ToolParameterControlKind.None], autoRun: false, virtualized: true),
"hotboard" =>
Spec(id, ToolPrimaryInputKind.FixedOptions, ToolLayoutKind.Dashboard, ToolResultKind.RankedList, controls: [ToolParameterControlKind.ComboBox], autoRun: false, virtualized: true),
"history_today" =>
Spec(id, ToolPrimaryInputKind.Query, ToolLayoutKind.Dashboard, ToolResultKind.RankedList, controls: [ToolParameterControlKind.None], autoRun: true, virtualized: true),
"baidu_hot" or "bili_hot" or "zhihu_hot" or "cctv_news" or "tech_news" or "football_news" or "movie_box_office" or "earthquake_info" or "gold_price" or "oil_price" =>
Spec(id, ToolPrimaryInputKind.None, ToolLayoutKind.Dashboard, DashboardResult(id), controls: [ToolParameterControlKind.ComboBox], autoRun: true, virtualized: true),
"mime_lookup" or "http_status_lookup" or "port_lookup" or "dns_record_lookup" or "unicode_block_lookup" or "charset_lookup" or "timezone_abbr_lookup" or "regex_preset_lookup" or "magic_number_lookup" or "car_info" or "sanguosha_skin" =>
Spec(id, ToolPrimaryInputKind.FixedOptions, ToolLayoutKind.ReferenceBrowser, ToolResultKind.ReferenceRows, controls: [ToolParameterControlKind.ComboBox], autoRun: true, virtualized: true),
"calculator_tool" => Spec(id, ToolPrimaryInputKind.Text, ToolLayoutKind.CalculatorForm, ToolResultKind.CalculatorTable, controls: [ToolParameterControlKind.RadioButtons]),
"unit_converter" or "length_converter" or "weight_converter" or "temperature_converter" or "speed_converter" or "area_converter" or "volume_converter" or "data_size_converter" or "time_duration_converter" =>
Spec(id, ToolPrimaryInputKind.Number, ToolLayoutKind.UnitConverter, ToolResultKind.CalculatorTable, controls: [ToolParameterControlKind.NumberBox, ToolParameterControlKind.ComboBox]),
"bmi_calculator" or "percentage_calculator" or "percentage_change_calculator" or "discount_calculator" or "tax_calculator" or "ratio_simplifier" or "average_calculator" or "median_calculator" or "gcd_lcm_calculator" or "permutation_calculator" or "combination_calculator" or "simple_interest_calculator" or "compound_interest_calculator" or "loan_emi_calculator" =>
Spec(id, NumericInput(id), ToolLayoutKind.CalculatorForm, ToolResultKind.CalculatorTable, controls: [ToolParameterControlKind.NumberBox]),
"date_difference_calculator" or "workday_calculator" or "age_calculator" =>
Spec(id, ToolPrimaryInputKind.DateRange, ToolLayoutKind.CalculatorForm, ToolResultKind.CalculatorTable, controls: [ToolParameterControlKind.CalendarDatePicker]),
"shelf_life" => Spec(id, ToolPrimaryInputKind.DateRange, ToolLayoutKind.CalculatorForm, ToolResultKind.KeyValueCards, controls: [ToolParameterControlKind.CalendarDatePicker, ToolParameterControlKind.NumberBox]),
"qrcode_scanner" or "image_processor" or "image_metadata_inspector" or "archive_tool" or "hash_manifest_builder" or "hash_manifest_verify" =>
Spec(id, ToolPrimaryInputKind.FilePath, ToolLayoutKind.FileWorkflow, FileResult(id), controls: [ToolParameterControlKind.FilePicker, ToolParameterControlKind.RadioButtons], filePicker: true, virtualized: true),
"qr_generator" => Spec(id, ToolPrimaryInputKind.MultilineText, ToolLayoutKind.FileWorkflow, ToolResultKind.ImagePreview, controls: [ToolParameterControlKind.RadioButtons]),
"color_picker" => Spec(id, ToolPrimaryInputKind.Color, ToolLayoutKind.TextWorkbench, ToolResultKind.ColorSwatch, controls: [ToolParameterControlKind.ComboBox]),
"color_contrast_checker" => Spec(id, ToolPrimaryInputKind.Text, ToolLayoutKind.TextWorkbench, ToolResultKind.KeyValueCards, controls: [ToolParameterControlKind.RadioButtons]),
"totp_generator" => Spec(id, ToolPrimaryInputKind.Token, ToolLayoutKind.SecurityWorkbench, ToolResultKind.KeyValueCards, controls: [ToolParameterControlKind.RadioButtons]),
"markdown_preview" => Spec(id, ToolPrimaryInputKind.MultilineText, ToolLayoutKind.FormatterWorkbench, ToolResultKind.CodePreview, controls: [ToolParameterControlKind.RadioButtons]),
"qq_avatar" => Spec(id, ToolPrimaryInputKind.Query, ToolLayoutKind.QueryDashboard, ToolResultKind.ImagePreview, controls: [ToolParameterControlKind.None], autoRun: false),
"html_js_playground" => Spec(id, ToolPrimaryInputKind.MultilineText, ToolLayoutKind.BrowserNative, ToolResultKind.CodePreview, controls: [ToolParameterControlKind.None], autoRun: false),
"ascii_art" or "password_generator" or "uuid_generator" or "ulid_generator" or "random_generator" or "random_picker" =>
Spec(id, GeneratorInput(id), ToolLayoutKind.TextWorkbench, ToolResultKind.PlainText, controls: [ToolParameterControlKind.NumberBox, ToolParameterControlKind.RadioButtons]),
_ when module.Metadata.Category == ToolCategory.Security =>
Spec(id, ToolPrimaryInputKind.MultilineText, ToolLayoutKind.SecurityWorkbench, SecurityResult(id), controls: [ToolParameterControlKind.RadioButtons, ToolParameterControlKind.ToggleSwitch]),
_ when module.Metadata.Category == ToolCategory.Text =>
Spec(id, ToolPrimaryInputKind.MultilineText, ToolLayoutKind.TextWorkbench, TextResult(id), controls: [ToolParameterControlKind.RadioButtons, ToolParameterControlKind.ToggleSwitch], virtualized: true),
_ when module.Metadata.Category == ToolCategory.Image =>
Spec(id, ToolPrimaryInputKind.FilePath, ToolLayoutKind.FileWorkflow, ToolResultKind.FileCards, controls: [ToolParameterControlKind.FilePicker, ToolParameterControlKind.RadioButtons], filePicker: true),
_ when module.Metadata.Category == ToolCategory.Network =>
Spec(id, ToolPrimaryInputKind.Query, ToolLayoutKind.QueryDashboard, ToolResultKind.LinkCards, controls: [ToolParameterControlKind.ComboBox], autoRun: !module.Metadata.OfflineCapable),
_ when module.Metadata.Category == ToolCategory.Data =>
Spec(id, ToolPrimaryInputKind.None, ToolLayoutKind.Dashboard, ToolResultKind.RankedList, controls: [ToolParameterControlKind.ComboBox], autoRun: true, virtualized: true),
_ when module.Metadata.Category == ToolCategory.Calculator =>
Spec(id, ToolPrimaryInputKind.Number, ToolLayoutKind.CalculatorForm, ToolResultKind.CalculatorTable, controls: [ToolParameterControlKind.NumberBox]),
_ when module.Metadata.Category == ToolCategory.Design =>
Spec(id, ToolPrimaryInputKind.Text, ToolLayoutKind.TextWorkbench, ToolResultKind.ImagePreview, controls: [ToolParameterControlKind.RadioButtons]),
_ when module.Metadata.Category == ToolCategory.Life =>
Spec(id, ToolPrimaryInputKind.Query, ToolLayoutKind.QueryDashboard, ToolResultKind.KeyValueCards, controls: [ToolParameterControlKind.ComboBox]),
_ when module.Metadata.Category == ToolCategory.System =>
Spec(id, ToolPrimaryInputKind.FixedOptions, ToolLayoutKind.SystemLauncher, ToolResultKind.SystemCards, controls: [ToolParameterControlKind.ComboBox]),
_ =>
Spec(id, ToolPrimaryInputKind.MultilineText, ToolLayoutKind.TextWorkbench, ToolResultKind.PlainText, controls: [ToolParameterControlKind.RadioButtons])
};
}
public static IReadOnlyDictionary<string, ToolPageSpec> CreateFor(ToolCatalog catalog)
{
return catalog.Modules.ToDictionary(module => module.Id, For, StringComparer.OrdinalIgnoreCase);
}
private static ToolPageSpec Spec(
string id,
ToolPrimaryInputKind input,
ToolLayoutKind layout,
ToolResultKind result,
IReadOnlyList<ToolParameterControlKind>? controls = null,
bool autoRun = false,
bool filePicker = false,
bool virtualized = false)
{
return new ToolPageSpec(
id,
input,
layout,
result,
controls is { Count: > 0 } ? controls : [ToolParameterControlKind.None],
autoRun,
filePicker,
virtualized,
BuildRules(id, input, controls ?? [ToolParameterControlKind.None]),
BuildParameters(id, input, controls ?? [ToolParameterControlKind.None]),
input == ToolPrimaryInputKind.None ? "Open this tool to refresh and render live data." : "Enter values or choose options, then run the tool.",
"The tool could not finish. Check the input and try again.",
ResolveAssistPanelMode(id, input, layout, controls ?? [ToolParameterControlKind.None], autoRun, filePicker),
ResolveInteractionMode(id, input, layout, autoRun),
ResolvePageExperience(id, input, layout, autoRun, filePicker),
ResolvePageDensity(result, layout, virtualized),
ResolvePrimaryActionLabel(id, input, layout, autoRun, filePicker),
ResolveSecondaryActions(input, filePicker, autoRun),
ResolveResultPriority(result),
ShouldShowInputHeader(input, layout, autoRun),
ShouldShowDescription(input, layout, autoRun),
ResolveDefaultFocusTarget(input, layout));
}
private static ToolPageExperienceKind ResolvePageExperience(
string id,
ToolPrimaryInputKind input,
ToolLayoutKind layout,
bool autoRun,
bool filePicker)
{
if (id == "color_picker")
{
return ToolPageExperienceKind.LivePreview;
}
if (id == "hotboard")
{
return ToolPageExperienceKind.RemoteDashboard;
}
return layout switch
{
ToolLayoutKind.BrowserNative => ToolPageExperienceKind.NativeWindow,
ToolLayoutKind.MediaNative or ToolLayoutKind.RandomCinema => ToolPageExperienceKind.MediaViewer,
ToolLayoutKind.SystemLauncher => ToolPageExperienceKind.SystemLauncher,
ToolLayoutKind.FileWorkflow => ToolPageExperienceKind.FileWorkflow,
ToolLayoutKind.ReferenceBrowser => ToolPageExperienceKind.LookupBrowser,
ToolLayoutKind.Dashboard or ToolLayoutKind.QueryDashboard when autoRun || input == ToolPrimaryInputKind.None => ToolPageExperienceKind.RemoteDashboard,
ToolLayoutKind.CalculatorForm or ToolLayoutKind.UnitConverter => ToolPageExperienceKind.FormCalculator,
_ when filePicker => ToolPageExperienceKind.FileWorkflow,
_ => ToolPageExperienceKind.OneShotTransform
};
}
private static ToolPageDensity ResolvePageDensity(ToolResultKind result, ToolLayoutKind layout, bool virtualized)
{
return virtualized ||
layout is ToolLayoutKind.MediaNative or ToolLayoutKind.RandomCinema or ToolLayoutKind.FileWorkflow ||
result is ToolResultKind.Table or ToolResultKind.RankedList or ToolResultKind.NewsCards or ToolResultKind.ReferenceRows or ToolResultKind.Media
? ToolPageDensity.Expanded
: ToolPageDensity.Compact;
}
private static string ResolvePrimaryActionLabel(
string id,
ToolPrimaryInputKind input,
ToolLayoutKind layout,
bool autoRun,
bool filePicker)
{
if (id == "color_picker")
{
return string.Empty;
}
if (id == "hotboard")
{
return "查询";
}
if (filePicker || layout == ToolLayoutKind.FileWorkflow)
{
return id is "qrcode_scanner" ? "扫描" : id is "qr_generator" ? "生成" : "处理";
}
if (layout == ToolLayoutKind.SystemLauncher)
{
return "打开";
}
if (autoRun || input == ToolPrimaryInputKind.None)
{
return "重新加载";
}
if (layout is ToolLayoutKind.FormatterWorkbench)
{
return "格式化";
}
if (layout is ToolLayoutKind.CodecWorkbench or ToolLayoutKind.UnitConverter)
{
return "转换";
}
if (layout is ToolLayoutKind.CalculatorForm)
{
return "计算";
}
if (layout is ToolLayoutKind.QueryDashboard)
{
return "查询";
}
if (id.Contains("generator", StringComparison.OrdinalIgnoreCase))
{
return "生成";
}
return "处理";
}
private static IReadOnlyList<string> ResolveSecondaryActions(ToolPrimaryInputKind input, bool filePicker, bool autoRun)
{
var actions = new List<string>();
if (filePicker)
{
actions.Add("chooseFile");
}
if (input != ToolPrimaryInputKind.None)
{
actions.Add("clear");
}
actions.Add("copyResult");
return actions;
}
private static ToolResultPriority ResolveResultPriority(ToolResultKind result)
{
return result switch
{
ToolResultKind.JsonTree or ToolResultKind.CodePreview or ToolResultKind.Diff => ToolResultPriority.Document,
ToolResultKind.Table or ToolResultKind.CalculatorTable or ToolResultKind.ReferenceRows => ToolResultPriority.Table,
ToolResultKind.RankedList or ToolResultKind.NewsCards => ToolResultPriority.List,
ToolResultKind.ImagePreview or ToolResultKind.ColorSwatch => ToolResultPriority.Preview,
ToolResultKind.FileCards or ToolResultKind.StatusList or ToolResultKind.SystemCards => ToolResultPriority.Status,
ToolResultKind.Media => ToolResultPriority.Media,
_ => ToolResultPriority.Value
};
}
private static bool ShouldShowInputHeader(ToolPrimaryInputKind input, ToolLayoutKind layout, bool autoRun)
{
return input != ToolPrimaryInputKind.None && layout is not ToolLayoutKind.ReferenceBrowser && !autoRun;
}
private static bool ShouldShowDescription(ToolPrimaryInputKind input, ToolLayoutKind layout, bool autoRun)
{
return input is ToolPrimaryInputKind.MultilineText or ToolPrimaryInputKind.FilePath or ToolPrimaryInputKind.DateRange ||
layout is ToolLayoutKind.FileWorkflow or ToolLayoutKind.SecurityWorkbench ||
(!autoRun && input != ToolPrimaryInputKind.None);
}
private static string ResolveDefaultFocusTarget(ToolPrimaryInputKind input, ToolLayoutKind layout)
{
return input switch
{
ToolPrimaryInputKind.Query or ToolPrimaryInputKind.Url => "query",
ToolPrimaryInputKind.Number or ToolPrimaryInputKind.NumberPair or ToolPrimaryInputKind.NumberTriple or ToolPrimaryInputKind.NumberQuad => "number",
ToolPrimaryInputKind.DateRange => "date",
ToolPrimaryInputKind.FilePath => "file",
ToolPrimaryInputKind.FixedOptions => "parameter",
_ when layout == ToolLayoutKind.ReferenceBrowser => "parameter",
_ => "input"
};
}
private static ToolAssistPanelMode ResolveAssistPanelMode(
string id,
ToolPrimaryInputKind input,
ToolLayoutKind layout,
IReadOnlyList<ToolParameterControlKind> controls,
bool autoRun,
bool filePicker)
{
if (id == "color_picker")
{
return ToolAssistPanelMode.Custom;
}
if (layout is ToolLayoutKind.BrowserNative or ToolLayoutKind.MediaNative or ToolLayoutKind.RandomCinema ||
id is "system_info" or "pc_benchmark" or "system_tool")
{
return ToolAssistPanelMode.Hidden;
}
if (input == ToolPrimaryInputKind.None && autoRun)
{
return ToolAssistPanelMode.Hidden;
}
if (!filePicker && layout == ToolLayoutKind.QueryDashboard && autoRun)
{
return ToolAssistPanelMode.SourceOnly;
}
if (layout == ToolLayoutKind.ReferenceBrowser || id == "smart_search")
{
return ToolAssistPanelMode.Hidden;
}
if (id is "regex_tool" or "template_filler" or "batch_text_replace" or "qr_generator" or "path_breakdown" or "path_normalizer")
{
return ToolAssistPanelMode.ExamplesOnly;
}
if (filePicker ||
id is "json_formatter" or "xml_formatter" or "sql_formatter" or "html_minifier" or "js_obfuscator"
or "base64_codec" or "url_codec" or "html_entity" or "unicode_codec" or "punycode_codec"
or "text_sorter" or "text_deduplicator" or "duplicate_detector" or "manifest_deduplicator"
or "text_filter" or "keyword_counter" or "hash_generator" or "hmac_generator")
{
return ToolAssistPanelMode.RulesOnly;
}
return ToolAssistPanelMode.Hidden;
}
private static ToolInteractionMode ResolveInteractionMode(
string id,
ToolPrimaryInputKind input,
ToolLayoutKind layout,
bool autoRun)
{
if (id == "color_picker")
{
return ToolInteractionMode.LivePreview;
}
return layout switch
{
ToolLayoutKind.BrowserNative => ToolInteractionMode.NativeLauncher,
ToolLayoutKind.MediaNative => ToolInteractionMode.MediaViewer,
ToolLayoutKind.RandomCinema => ToolInteractionMode.MediaViewer,
ToolLayoutKind.ReferenceBrowser => ToolInteractionMode.ReferenceBrowser,
_ when autoRun || input == ToolPrimaryInputKind.None => ToolInteractionMode.AutoLoad,
_ => ToolInteractionMode.ManualRun
};
}
private static IReadOnlyList<ToolRuleSpec> BuildRules(
string id,
ToolPrimaryInputKind input,
IReadOnlyList<ToolParameterControlKind> controls)
{
static ToolTextPair Text(string zh, string en) => new(zh, en);
if (id is "json_formatter" or "xml_formatter" or "sql_formatter" or "html_minifier" or "js_obfuscator")
{
return
[
new ToolRuleSpec("mode", "Mode", ToolRulePresentationKind.RadioButtons,
[
new("Format", "format", LocalizedLabel: Text("格式化", "Format")),
new("Minify", "minify", LocalizedLabel: Text("压缩", "Minify")),
new("Validate", "validate", LocalizedLabel: Text("校验", "Validate"))
], LocalizedLabel: Text("模式", "Mode"))
];
}
if (id is "base64_codec" or "url_codec" or "html_entity" or "unicode_codec" or "punycode_codec" or "query_builder" or "form_urlencoded_builder" or "key_value_converter")
{
return
[
new ToolRuleSpec("direction", "Direction", ToolRulePresentationKind.SelectionCards,
[
new("Encode", "encode", LocalizedLabel: Text("编码", "Encode")),
new("Decode", "decode", LocalizedLabel: Text("解码", "Decode")),
new("Both", "both", LocalizedLabel: Text("双向", "Both"))
], LocalizedLabel: Text("方向", "Direction"))
];
}
if (id is "text_sorter" or "manifest_sorter")
{
return
[
new ToolRuleSpec("sort", "Sort order", ToolRulePresentationKind.RadioButtons,
[
new("Ascending", "ascending", LocalizedLabel: Text("升序", "Ascending")),
new("Descending", "descending", LocalizedLabel: Text("降序", "Descending")),
new("Natural", "natural", LocalizedLabel: Text("自然排序", "Natural"))
], LocalizedLabel: Text("排序方式", "Sort order"))
];
}
if (id is "text_deduplicator" or "duplicate_detector" or "manifest_deduplicator")
{
return
[
new ToolRuleSpec("dedupe", "Deduplication", ToolRulePresentationKind.SelectionCards,
[
new("Keep first", "first", LocalizedLabel: Text("保留首次出现", "Keep first")),
new("Keep last", "last", LocalizedLabel: Text("保留最后出现", "Keep last")),
new("Ignore case", "ignoreCase", LocalizedLabel: Text("忽略大小写", "Ignore case"))
], LocalizedLabel: Text("去重规则", "Deduplication")),
new ToolRuleSpec("preserveOrder", "Keep original order", ToolRulePresentationKind.ToggleSwitch, [], LocalizedLabel: Text("保留原始顺序", "Keep original order"))
];
}
if (id is "text_filter" or "keyword_counter")
{
return
[
new ToolRuleSpec("match", "Match mode", ToolRulePresentationKind.RadioButtons,
[
new("Contains", "contains", LocalizedLabel: Text("包含", "Contains")),
new("Regex", "regex", LocalizedLabel: Text("正则", "Regex")),
new("Exclude", "exclude", LocalizedLabel: Text("排除", "Exclude"))
], LocalizedLabel: Text("匹配模式", "Match mode"))
];
}
if (id is "hash_generator" or "hmac_generator" or "hash_manifest_builder" or "hash_manifest_verify")
{
return
[
new ToolRuleSpec("algorithm", "Algorithm", ToolRulePresentationKind.ComboBox,
[
new("SHA256", "sha256"),
new("SHA512", "sha512"),
new("MD5", "md5")
], LocalizedLabel: Text("算法", "Algorithm"))
];
}
if (input == ToolPrimaryInputKind.FixedOptions || controls.Contains(ToolParameterControlKind.ComboBox))
{
return
[
new ToolRuleSpec("source", "Source", ToolRulePresentationKind.ComboBox,
[
new("Default", "default", LocalizedLabel: Text("默认", "Default")),
new("All", "all", LocalizedLabel: Text("全部", "All"))
], LocalizedLabel: Text("来源", "Source"))
];
}
if (controls.Contains(ToolParameterControlKind.ToggleSwitch))
{
return [new ToolRuleSpec("strict", "Strict mode", ToolRulePresentationKind.ToggleSwitch, [], LocalizedLabel: Text("严格模式", "Strict mode"))];
}
return [];
}
private static IReadOnlyList<ToolParameterSpec> BuildParameters(
string id,
ToolPrimaryInputKind input,
IReadOnlyList<ToolParameterControlKind> controls)
{
static ToolTextPair Text(string zh, string en) => new(zh, en);
if (input == ToolPrimaryInputKind.None)
{
return [];
}
if (input == ToolPrimaryInputKind.FixedOptions || controls.Contains(ToolParameterControlKind.ComboBox))
{
return
[
new ToolParameterSpec(
"preset",
"Preset",
ToolParameterControlKind.ComboBox,
"Choose a preset",
BuildPresetOptions(id),
BuildPresetOptions(id).FirstOrDefault()?.Value ?? "",
LocalizedLabel: Text("预设", "Preset"),
LocalizedPlaceholder: Text("选择预设", "Choose a preset"))
];
}
if (input is ToolPrimaryInputKind.Number or ToolPrimaryInputKind.NumberPair or ToolPrimaryInputKind.NumberTriple or ToolPrimaryInputKind.NumberQuad)
{
return [new ToolParameterSpec("value", "Value", ToolParameterControlKind.NumberBox, "Enter a number", [], "1", true, Text("数值", "Value"), Text("输入数字", "Enter a number"))];
}
if (input == ToolPrimaryInputKind.DateRange)
{
return [new ToolParameterSpec("date", "Date", ToolParameterControlKind.CalendarDatePicker, "Choose date", [], string.Empty, true, Text("日期", "Date"), Text("选择日期", "Choose date"))];
}
if (input == ToolPrimaryInputKind.FilePath)
{
return [new ToolParameterSpec("path", "File path", ToolParameterControlKind.FilePicker, "Choose or paste a local file", [], string.Empty, true, Text("文件路径", "File path"), Text("选择或粘贴本地文件", "Choose or paste a local file"))];
}
return [new ToolParameterSpec("input", "Input", ToolParameterControlKind.None, "Enter input", [], string.Empty, false, Text("输入", "Input"), Text("输入内容", "Enter input"))];
}
private static IReadOnlyList<ToolRuleOptionSpec> BuildPresetOptions(string id)
{
static ToolTextPair Text(string zh, string en) => new(zh, en);
return id switch
{
"system_tool" =>
[
new("System status", "status", LocalizedLabel: Text("系统状态", "System status")),
new("Temporary folder", "temp", LocalizedLabel: Text("临时目录", "Temporary folder")),
new("Processes", "process", LocalizedLabel: Text("进程", "Processes")),
new("Environment variables", "env", LocalizedLabel: Text("环境变量", "Environment variables")),
new("Remote Desktop", "rdp", LocalizedLabel: Text("远程桌面", "Remote Desktop")),
new("Paint", "paint", LocalizedLabel: Text("画图", "Paint")),
new("Calculator", "calculator", LocalizedLabel: Text("计算器", "Calculator")),
new("Control Panel", "control_panel", LocalizedLabel: Text("控制面板", "Control Panel")),
new("Registry Editor", "registry_editor", LocalizedLabel: Text("注册表编辑器", "Registry Editor")),
new("Task Manager", "task_manager", LocalizedLabel: Text("任务管理器", "Task Manager")),
new("Services", "services", LocalizedLabel: Text("服务", "Services"))
],
"http_status_lookup" => [new("All status codes", "", LocalizedLabel: Text("全部状态码", "All status codes")), new("2xx", "2"), new("3xx", "3"), new("4xx", "4"), new("5xx", "5"), new("404", "404")],
"port_lookup" => [new("All ports", "", LocalizedLabel: Text("全部端口", "All ports")), new("Web service", "http", LocalizedLabel: Text("网页服务", "Web service")), new("Mail service", "mail", LocalizedLabel: Text("邮件服务", "Mail service")), new("Database", "sql", LocalizedLabel: Text("数据库", "Database")), new("Remote access", "remote", LocalizedLabel: Text("远程连接", "Remote access"))],
"mime_lookup" => [new("All MIME", "", LocalizedLabel: Text("全部 MIME", "All MIME")), new("Image", "image", LocalizedLabel: Text("图片", "Image")), new("Audio", "audio", LocalizedLabel: Text("音频", "Audio")), new("Video", "video", LocalizedLabel: Text("视频", "Video")), new("Text", "text", LocalizedLabel: Text("文本", "Text")), new("JSON", "json")],
"hotboard" => [new("百度热搜", "百度"), new("哔哩哔哩", "哔哩哔哩"), new("知乎", "知乎"), new("微博", "微博"), new("抖音", "抖音"), new("今日头条", "今日头条"), new("36氪", "36氪"), new("少数派", "少数派"), new("GitHub", "GitHub"), new("V2EX", "V2EX")],
"dns_record_lookup" => [new("All records", "", LocalizedLabel: Text("全部记录", "All records")), new("A", "A"), new("AAAA", "AAAA"), new("CNAME", "CNAME"), new("MX", "MX"), new("TXT", "TXT")],
_ => [new("Default", "", LocalizedLabel: Text("默认", "Default"))]
};
}
private static ToolPrimaryInputKind TextInput(string id)
{
return id is "base64_codec" or "url_codec" or "html_entity" or "unicode_codec" or "punycode_codec"
? ToolPrimaryInputKind.Text
: ToolPrimaryInputKind.MultilineText;
}
private static ToolPrimaryInputKind QueryInput(string id)
{
return id is "http_diagnostic" or "url_inspector" ? ToolPrimaryInputKind.Url : ToolPrimaryInputKind.Query;
}
private static ToolPrimaryInputKind NumericInput(string id)
{
return id is "compound_interest_calculator"
? ToolPrimaryInputKind.NumberQuad
: id is "loan_emi_calculator" or "simple_interest_calculator"
? ToolPrimaryInputKind.NumberTriple
: id is "bmi_calculator" or "percentage_calculator" or "percentage_change_calculator" or "discount_calculator" or "tax_calculator" or "ratio_simplifier" or "permutation_calculator" or "combination_calculator"
? ToolPrimaryInputKind.NumberPair
: ToolPrimaryInputKind.MultilineText;
}
private static ToolPrimaryInputKind GeneratorInput(string id)
{
return id is "uuid_generator" or "ulid_generator" or "password_generator" or "random_generator" or "random_picker"
? ToolPrimaryInputKind.Number
: ToolPrimaryInputKind.Text;
}
private static ToolResultKind DashboardResult(string id)
{
return id is "cctv_news" or "tech_news" or "football_news" ? ToolResultKind.NewsCards : ToolResultKind.RankedList;
}
private static ToolResultKind FileResult(string id)
{
return id is "qrcode_scanner" or "image_processor" ? ToolResultKind.ImagePreview :
id is "hash_manifest_verify" ? ToolResultKind.StatusList :
ToolResultKind.FileCards;
}
private static ToolResultKind SecurityResult(string id)
{
return id is "hash_manifest_verify" ? ToolResultKind.StatusList : ToolResultKind.KeyValueCards;
}
private static ToolResultKind TextResult(string id)
{
return id is "text_diff" ? ToolResultKind.Diff :
id is "markdown_table_normalizer" ? ToolResultKind.Table :
id.Contains("statistics", StringComparison.OrdinalIgnoreCase) || id.Contains("counter", StringComparison.OrdinalIgnoreCase) || id.Contains("analyzer", StringComparison.OrdinalIgnoreCase)
? ToolResultKind.KeyValueCards
: ToolResultKind.PlainText;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,419 @@
using System.Text.Json;
using System.Text.RegularExpressions;
namespace YMhut.Box.Core.Tools;
public enum ToolResultBlockKind
{
Text,
KeyValue,
Table,
RankedList,
NewsList,
Image,
Link,
File,
Diff,
Status,
Code,
Json,
JsonTree,
Metric,
LineChart,
CardList,
Timeline,
Media,
Color,
Raw
}
public sealed record ToolResultDocument(
string ToolId,
ToolResultKind ResultKind,
string RawText,
IReadOnlyList<ToolResultBlock> Blocks,
IReadOnlyDictionary<string, string> Metadata,
string RawPayload = "",
string SourceName = "",
string Status = "")
{
public static ToolResultDocument FromOutput(IToolModule module, string output)
{
var spec = ToolPageSpecCatalog.For(module);
var lines = Lines(output);
var blocks = spec.Result switch
{
ToolResultKind.JsonTree => JsonBlocks(output),
ToolResultKind.CodePreview => [ToolResultBlock.Code("Code", output)],
ToolResultKind.Table or ToolResultKind.CalculatorTable => TableBlocks(lines),
ToolResultKind.RankedList => RankedBlocks(lines),
ToolResultKind.NewsCards => NewsBlocks(lines),
ToolResultKind.ImagePreview => MediaBlocks(lines, imageOnly: true),
ToolResultKind.LinkCards => LinkBlocks(lines),
ToolResultKind.FileCards => FileBlocks(lines),
ToolResultKind.Diff => DiffBlocks(lines),
ToolResultKind.StatusList => StatusBlocks(lines),
ToolResultKind.ColorSwatch => ColorBlocks(lines),
ToolResultKind.SystemCards or ToolResultKind.KeyValueCards or ToolResultKind.ReferenceRows => KeyValueBlocks(lines),
ToolResultKind.Media => MediaBlocks(lines, imageOnly: false),
_ => TextBlocks(lines)
};
if (blocks.Count == 0)
{
blocks = TextBlocks(lines);
}
return new ToolResultDocument(
module.Id,
spec.Result,
output,
blocks,
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["toolId"] = module.Id,
["resultKind"] = spec.Result.ToString(),
["layout"] = spec.Layout.ToString(),
["generatedAt"] = DateTimeOffset.Now.ToString("O")
},
output,
string.Empty,
"ok");
}
private static IReadOnlyList<ToolResultBlock> JsonBlocks(string output)
{
try
{
using var document = JsonDocument.Parse(output);
return [ToolResultBlock.Json("JSON", JsonSerializer.Serialize(document.RootElement, new JsonSerializerOptions { WriteIndented = true }))];
}
catch
{
return [];
}
}
private static IReadOnlyList<ToolResultBlock> TableBlocks(IReadOnlyList<string> lines)
{
var rows = lines
.Select(line => line.Contains('|') ? line.Split('|').Select(cell => cell.Trim()).Where(cell => cell.Length > 0).ToArray() : SplitKeyValue(line))
.Where(row => row.Length >= 2)
.Take(80)
.ToArray();
return rows.Length == 0 ? [] : [ToolResultBlock.Table("Table", rows)];
}
private static IReadOnlyList<ToolResultBlock> RankedBlocks(IReadOnlyList<string> lines)
{
var items = lines
.Select(ParseRankedItem)
.Where(item => item is not null)
.Select(item => item!)
.Take(80)
.ToArray();
return items.Length == 0 ? TextBlocks(lines) : [ToolResultBlock.List(ToolResultBlockKind.RankedList, "Ranked list", items)];
}
private static IReadOnlyList<ToolResultBlock> NewsBlocks(IReadOnlyList<string> lines)
{
var items = lines
.Where(line => !string.IsNullOrWhiteSpace(line))
.Take(60)
.Select((line, index) => new ToolResultListItem((index + 1).ToString(), StripOrdinal(line), string.Empty, string.Empty, string.Empty))
.ToArray();
return items.Length == 0 ? [] : [ToolResultBlock.List(ToolResultBlockKind.NewsList, "News", items)];
}
private static IReadOnlyList<ToolResultBlock> LinkBlocks(IReadOnlyList<string> lines)
{
return ExtractLinks(lines)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(30)
.Select(link => ToolResultBlock.Link("Link", link, link))
.ToArray();
}
private static IReadOnlyList<ToolResultBlock> MediaBlocks(IReadOnlyList<string> lines, bool imageOnly)
{
var blocks = new List<ToolResultBlock>();
foreach (var link in ExtractLinks(lines).Distinct(StringComparer.OrdinalIgnoreCase).Take(30))
{
if (IsImageLink(link))
{
blocks.Add(ToolResultBlock.Media(ToolResultBlockKind.Image, "Image", link, link));
}
else if (!imageOnly)
{
blocks.Add(ToolResultBlock.Media(ToolResultBlockKind.Media, "Media", link, link));
}
}
blocks.AddRange(FileBlocks(lines));
return blocks;
}
private static IReadOnlyList<ToolResultBlock> FileBlocks(IReadOnlyList<string> lines)
{
return lines
.Select(ExtractPath)
.Where(path => !string.IsNullOrWhiteSpace(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(30)
.Select(path => ToolResultBlock.File("File", path!, path!))
.ToArray();
}
private static IReadOnlyList<ToolResultBlock> DiffBlocks(IReadOnlyList<string> lines)
{
var items = lines
.Take(160)
.Select(line => new ToolResultListItem(
line.StartsWith('+') ? "+" : line.StartsWith('-') ? "-" : string.Empty,
line,
string.Empty,
line.StartsWith('+') ? "added" : line.StartsWith('-') ? "removed" : "context",
string.Empty))
.ToArray();
return items.Length == 0 ? [] : [ToolResultBlock.List(ToolResultBlockKind.Diff, "Diff", items)];
}
private static IReadOnlyList<ToolResultBlock> StatusBlocks(IReadOnlyList<string> lines)
{
var items = lines
.Take(80)
.Select(line =>
{
var status = line.Contains("OK", StringComparison.OrdinalIgnoreCase) || line.Contains("SUCCESS", StringComparison.OrdinalIgnoreCase)
? "ok"
: line.Contains("FAIL", StringComparison.OrdinalIgnoreCase) || line.Contains("MISMATCH", StringComparison.OrdinalIgnoreCase)
? "error"
: "info";
return new ToolResultListItem(status, line, string.Empty, status, string.Empty);
})
.ToArray();
return items.Length == 0 ? [] : [ToolResultBlock.List(ToolResultBlockKind.Status, "Status", items)];
}
private static IReadOnlyList<ToolResultBlock> ColorBlocks(IReadOnlyList<string> lines)
{
var colors = lines.SelectMany(line => Regex.Matches(line, @"#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}").Select(match => match.Value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(20)
.Select(color => ToolResultBlock.Media(ToolResultBlockKind.Color, color, color, color))
.ToArray();
return colors.Length == 0 ? KeyValueBlocks(lines) : colors;
}
private static IReadOnlyList<ToolResultBlock> KeyValueBlocks(IReadOnlyList<string> lines)
{
var rows = lines
.Select(SplitKeyValue)
.Where(row => row.Length >= 2)
.Select(row => new KeyValuePair<string, string>(row[0], row[1]))
.Take(80)
.ToArray();
return rows.Length == 0 ? [] : [ToolResultBlock.KeyValue("Details", rows)];
}
private static IReadOnlyList<ToolResultBlock> TextBlocks(IReadOnlyList<string> lines)
{
var items = lines
.Take(120)
.Select((line, index) => new ToolResultListItem((index + 1).ToString(), line, string.Empty, string.Empty, string.Empty))
.ToArray();
return items.Length == 0 ? [] : [ToolResultBlock.List(ToolResultBlockKind.Text, "Lines", items)];
}
private static IReadOnlyList<string> Lines(string output)
{
return output.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string[] SplitKeyValue(string line)
{
var separators = new[] { "", ":", "=", "\t" };
foreach (var separator in separators)
{
var index = line.IndexOf(separator, StringComparison.Ordinal);
if (index > 0 && index < line.Length - separator.Length)
{
return [line[..index].Trim().Trim('-', '*', ' '), line[(index + separator.Length)..].Trim()];
}
}
return [];
}
private static ToolResultListItem? ParseRankedItem(string line)
{
var match = Regex.Match(line, @"^\s*(?<rank>\d+)[\.\)\u3001\uff09\s]+(?<title>.+)$");
return match.Success
? new ToolResultListItem(match.Groups["rank"].Value, CleanVisibleText(match.Groups["title"].Value), string.Empty, string.Empty, ExtractLinks([line]).FirstOrDefault() ?? string.Empty)
: null;
}
private static string CleanVisibleText(string value)
{
var text = value ?? string.Empty;
text = Regex.Replace(text, @"\s*https?://[^\s\]\)>'""]+", string.Empty, RegexOptions.IgnoreCase);
text = Regex.Replace(text, @"\s*/\s*$", string.Empty);
text = Regex.Replace(text, @"\s{2,}", " ");
return text.Trim();
}
private static string StripOrdinal(string value)
{
return Regex.Replace(value, @"^\s*\d+[\.\)\u3001\uff09\s]+", string.Empty).Trim();
}
private static IEnumerable<string> ExtractLinks(IEnumerable<string> lines)
{
foreach (var line in lines)
{
foreach (Match match in Regex.Matches(line, @"https?://[^\s\]\)>'""]+", RegexOptions.IgnoreCase))
{
yield return match.Value.TrimEnd('.', ',', ';');
}
}
}
private static bool IsImageLink(string link)
{
if (!Uri.TryCreate(link, UriKind.Absolute, out var uri))
{
return false;
}
var path = uri.AbsolutePath.ToLowerInvariant();
return path.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".gif", StringComparison.OrdinalIgnoreCase)
|| uri.Host.Contains("qlogo.cn", StringComparison.OrdinalIgnoreCase);
}
private static string? ExtractPath(string line)
{
var candidate = line
.Replace("Path:", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Output:", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("已输出:", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("已创建归档:", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("已解压到:", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim()
.Trim('"');
return File.Exists(candidate) || Directory.Exists(candidate) ? candidate : null;
}
}
public sealed record ToolResultBlock(
ToolResultBlockKind Kind,
string Title,
string Text,
IReadOnlyList<KeyValuePair<string, string>> Pairs,
IReadOnlyList<string[]> Rows,
IReadOnlyList<ToolResultListItem> Items,
string Uri,
string Path,
string Language,
string Status,
string Subtitle = "",
string Description = "",
string Severity = "",
IReadOnlyDictionary<string, string>? Metadata = null)
{
public static ToolResultBlock KeyValue(
string title,
IReadOnlyList<KeyValuePair<string, string>> pairs,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(ToolResultBlockKind.KeyValue, title, string.Empty, pairs, [], [], string.Empty, string.Empty, string.Empty, string.Empty, Metadata: metadata);
public static ToolResultBlock Table(
string title,
IReadOnlyList<string[]> rows,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(ToolResultBlockKind.Table, title, string.Empty, [], rows, [], string.Empty, string.Empty, string.Empty, string.Empty, Metadata: metadata);
public static ToolResultBlock List(
ToolResultBlockKind kind,
string title,
IReadOnlyList<ToolResultListItem> items,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(kind, title, string.Empty, [], [], items, string.Empty, string.Empty, string.Empty, string.Empty, Metadata: metadata);
public static ToolResultBlock Link(
string title,
string text,
string uri,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(ToolResultBlockKind.Link, title, text, [], [], [], uri, string.Empty, string.Empty, string.Empty, Metadata: metadata);
public static ToolResultBlock File(
string title,
string text,
string path,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(ToolResultBlockKind.File, title, text, [], [], [], string.Empty, path, string.Empty, string.Empty, Metadata: metadata);
public static ToolResultBlock Code(
string title,
string text,
string language = "",
IReadOnlyDictionary<string, string>? metadata = null)
=> new(ToolResultBlockKind.Code, title, text, [], [], [], string.Empty, string.Empty, language, string.Empty, Metadata: metadata);
public static ToolResultBlock Json(string title, string text)
=> new(ToolResultBlockKind.Json, title, text, [], [], [], string.Empty, string.Empty, "json", string.Empty);
public static ToolResultBlock JsonTree(string title, string text)
=> new(ToolResultBlockKind.JsonTree, title, text, [], [], [], string.Empty, string.Empty, "json", string.Empty);
public static ToolResultBlock Metric(
string title,
IReadOnlyList<KeyValuePair<string, string>> pairs,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(ToolResultBlockKind.Metric, title, string.Empty, pairs, [], [], string.Empty, string.Empty, string.Empty, string.Empty, Metadata: metadata);
public static ToolResultBlock LineChart(
string title,
IReadOnlyList<string[]> rows,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(ToolResultBlockKind.LineChart, title, string.Empty, [], rows, [], string.Empty, string.Empty, string.Empty, string.Empty, Metadata: metadata);
public static ToolResultBlock Cards(
string title,
IReadOnlyList<ToolResultListItem> items,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(ToolResultBlockKind.CardList, title, string.Empty, [], [], items, string.Empty, string.Empty, string.Empty, string.Empty, Metadata: metadata);
public static ToolResultBlock Timeline(
string title,
IReadOnlyList<ToolResultListItem> items,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(ToolResultBlockKind.Timeline, title, string.Empty, [], [], items, string.Empty, string.Empty, string.Empty, string.Empty, Metadata: metadata);
public static ToolResultBlock Media(
ToolResultBlockKind kind,
string title,
string text,
string uri,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(kind, title, text, [], [], [], uri, string.Empty, string.Empty, string.Empty, Metadata: metadata);
public static ToolResultBlock Raw(
string title,
string text,
string language = "",
IReadOnlyDictionary<string, string>? metadata = null)
=> new(ToolResultBlockKind.Raw, title, text, [], [], [], string.Empty, string.Empty, language, string.Empty, Metadata: metadata);
}
public sealed record ToolResultListItem(
string Leading,
string Title,
string Subtitle,
string Status,
string Uri);
@@ -0,0 +1,359 @@
namespace YMhut.Box.Core.Tools;
public sealed record ToolResultExperience(
string ToolId,
string ExperienceId,
string Layout,
string Accent,
string Tone,
string PrimaryPrimitive,
IReadOnlyList<string> SecondaryPrimitives);
public sealed record ToolPageExperience(
string ToolId,
string ExperienceId,
string InputLayout,
string ResultLayout,
string Accent,
string Tone,
IReadOnlyList<string> InputPrimitives,
IReadOnlyList<string> ResultPrimitives,
IReadOnlyList<string> Actions);
public interface IToolResultExperienceCatalog
{
ToolResultExperience GetRequired(string toolId);
ToolPageExperience GetRequiredPage(string toolId);
IReadOnlyDictionary<string, ToolResultExperience> Experiences { get; }
IReadOnlyDictionary<string, ToolPageExperience> PageExperiences { get; }
}
public sealed class ToolResultExperienceCatalog : IToolResultExperienceCatalog
{
private readonly IReadOnlyDictionary<string, ToolResultExperience> _experiences;
private readonly IReadOnlyDictionary<string, ToolPageExperience> _pageExperiences;
public ToolResultExperienceCatalog(ToolCatalog catalog)
{
_experiences = catalog.Modules.ToDictionary(module => module.Id, CreateExperience, StringComparer.OrdinalIgnoreCase);
_pageExperiences = catalog.Modules.ToDictionary(module => module.Id, CreatePageExperience, StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyDictionary<string, ToolResultExperience> Experiences => _experiences;
public IReadOnlyDictionary<string, ToolPageExperience> PageExperiences => _pageExperiences;
public ToolResultExperience GetRequired(string toolId)
{
if (_experiences.TryGetValue(toolId, out var experience))
{
return experience;
}
throw new InvalidOperationException($"Tool result experience is missing for '{toolId}'.");
}
public ToolPageExperience GetRequiredPage(string toolId)
{
if (_pageExperiences.TryGetValue(toolId, out var experience))
{
return experience;
}
throw new InvalidOperationException($"Tool page experience is missing for '{toolId}'.");
}
public static ToolResultExperience CreateExperience(IToolModule module)
{
var spec = ToolPageSpecCatalog.For(module);
var slug = module.Id.Replace('_', '-').ToLowerInvariant();
var family = ResolveFamily(module, spec);
var primitive = ResolvePrimaryPrimitive(module, spec);
return new ToolResultExperience(
module.Id,
$"{slug}-{family}-{primitive}",
$"{family}-{spec.Result.ToString().ToLowerInvariant()}",
AccentFor(module.Metadata.Category),
ToneFor(module, spec),
primitive,
SecondaryPrimitives(spec));
}
public static ToolPageExperience CreatePageExperience(IToolModule module)
{
var spec = ToolPageSpecCatalog.For(module);
var result = CreateExperience(module);
var inputLayout = InputLayoutFor(spec);
var resultLayout = $"{ResolveFamily(module, spec)}-{result.PrimaryPrimitive}";
var slug = module.Id.Replace('_', '-').ToLowerInvariant();
return new ToolPageExperience(
module.Id,
$"{slug}-{inputLayout}-{result.PrimaryPrimitive}",
inputLayout,
resultLayout,
result.Accent,
result.Tone,
InputPrimitives(spec),
[result.PrimaryPrimitive, ..result.SecondaryPrimitives],
PageActions(spec));
}
private static string ResolveFamily(IToolModule module, ToolPageSpec spec)
{
if (module.Id.Contains("json", StringComparison.OrdinalIgnoreCase) || module.Id is "jwt_decoder")
{
return "structured";
}
if (spec.Layout is ToolLayoutKind.CalculatorForm or ToolLayoutKind.UnitConverter)
{
return "calculation";
}
if (spec.Layout is ToolLayoutKind.QueryDashboard or ToolLayoutKind.Dashboard)
{
return module.Metadata.OfflineCapable ? "reference" : "live";
}
if (spec.Layout is ToolLayoutKind.FileWorkflow)
{
return "fileflow";
}
if (spec.Layout is ToolLayoutKind.SecurityWorkbench)
{
return "security";
}
if (spec.Result is ToolResultKind.CodePreview or ToolResultKind.JsonTree)
{
return "code";
}
return module.Metadata.Category.ToString().ToLowerInvariant();
}
private static string ResolvePrimaryPrimitive(IToolModule module, ToolPageSpec spec)
{
return spec.Result switch
{
ToolResultKind.JsonTree => "json-tree",
ToolResultKind.CodePreview => "code-panel",
ToolResultKind.Table or ToolResultKind.CalculatorTable or ToolResultKind.ReferenceRows => "data-table",
ToolResultKind.RankedList => "ranked-lane",
ToolResultKind.NewsCards => "news-board",
ToolResultKind.ImagePreview => "media-stage",
ToolResultKind.LinkCards => "link-dock",
ToolResultKind.FileCards => "file-rail",
ToolResultKind.Diff => "diff-stack",
ToolResultKind.StatusList => "status-timeline",
ToolResultKind.ColorSwatch => "color-lab",
ToolResultKind.SystemCards => "metric-grid",
ToolResultKind.Media => "media-wall",
_ when module.Id.Contains("generator", StringComparison.OrdinalIgnoreCase) => "generator-strip",
_ => "text-stream"
};
}
private static IReadOnlyList<string> SecondaryPrimitives(ToolPageSpec spec)
{
var primitives = new List<string> { "summary-metrics", "raw-drawer" };
if (spec.UseVirtualizedResults)
{
primitives.Add("virtual-list");
}
if (spec.ResultPriority is ToolResultPriority.Table)
{
primitives.Add("column-scan");
}
if (spec.ResultPriority is ToolResultPriority.Media or ToolResultPriority.Preview)
{
primitives.Add("preview-actions");
}
return primitives;
}
private static string InputLayoutFor(ToolPageSpec spec)
{
return spec.PrimaryInput switch
{
ToolPrimaryInputKind.None => spec.AutoRunOnOpen ? "auto-dashboard" : "action-panel",
ToolPrimaryInputKind.MultilineText => "editor-workbench",
ToolPrimaryInputKind.Query or ToolPrimaryInputKind.Url => "search-console",
ToolPrimaryInputKind.Token => "secret-entry",
ToolPrimaryInputKind.Color => "color-studio",
ToolPrimaryInputKind.FixedOptions => "preset-browser",
ToolPrimaryInputKind.FilePath => "file-dropzone",
ToolPrimaryInputKind.DateRange => "date-range-form",
ToolPrimaryInputKind.Number or ToolPrimaryInputKind.NumberPair or ToolPrimaryInputKind.NumberTriple or ToolPrimaryInputKind.NumberQuad => "numeric-calculator",
_ => "text-console"
};
}
private static IReadOnlyList<string> InputPrimitives(ToolPageSpec spec)
{
var primitives = new List<string>();
if (spec.PrimaryInput is ToolPrimaryInputKind.Text or ToolPrimaryInputKind.MultilineText or ToolPrimaryInputKind.Query or ToolPrimaryInputKind.Url or ToolPrimaryInputKind.Token)
{
primitives.Add("text-input");
}
if (spec.PrimaryInput == ToolPrimaryInputKind.MultilineText)
{
primitives.Add("monospace-editor");
}
if (spec.PrimaryInput == ToolPrimaryInputKind.FilePath || spec.RequiresFilePicker)
{
primitives.Add("file-picker");
}
if (spec.UsesNumberBox || spec.PrimaryInput is ToolPrimaryInputKind.Number or ToolPrimaryInputKind.NumberPair or ToolPrimaryInputKind.NumberTriple or ToolPrimaryInputKind.NumberQuad)
{
primitives.Add("number-grid");
}
if (spec.UsesDatePicker || spec.PrimaryInput == ToolPrimaryInputKind.DateRange)
{
primitives.Add("date-fields");
}
if (spec.Rules.Count > 0)
{
primitives.Add("rule-strip");
}
if (spec.Parameters.Count > 0 || spec.UsesComboBox)
{
primitives.Add("preset-select");
}
if (primitives.Count == 0)
{
primitives.Add("action-surface");
}
return primitives;
}
private static IReadOnlyList<string> PageActions(ToolPageSpec spec)
{
var actions = new List<string>();
if (!string.IsNullOrWhiteSpace(spec.PrimaryActionLabel))
{
actions.Add("run");
}
if (spec.RequiresFilePicker || spec.SecondaryActions.Contains("chooseFile"))
{
actions.Add("chooseFile");
}
if (spec.SecondaryActions.Contains("clear"))
{
actions.Add("clear");
}
actions.Add("copyResult");
actions.Add("showRaw");
return actions.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private static string AccentFor(ToolCategory category)
{
return category switch
{
ToolCategory.Network => "#38BDF8",
ToolCategory.Security => "#F97316",
ToolCategory.Data => "#A78BFA",
ToolCategory.Calculator => "#22C55E",
ToolCategory.Text => "#FACC15",
ToolCategory.Image => "#EC4899",
ToolCategory.Design => "#14B8A6",
ToolCategory.Life => "#84CC16",
ToolCategory.System => "#60A5FA",
ToolCategory.Plugin => "#C084FC",
_ => "#22C55E"
};
}
private static string ToneFor(IToolModule module, ToolPageSpec spec)
{
if (!module.Metadata.OfflineCapable)
{
return "live-network";
}
return spec.PageExperience switch
{
ToolPageExperienceKind.FormCalculator => "precision",
ToolPageExperienceKind.FileWorkflow => "workflow",
ToolPageExperienceKind.LookupBrowser => "reference",
ToolPageExperienceKind.LivePreview => "studio",
ToolPageExperienceKind.SystemLauncher => "system",
_ => "local"
};
}
}
public sealed record ToolResultWebPayload(
string ToolId,
string ToolName,
ToolResultDocument ResultDocument,
string ExperienceId,
string Theme,
string Language,
ToolResultPrivacyPolicy PrivacyPolicy,
ToolResultRuntimeMetadata RuntimeMetadata,
ToolResultExperience Experience);
public sealed record ToolPageWebPayload(
string ToolId,
string ToolName,
string ToolDescription,
ToolPageSpec Spec,
ToolPageExperience Experience,
ToolInputState Input,
string Theme,
string Language,
ToolResultPrivacyPolicy PrivacyPolicy,
ToolPageRuntimeMetadata RuntimeMetadata,
IReadOnlyList<string> AvailableActions,
ToolResultDocument? Result = null,
ToolResultRunState? RunState = null);
public sealed record ToolInputState(
string Text,
IReadOnlyList<ToolInputField> Fields,
IReadOnlyDictionary<string, string> Rules,
IReadOnlyDictionary<string, string> Metadata);
public sealed record ToolInputField(
string Key,
string Label,
string Kind,
string Value,
string Placeholder = "",
double? Minimum = null,
double? Maximum = null,
IReadOnlyList<ToolInputOption>? Options = null);
public sealed record ToolInputOption(string Label, string Value, string Description = "");
public sealed record ToolResultRunState(
string State,
string Message,
string? Error = null,
long DurationMs = 0);
public sealed record ToolPageRuntimeMetadata(
DateTimeOffset CreatedAt,
bool AutoRun,
bool OfflineCapable,
string Category,
string Icon,
string Source = "tool-page");
public sealed record ToolResultPrivacyPolicy(
IReadOnlyList<string> RedactedHostHints,
string Mode = "ymhut-api-only");
public sealed record ToolResultRuntimeMetadata(
long DurationMs,
DateTimeOffset CompletedAt,
bool Cached,
string Source,
IReadOnlyDictionary<string, string> ToolMetadata);
@@ -0,0 +1,50 @@
using System.Text.RegularExpressions;
namespace YMhut.Box.Core.Tools;
public static class ToolResultPrivacySanitizer
{
private static readonly Regex UrlPattern = new(
@"https?://[^\s\]\)""'<>]+",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
private static readonly Regex RequestUrlContextPattern = new(
@"(?:api[_\-\s]*url|request[_\-\s]*url|api\s+request|endpoint|接口地址|请求地址)\s*[:=]?\s*$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
public static IReadOnlyList<string> DefaultRedactedHostHints { get; } =
["ymhut.cn", "ymhut.com", "api.ymhut", "update.ymhut"];
public static string Redact(string? value, string language, IReadOnlyList<string>? hostHints = null)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
var hints = hostHints is { Count: > 0 } ? hostHints : DefaultRedactedHostHints;
var placeholder = IsEnglish(language) ? "[YMhut endpoint hidden]" : "[已隐藏 YMhut 接口]";
return UrlPattern.Replace(value, match =>
ShouldRedact(match.Value, value, match.Index, hints) ? placeholder : match.Value);
}
private static bool ShouldRedact(string candidate, string fullText, int index, IReadOnlyList<string> hostHints)
{
if (Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
var host = uri.Host;
if (hostHints.Any(hint => host.Contains(hint, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
var contextStart = Math.Max(0, index - 64);
var context = fullText[contextStart..index];
return RequestUrlContextPattern.IsMatch(context);
}
private static bool IsEnglish(string? language)
=> string.Equals(language, "en-US", StringComparison.OrdinalIgnoreCase) ||
string.Equals(language, "en", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,45 @@
using System.Text.Json;
namespace YMhut.Box.Core.Tools;
public static class ToolWorkerProtocol
{
public const string Version = "1";
public const string Ready = "ready";
public const string Ping = "ping";
public const string Pong = "pong";
public const string ExecuteTool = "executeTool";
public const string Cancel = "cancel";
public const string Progress = "progress";
public const string Result = "result";
public const string Error = "error";
public const string Shutdown = "shutdown";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
public static string Serialize(ToolWorkerMessage message)
{
return JsonSerializer.Serialize(message, JsonOptions);
}
public static ToolWorkerMessage? Deserialize(string line)
{
return JsonSerializer.Deserialize<ToolWorkerMessage>(line, JsonOptions);
}
}
public sealed record ToolWorkerMessage(
string Type,
string RequestId = "",
string? ToolId = null,
string? Input = null,
bool Ok = false,
string? Output = null,
string? Error = null,
ToolResultDocument? Document = null,
string? Version = null,
int TimeoutMs = 0,
string? Language = null);
@@ -0,0 +1,38 @@
using YMhut.Box.Core.Api;
using YMhut.Box.Core.Data;
namespace YMhut.Box.Core.Tools;
public interface IToolWorkerService
{
Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default);
Task<ToolExecutionResult> ExecuteToolAsync(
IToolModule module,
string input,
IApiManager? apiManager = null,
IReferenceDataService? referenceDataService = null,
CancellationToken cancellationToken = default,
string language = "zh-CN");
}
public sealed class ToolWorkerService : IToolWorkerService
{
public Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)
{
return Task.Run(() => work(cancellationToken), cancellationToken);
}
public Task<ToolExecutionResult> ExecuteToolAsync(
IToolModule module,
string input,
IApiManager? apiManager = null,
IReferenceDataService? referenceDataService = null,
CancellationToken cancellationToken = default,
string language = "zh-CN")
{
return RunAsync(
token => ToolExecutor.ExecuteAsync(module, input, token, apiManager, referenceDataService, language),
cancellationToken);
}
}
@@ -0,0 +1,66 @@
namespace YMhut.Box.Core.Tools;
public readonly record struct ToolboxGridLayout(
int Columns,
double CardWidth,
double ItemWidth,
bool ShowCategoryRail,
bool IsCompact);
public static class ToolboxLayoutCalculator
{
public const double MinCardWidth = 264;
public const double MaxCardWidth = 360;
public const double ItemGap = 14;
public static ToolboxGridLayout Calculate(double availableWidth)
{
if (double.IsNaN(availableWidth) || double.IsInfinity(availableWidth) || availableWidth <= 0)
{
return new ToolboxGridLayout(3, 318, 332, ShowCategoryRail: true, IsCompact: false);
}
var showCategoryRail = availableWidth >= 900;
var usable = Math.Max(MinCardWidth, availableWidth - 28);
var columns = ColumnsFor(usable);
var cardWidth = Math.Floor((usable - (columns - 1) * ItemGap) / columns);
cardWidth = Math.Clamp(cardWidth, MinCardWidth, MaxCardWidth);
return new ToolboxGridLayout(
columns,
cardWidth,
cardWidth + ItemGap,
showCategoryRail,
IsCompact: availableWidth < 720);
}
private static int ColumnsFor(double usableWidth)
{
if (usableWidth < 620)
{
return 1;
}
if (usableWidth < 900)
{
return 2;
}
if (usableWidth < 1220)
{
return 3;
}
if (usableWidth < 1540)
{
return 4;
}
if (usableWidth < 1880)
{
return 5;
}
return 6;
}
}
@@ -0,0 +1,20 @@
namespace YMhut.Box.Core.Updates;
public static class UpdateInfoEndpointPolicy
{
public static readonly IReadOnlyList<string> DefaultRelativePaths =
[
"api/client/bootstrap",
"update-info.json",
"update-info",
"api/update-info"
];
public static IReadOnlyList<Uri> BuildDefaultUris(string baseUri = "https://update.ymhut.cn/")
{
var root = new Uri(baseUri.TrimEnd('/') + "/");
return DefaultRelativePaths
.Select(path => new Uri(root, path))
.ToArray();
}
}
@@ -0,0 +1,48 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace YMhut.Box.Core.Updates;
public sealed record ReleaseManifest(
string Version,
string PackageVersion,
string Channel,
DateTimeOffset CreatedAt,
IReadOnlyList<ReleaseFileEntry> Files,
IReadOnlyList<string> DeletedFiles,
string BaseDownloadUrl = "",
string Flavor = "");
public sealed record ReleaseFileEntry(
string Path,
long Size,
string Sha256,
bool Required = false);
public interface IAppUpdateService
{
Task<ReleaseManifest?> GetLatestManifestAsync(CancellationToken cancellationToken = default);
}
public static class ReleaseManifestSerializer
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static async Task<ReleaseManifest> ReadManifestAsync(string path, CancellationToken cancellationToken = default)
{
await using var stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<ReleaseManifest>(stream, Options, cancellationToken).ConfigureAwait(false)
?? throw new InvalidDataException("Release manifest is empty or invalid.");
}
public static async Task WriteManifestAsync(string path, ReleaseManifest manifest, CancellationToken cancellationToken = default)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var stream = File.Create(path);
await JsonSerializer.SerializeAsync(stream, manifest, Options, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,82 @@
namespace YMhut.Box.Core.Updates;
public static class UpdateVersionComparer
{
public static string NormalizeVersion(string? version, string? build = null)
{
var baseParts = VersionParts(version).ToArray();
if (baseParts.Length == 0)
{
return string.Empty;
}
if (!string.IsNullOrWhiteSpace(build) &&
baseParts.Length < 4 &&
TryReadFirstNumber(build, out var buildNumber))
{
baseParts = [.. baseParts, buildNumber];
}
return string.Join('.', baseParts);
}
public static int Compare(string? remoteVersion, string? remoteBuild, string? currentVersion)
{
return CompareNormalized(NormalizeVersion(remoteVersion, remoteBuild), NormalizeVersion(currentVersion));
}
public static int CompareNormalized(string? remoteVersion, string? currentVersion)
{
var remoteParts = VersionParts(remoteVersion).ToArray();
var currentParts = VersionParts(currentVersion).ToArray();
var length = Math.Max(remoteParts.Length, currentParts.Length);
for (var index = 0; index < length; index++)
{
var left = index < remoteParts.Length ? remoteParts[index] : 0;
var right = index < currentParts.Length ? currentParts[index] : 0;
if (left > right)
{
return 1;
}
if (left < right)
{
return -1;
}
}
return 0;
}
public static bool IsRemoteNewer(string? remoteVersion, string? remoteBuild, string? currentVersion)
{
return Compare(remoteVersion, remoteBuild, currentVersion) > 0;
}
private static IEnumerable<int> VersionParts(string? value)
{
return (value ?? string.Empty)
.Split(['.', '+', '-', ' ', '_'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(ReadLeadingNumber)
.Where(part => part is not null)
.Select(part => part!.Value);
}
private static int? ReadLeadingNumber(string part)
{
var digits = new string(part.TakeWhile(char.IsDigit).ToArray());
return int.TryParse(digits, out var parsed) ? parsed : null;
}
private static bool TryReadFirstNumber(string value, out int number)
{
number = 0;
foreach (var part in VersionParts(value))
{
number = part;
return true;
}
return false;
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
<PackageReference Include="System.Management" Version="10.0.0" />
<PackageReference Include="ZXing.Net" Version="0.16.11" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Plugins\BuiltIn\**\*.*" />
</ItemGroup>
</Project>
+409
View File
@@ -0,0 +1,409 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO.Pipes;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using YMhut.Box.Core.Downloads;
Console.OutputEncoding = Encoding.UTF8;
var pipeName = ReadPipeName(args);
if (string.IsNullOrWhiteSpace(pipeName))
{
Console.Error.WriteLine("Missing --pipe argument.");
return 2;
}
var running = new ConcurrentDictionary<string, CancellationTokenSource>(StringComparer.Ordinal);
var paused = new ConcurrentDictionary<string, bool>(StringComparer.Ordinal);
var canceled = new ConcurrentDictionary<string, bool>(StringComparer.Ordinal);
var writeGate = new SemaphoreSlim(1, 1);
await using var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
await pipe.ConnectAsync(8000).ConfigureAwait(false);
using var reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
await using var writer = new StreamWriter(pipe, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), bufferSize: 4096, leaveOpen: true)
{
AutoFlush = true
};
await WriteAsync(new DownloadHostMessage(DownloadHostProtocol.Ready, Version: DownloadHostProtocol.Version), CancellationToken.None)
.ConfigureAwait(false);
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
{
var message = DownloadHostProtocol.Deserialize(line);
if (message is null)
{
continue;
}
if (string.Equals(message.Type, DownloadHostProtocol.Shutdown, StringComparison.Ordinal))
{
foreach (var request in running.Values)
{
request.Cancel();
}
break;
}
if (string.Equals(message.Type, DownloadHostProtocol.Ping, StringComparison.Ordinal))
{
await WriteAsync(new DownloadHostMessage(DownloadHostProtocol.Pong, message.RequestId), CancellationToken.None)
.ConfigureAwait(false);
continue;
}
if (string.Equals(message.Type, DownloadHostProtocol.Start, StringComparison.Ordinal) && message.Item is not null)
{
StartDownload(message.Item, resume: FileLength(message.Item.EffectivePartialPath) > 0);
continue;
}
if (string.Equals(message.Type, DownloadHostProtocol.Resume, StringComparison.Ordinal) && message.Item is not null)
{
StartDownload(message.Item, resume: true);
continue;
}
if (string.Equals(message.Type, DownloadHostProtocol.Pause, StringComparison.Ordinal))
{
if (running.TryRemove(message.RequestId, out var source))
{
paused[message.RequestId] = true;
source.Cancel();
source.Dispose();
}
continue;
}
if (string.Equals(message.Type, DownloadHostProtocol.Cancel, StringComparison.Ordinal))
{
if (running.TryRemove(message.RequestId, out var source))
{
source.Cancel();
source.Dispose();
}
paused.TryRemove(message.RequestId, out _);
canceled[message.RequestId] = true;
}
}
foreach (var request in running.Values)
{
request.Cancel();
request.Dispose();
}
return 0;
void StartDownload(DownloadItem item, bool resume)
{
if (running.ContainsKey(item.Id))
{
return;
}
paused.TryRemove(item.Id, out _);
var cancelSource = new CancellationTokenSource();
running[item.Id] = cancelSource;
_ = Task.Run(async () =>
{
try
{
await DownloadAsync(item, resume, cancelSource.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
if (paused.ContainsKey(item.Id))
{
var partialLength = FileLength(item.EffectivePartialPath);
await WriteAsync(
new DownloadHostMessage(
DownloadHostProtocol.Paused,
item.Id,
Progress: new DownloadProgressSnapshot(item.Id, DownloadState.Paused, partialLength, item.TotalBytes, 0)),
CancellationToken.None).ConfigureAwait(false);
}
else
{
var wasCanceled = canceled.ContainsKey(item.Id);
if (wasCanceled)
{
TryDeleteFile(item.EffectivePartialPath);
}
var partialLength = wasCanceled ? 0 : FileLength(item.EffectivePartialPath);
await WriteAsync(
new DownloadHostMessage(
DownloadHostProtocol.Canceled,
item.Id,
Progress: new DownloadProgressSnapshot(item.Id, DownloadState.Canceled, partialLength, item.TotalBytes, 0)),
CancellationToken.None).ConfigureAwait(false);
}
}
catch (Exception exception)
{
await WriteAsync(
new DownloadHostMessage(
DownloadHostProtocol.Failed,
item.Id,
Progress: new DownloadProgressSnapshot(item.Id, DownloadState.Failed, FileLength(item.EffectivePartialPath), item.TotalBytes, 0, exception.Message),
Error: exception.Message),
CancellationToken.None).ConfigureAwait(false);
}
finally
{
paused.TryRemove(item.Id, out _);
canceled.TryRemove(item.Id, out _);
if (running.TryRemove(item.Id, out var source))
{
source.Dispose();
}
}
});
}
async Task DownloadAsync(DownloadItem item, bool resume, CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(item.TargetPath) ?? AppContext.BaseDirectory);
Directory.CreateDirectory(Path.GetDirectoryName(item.EffectivePartialPath) ?? AppContext.BaseDirectory);
using var client = new HttpClient { Timeout = TimeSpan.FromHours(6) };
client.DefaultRequestHeaders.UserAgent.ParseAdd("YMhutBox/2.0 DownloadHost");
var allowResume = resume;
for (var attempt = 0; attempt < 2; attempt++)
{
var existingBytes = allowResume ? FileLength(item.EffectivePartialPath) : 0;
using var request = new HttpRequestMessage(HttpMethod.Get, item.Source.EffectiveUrl);
if (existingBytes > 0)
{
request.Headers.Range = new RangeHeaderValue(existingBytes, null);
}
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
var metadata = ResponseMetadata.From(response);
if (ShouldRestartFromZero(item, metadata, response.StatusCode, existingBytes))
{
TryDeleteFile(item.EffectivePartialPath);
allowResume = false;
if (attempt == 0)
{
continue;
}
existingBytes = 0;
}
if (existingBytes > 0 && response.StatusCode == HttpStatusCode.OK)
{
TryDeleteFile(item.EffectivePartialPath);
existingBytes = 0;
}
response.EnsureSuccessStatusCode();
var remoteLength = response.Content.Headers.ContentLength;
var totalBytes = response.Content.Headers.ContentRange?.Length ??
metadata.ContentLength ??
(remoteLength is > 0 ? remoteLength + existingBytes : item.TotalBytes);
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using var target = new FileStream(
item.EffectivePartialPath,
existingBytes > 0 ? FileMode.Append : FileMode.Create,
FileAccess.Write,
FileShare.Read,
128 * 1024,
useAsync: true);
var buffer = new byte[128 * 1024];
var received = existingBytes;
var started = Stopwatch.StartNew();
var lastReport = Stopwatch.StartNew();
var restartedFromZero = attempt > 0 || (resume && existingBytes == 0);
await ReportAsync(item.Id, DownloadState.Running, received, totalBytes, 0, metadata, restartedFromZero, cancellationToken).ConfigureAwait(false);
while (true)
{
var read = await source.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false);
if (read <= 0)
{
break;
}
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
received += read;
if (lastReport.ElapsedMilliseconds >= 250)
{
var speed = (received - existingBytes) / Math.Max(0.001, started.Elapsed.TotalSeconds);
await ReportAsync(item.Id, DownloadState.Running, received, totalBytes, speed, metadata, restartedFromZero, cancellationToken).ConfigureAwait(false);
lastReport.Restart();
}
}
await target.FlushAsync(cancellationToken).ConfigureAwait(false);
target.Close();
MovePartialToFinal(item.EffectivePartialPath, item.TargetPath);
await WriteAsync(
new DownloadHostMessage(
DownloadHostProtocol.Completed,
item.Id,
Progress: new DownloadProgressSnapshot(
item.Id,
DownloadState.Completed,
received,
totalBytes,
0,
ETag: metadata.ETag,
LastModified: metadata.LastModified,
AcceptRanges: metadata.AcceptRanges,
ContentLength: metadata.ContentLength,
FinalUrl: metadata.FinalUrl,
RestartedFromZero: restartedFromZero)),
cancellationToken).ConfigureAwait(false);
return;
}
throw new IOException("Download restart failed.");
}
async Task ReportAsync(string id, DownloadState state, long received, long? total, double speed, ResponseMetadata metadata, bool restartedFromZero, CancellationToken cancellationToken)
{
await WriteAsync(
new DownloadHostMessage(
DownloadHostProtocol.Progress,
id,
Progress: new DownloadProgressSnapshot(
id,
state,
received,
total,
speed,
ETag: metadata.ETag,
LastModified: metadata.LastModified,
AcceptRanges: metadata.AcceptRanges,
ContentLength: metadata.ContentLength,
FinalUrl: metadata.FinalUrl,
RestartedFromZero: restartedFromZero)),
cancellationToken).ConfigureAwait(false);
}
static bool ShouldRestartFromZero(DownloadItem item, ResponseMetadata metadata, HttpStatusCode statusCode, long existingBytes)
{
if (existingBytes <= 0)
{
return false;
}
if (statusCode != HttpStatusCode.PartialContent)
{
return true;
}
if (!string.IsNullOrWhiteSpace(item.ETag) &&
!string.IsNullOrWhiteSpace(metadata.ETag) &&
!string.Equals(item.ETag, metadata.ETag, StringComparison.Ordinal))
{
return true;
}
return !string.IsNullOrWhiteSpace(item.LastModified) &&
!string.IsNullOrWhiteSpace(metadata.LastModified) &&
!string.Equals(item.LastModified, metadata.LastModified, StringComparison.OrdinalIgnoreCase);
}
static void MovePartialToFinal(string partialPath, string targetPath)
{
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? AppContext.BaseDirectory);
if (File.Exists(targetPath))
{
File.Delete(targetPath);
}
File.Move(partialPath, targetPath);
}
static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
}
}
async Task WriteAsync(DownloadHostMessage message, CancellationToken cancellationToken)
{
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();
}
}
static long FileLength(string path)
{
try
{
return File.Exists(path) ? new FileInfo(path).Length : 0;
}
catch
{
return 0;
}
}
static string? ReadPipeName(string[] args)
{
for (var index = 0; index < args.Length; index++)
{
if (string.Equals(args[index], "--pipe", StringComparison.OrdinalIgnoreCase) &&
index + 1 < args.Length)
{
return args[index + 1];
}
}
return null;
}
sealed record ResponseMetadata(
string ETag,
string LastModified,
string AcceptRanges,
long? ContentLength,
string FinalUrl)
{
public bool ResumeSupported =>
AcceptRanges.Contains("bytes", StringComparison.OrdinalIgnoreCase) ||
!string.IsNullOrWhiteSpace(ETag) ||
!string.IsNullOrWhiteSpace(LastModified);
public static ResponseMetadata From(HttpResponseMessage response)
{
var requestUri = response.RequestMessage?.RequestUri?.ToString() ?? string.Empty;
return new ResponseMetadata(
response.Headers.ETag?.ToString() ?? string.Empty,
response.Content.Headers.LastModified?.ToString() ?? response.Headers.Date?.ToString() ?? string.Empty,
string.Join(",", response.Headers.AcceptRanges.Select(value => value.ToString())),
response.Content.Headers.ContentRange?.Length ?? response.Content.Headers.ContentLength,
requestUri);
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>YMhut.Box.DownloadHost</AssemblyName>
<RootNamespace>YMhut.Box.DownloadHost</RootNamespace>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\YMhut.Box.Core\YMhut.Box.Core.csproj" />
</ItemGroup>
</Project>
+749
View File
@@ -0,0 +1,749 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.IO.Pipes;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using YMhut.Box.Core.Api;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Data;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Net;
using YMhut.Box.Core.Plugins;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.Tools;
Console.OutputEncoding = Encoding.UTF8;
var pipeName = ReadPipeName(args);
if (string.IsNullOrWhiteSpace(pipeName))
{
Console.Error.WriteLine("Missing --pipe argument.");
return 2;
}
var appPaths = AppPaths.ForCurrentUser();
var logService = new SqliteLogService(appPaths);
var settingsService = new AppSettingsService(new AppSettingsStore(appPaths.Root));
await settingsService.LoadAsync().ConfigureAwait(false);
using var httpService = new HttpService(logService: logService, settingsService: settingsService);
var apiManager = new ApiManager(httpService, logService);
var referenceDataService = new ReferenceDataService(appPaths);
var stateStore = new PluginStateStore(appPaths);
var builtInPluginInstaller = new BuiltInPluginInstallerService(appPaths, logService, settingsService);
var registry = new PluginRegistryService(appPaths, stateStore, logService, settingsService, builtInPluginInstaller);
var writeGate = new SemaphoreSlim(1, 1);
var snapshotGate = new SemaphoreSlim(1, 1);
var runtimeValues = new ConcurrentDictionary<string, ConcurrentDictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
FileSystemWatcher? watcher = null;
CancellationTokenSource? reloadDebounce = null;
PluginSnapshot? currentSnapshot = null;
var backgroundRefreshActive = 0;
await using var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
await pipe.ConnectAsync(8000).ConfigureAwait(false);
using var reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
await using var writer = new StreamWriter(pipe, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), bufferSize: 4096, leaveOpen: true)
{
AutoFlush = true
};
await WriteAsync(new PluginHostMessage(PluginHostProtocol.Ready, Version: PluginHostProtocol.Version), CancellationToken.None)
.ConfigureAwait(false);
await logService.WriteAsync("Information", "plugin-host", "Plugin host ready").ConfigureAwait(false);
ConfigureWatcher();
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
{
var message = PluginHostProtocol.Deserialize(line);
if (message is null)
{
continue;
}
if (string.Equals(message.Type, PluginHostProtocol.Shutdown, StringComparison.Ordinal))
{
break;
}
_ = Task.Run(async () => await HandleAsync(message).ConfigureAwait(false));
}
watcher?.Dispose();
reloadDebounce?.Cancel();
reloadDebounce?.Dispose();
return 0;
async Task HandleAsync(PluginHostMessage message)
{
try
{
switch (message.Type)
{
case PluginHostProtocol.Ping:
await WriteAsync(new PluginHostMessage(PluginHostProtocol.Pong, message.RequestId), CancellationToken.None).ConfigureAwait(false);
break;
case PluginHostProtocol.GetSnapshot:
var hadSnapshot = currentSnapshot is not null;
await EnsureSnapshotAvailableAsync(CancellationToken.None).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.GetSnapshot, message.RequestId, Snapshot: currentSnapshot), CancellationToken.None).ConfigureAwait(false);
if (hadSnapshot)
{
QueueSnapshotRefresh();
}
break;
case PluginHostProtocol.Reload:
await settingsService.LoadAsync().ConfigureAwait(false);
await ReloadSnapshotAsync(broadcast: true, CancellationToken.None).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.Reload, message.RequestId, Snapshot: currentSnapshot), CancellationToken.None).ConfigureAwait(false);
break;
case PluginHostProtocol.SetPluginEnabled:
await stateStore.SetEnabledAsync(Required(message.PluginId), message.Enabled == true).ConfigureAwait(false);
await ReloadSnapshotAsync(broadcast: true, CancellationToken.None).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.SetPluginEnabled, message.RequestId, Snapshot: currentSnapshot), CancellationToken.None).ConfigureAwait(false);
break;
case PluginHostProtocol.SetPermission:
await stateStore.SetPermissionAsync(Required(message.PluginId), message.Permission ?? throw new InvalidOperationException("Missing permission."), message.Granted == true).ConfigureAwait(false);
await ReloadSnapshotAsync(broadcast: true, CancellationToken.None).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.SetPermission, message.RequestId, Snapshot: currentSnapshot), CancellationToken.None).ConfigureAwait(false);
break;
case PluginHostProtocol.SetSurfaceMounted:
await stateStore.SetSurfaceMountedAsync(Required(message.PluginId), Required(message.SurfaceId), message.Mounted == true).ConfigureAwait(false);
await ReloadSnapshotAsync(broadcast: true, CancellationToken.None).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.SetSurfaceMounted, message.RequestId, Snapshot: currentSnapshot), CancellationToken.None).ConfigureAwait(false);
break;
case PluginHostProtocol.BridgeCall:
var response = await HandleBridgeAsync(message.BridgeRequest ?? throw new InvalidOperationException("Missing bridge request.")).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.BridgeCall, message.RequestId, BridgeResponse: response), CancellationToken.None).ConfigureAwait(false);
break;
default:
await WriteAsync(new PluginHostMessage(PluginHostProtocol.Error, message.RequestId, Error: $"Unknown message type: {message.Type}"), CancellationToken.None).ConfigureAwait(false);
break;
}
}
catch (Exception exception)
{
await logService.WriteAsync("Error", "plugin-host", "Plugin host request failed", exception.Message).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.Error, message.RequestId, Error: exception.Message), CancellationToken.None).ConfigureAwait(false);
}
}
async Task EnsureSnapshotAvailableAsync(CancellationToken cancellationToken)
{
if (currentSnapshot is not null)
{
return;
}
await settingsService.LoadAsync(cancellationToken).ConfigureAwait(false);
await ReloadSnapshotAsync(broadcast: false, cancellationToken).ConfigureAwait(false);
}
void QueueSnapshotRefresh()
{
if (Interlocked.Exchange(ref backgroundRefreshActive, 1) == 1)
{
return;
}
_ = Task.Run(async () =>
{
try
{
await settingsService.LoadAsync().ConfigureAwait(false);
await ReloadSnapshotAsync(broadcast: true, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception exception)
{
await logService.WriteAsync("Warning", "plugin-host", "Plugin background refresh failed", exception.Message).ConfigureAwait(false);
}
finally
{
Interlocked.Exchange(ref backgroundRefreshActive, 0);
}
});
}
async Task ReloadSnapshotAsync(bool broadcast, CancellationToken cancellationToken)
{
await snapshotGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var plugins = await registry.LoadPluginsAsync(cancellationToken).ConfigureAwait(false);
var tools = settingsService.Current.PluginsEnabled
? plugins
.Where(plugin => plugin.IsValid && plugin.State.Enabled)
.SelectMany(plugin => plugin.Manifest.Surfaces
.Where(surface => surface.Kind == PluginSurfaceKind.ToolboxTool &&
(plugin.State.MountedSurfaceIds.Count == 0 || plugin.State.MountedSurfaceIds.Contains(surface.Id)))
.Select(surface => new PluginToolModule(plugin, surface)))
.ToArray()
: [];
currentSnapshot = new PluginSnapshot(
settingsService.Current.PluginsEnabled,
registry.PluginsRoot,
plugins.Select(LoadedPluginDto.FromLoadedPlugin).ToArray(),
tools.Select(PluginToolDto.FromPluginToolModule).ToArray(),
DateTimeOffset.Now);
ConfigureWatcher();
}
finally
{
snapshotGate.Release();
}
if (broadcast && currentSnapshot is not null)
{
await WriteAsync(new PluginHostMessage(PluginHostProtocol.SnapshotChanged, Snapshot: currentSnapshot), CancellationToken.None).ConfigureAwait(false);
}
}
void ConfigureWatcher()
{
if (!settingsService.Current.PluginsEnabled)
{
watcher?.Dispose();
watcher = null;
return;
}
Directory.CreateDirectory(registry.PluginsRoot);
if (watcher is not null && string.Equals(watcher.Path, registry.PluginsRoot, StringComparison.OrdinalIgnoreCase))
{
return;
}
watcher?.Dispose();
watcher = new FileSystemWatcher(registry.PluginsRoot)
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size,
EnableRaisingEvents = true
};
watcher.Created += PluginFilesChanged;
watcher.Changed += PluginFilesChanged;
watcher.Deleted += PluginFilesChanged;
watcher.Renamed += PluginFilesChanged;
watcher.Error += (_, e) => _ = logService.WriteAsync("Warning", "plugin-host", "Plugin watcher failed", e.GetException().Message);
}
void PluginFilesChanged(object sender, FileSystemEventArgs e)
{
reloadDebounce?.Cancel();
reloadDebounce?.Dispose();
var cts = new CancellationTokenSource();
reloadDebounce = cts;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(500, cts.Token).ConfigureAwait(false);
await settingsService.LoadAsync(cts.Token).ConfigureAwait(false);
await ReloadSnapshotAsync(broadcast: true, cts.Token).ConfigureAwait(false);
await logService.WriteAsync("Information", "plugin-host", "Plugin hot reload", e.Name).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
catch (Exception exception)
{
await logService.WriteAsync("Error", "plugin-host", "Plugin hot reload failed", exception.Message).ConfigureAwait(false);
}
});
}
async Task<PluginBridgeResponse> HandleBridgeAsync(PluginBridgeRequest request)
{
var plugin = await FindPluginAsync(request.PluginId).ConfigureAwait(false);
if (plugin is null || !plugin.IsValid || !plugin.State.Enabled)
{
return Fail("Plugin is not enabled or valid.");
}
try
{
using var document = string.IsNullOrWhiteSpace(request.PayloadJson)
? JsonDocument.Parse("null")
: JsonDocument.Parse(request.PayloadJson);
var payload = document.RootElement;
return request.Method switch
{
"input.get" => JsonOk(GetRuntime(plugin.Manifest.Id, "input", "{}")),
"input.set" => SetRuntime(plugin.Manifest.Id, "input", JsonValue(payload), PluginPermission.Input),
"output.set" => AuthorizeUi(plugin, PluginPermission.Output),
"output.append" => AuthorizeUi(plugin, PluginPermission.Output),
"output.clear" => AuthorizeUi(plugin, PluginPermission.Output),
"log.info" => await LogAsync(plugin, "Information", payload).ConfigureAwait(false),
"log.warn" => await LogAsync(plugin, "Warning", payload).ConfigureAwait(false),
"log.error" => await LogAsync(plugin, "Error", payload).ConfigureAwait(false),
"storage.get" => await GetStorageAsync(plugin, payload).ConfigureAwait(false),
"storage.set" => await SetStorageAsync(plugin, payload).ConfigureAwait(false),
"storage.remove" => await RemoveStorageAsync(plugin, payload).ConfigureAwait(false),
"storage.list" => await ListStorageAsync(plugin).ConfigureAwait(false),
"http.fetch" => await FetchAsync(plugin, payload).ConfigureAwait(false),
"network.ping" => await PingAsync(plugin, payload).ConfigureAwait(false),
"network.dnsLookup" => await DnsLookupAsync(plugin, payload).ConfigureAwait(false),
"network.diagnostics" => NetworkDiagnostics(plugin),
"network.traceRoute" => await TraceRouteAsync(plugin, payload).ConfigureAwait(false),
"tool.run" => await RunToolAsync(plugin, payload).ConfigureAwait(false),
"clipboard.readText" => AuthorizeUi(plugin, PluginPermission.Clipboard, "clipboard.readText"),
"clipboard.writeText" => AuthorizeUi(plugin, PluginPermission.Clipboard, "clipboard.writeText"),
"file.openPicker" => AuthorizeUi(plugin, PluginPermission.FilePicker, "file.openPicker"),
"file.savePicker" => AuthorizeUi(plugin, PluginPermission.FilePicker, "file.savePicker"),
"openExternal" => ValidateExternal(plugin, payload),
_ => Fail($"Unknown plugin bridge method: {request.Method}")
};
}
catch (Exception exception)
{
return Fail(exception.Message);
}
}
async Task<LoadedPlugin?> FindPluginAsync(string pluginId)
{
var snapshot = currentSnapshot;
if (snapshot is null)
{
await ReloadSnapshotAsync(broadcast: false, CancellationToken.None).ConfigureAwait(false);
snapshot = currentSnapshot;
}
return snapshot?.Plugins
.FirstOrDefault(plugin => string.Equals(plugin.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
?.ToLoadedPlugin();
}
PluginBridgeResponse SetRuntime(string pluginId, string key, string value, PluginPermission permission)
{
EnsurePermissionById(pluginId, permission);
Runtime(pluginId)[key] = value;
return JsonOk(true);
}
PluginBridgeResponse AuthorizeUi(LoadedPlugin plugin, PluginPermission permission, string? uiAction = null)
{
EnsurePluginPermission(plugin, permission);
return new PluginBridgeResponse(true, JsonSerializer.Serialize(true), UiAction: uiAction);
}
PluginBridgeResponse ValidateExternal(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.OpenExternal);
var value = payload.ValueKind == JsonValueKind.Object
? ReadString(payload, "url") ?? ReadString(payload, "uri") ?? string.Empty
: JsonValue(payload);
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return Fail("Only absolute http/https URLs can be opened externally.");
}
return new PluginBridgeResponse(true, JsonSerializer.Serialize(true), UiAction: "openExternal");
}
async Task<PluginBridgeResponse> LogAsync(LoadedPlugin plugin, string level, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.Log);
var message = ReadString(payload, "message") ?? JsonValue(payload);
var detail = ReadString(payload, "detail");
await logService.WriteAsync(level, $"plugin:{plugin.Manifest.Id}", message, detail).ConfigureAwait(false);
return JsonOk(true);
}
async Task<PluginBridgeResponse> GetStorageAsync(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.Storage);
var value = await stateStore.GetValueAsync(plugin.Manifest.Id, JsonValue(payload)).ConfigureAwait(false);
return JsonOk(value);
}
async Task<PluginBridgeResponse> SetStorageAsync(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.Storage);
var key = ReadString(payload, "key") ?? throw new InvalidOperationException("storage.set requires key.");
var value = ReadString(payload, "value") ?? JsonValue(payload.GetProperty("value"));
await stateStore.SetValueAsync(plugin.Manifest.Id, key, value).ConfigureAwait(false);
return JsonOk(true);
}
async Task<PluginBridgeResponse> RemoveStorageAsync(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.Storage);
await stateStore.RemoveValueAsync(plugin.Manifest.Id, JsonValue(payload)).ConfigureAwait(false);
return JsonOk(true);
}
async Task<PluginBridgeResponse> ListStorageAsync(LoadedPlugin plugin)
{
EnsurePluginPermission(plugin, PluginPermission.Storage);
return JsonOk(await stateStore.ListValuesAsync(plugin.Manifest.Id).ConfigureAwait(false));
}
async Task<PluginBridgeResponse> FetchAsync(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.Http);
var rawUrl = payload.ValueKind == JsonValueKind.Object ? ReadString(payload, "url") ?? ReadString(payload, "uri") : JsonValue(payload);
if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return Fail("ymhut.http.fetch only accepts absolute http/https URLs.");
}
var method = payload.ValueKind == JsonValueKind.Object ? ReadString(payload, "method") ?? "GET" : "GET";
var body = payload.ValueKind == JsonValueKind.Object ? ReadString(payload, "body") : null;
var headers = ReadHeaders(payload);
var result = await httpService.SendAsync(uri, method, body, headers, ensureSuccess: false).ConfigureAwait(false);
await logService.WriteAsync("Information", $"plugin:{plugin.Manifest.Id}", "Plugin HTTP fetch", uri.Host).ConfigureAwait(false);
return JsonOk(new
{
status = (int)result.StatusCode,
ok = (int)result.StatusCode is >= 200 and < 300,
content = result.Content,
headers = result.Headers,
elapsedMs = (long)result.Elapsed.TotalMilliseconds
});
}
async Task<PluginBridgeResponse> PingAsync(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.NetworkDiagnostics);
var host = payload.ValueKind == JsonValueKind.Object ? ReadString(payload, "host") : JsonValue(payload);
if (string.IsNullOrWhiteSpace(host))
{
return Fail("network.ping requires host.");
}
var count = payload.ValueKind == JsonValueKind.Object ? ReadInt(payload, "count", 4, 1, 12) : 4;
var timeout = payload.ValueKind == JsonValueKind.Object ? ReadInt(payload, "timeoutMs", 2500, 500, 10000) : 2500;
var rows = new List<object>();
using var ping = new Ping();
for (var index = 0; index < count; index++)
{
try
{
var reply = await ping.SendPingAsync(host.Trim(), timeout).ConfigureAwait(false);
rows.Add(new
{
seq = index + 1,
status = reply.Status.ToString(),
address = reply.Address?.ToString() ?? string.Empty,
roundtripMs = reply.Status == IPStatus.Success ? reply.RoundtripTime : -1
});
}
catch (Exception exception)
{
rows.Add(new { seq = index + 1, status = "Error", address = string.Empty, roundtripMs = -1, error = exception.Message });
}
}
var success = rows
.Select(item => JsonSerializer.SerializeToElement(item))
.Where(item => string.Equals(ReadString(item, "status"), IPStatus.Success.ToString(), StringComparison.OrdinalIgnoreCase))
.Select(item => (double)ReadLong(item, "roundtripMs", -1))
.Where(value => value >= 0)
.ToArray();
return JsonOk(new
{
host = host.Trim(),
count,
sent = count,
received = success.Length,
lossPercent = Math.Round((count - success.Length) * 100d / count, 1),
minMs = success.Length == 0 ? -1 : Math.Round(success.Min(), 1),
avgMs = success.Length == 0 ? -1 : Math.Round(success.Average(), 1),
maxMs = success.Length == 0 ? -1 : Math.Round(success.Max(), 1),
replies = rows
});
}
async Task<PluginBridgeResponse> DnsLookupAsync(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.NetworkDiagnostics);
var host = payload.ValueKind == JsonValueKind.Object ? ReadString(payload, "host") : JsonValue(payload);
if (string.IsNullOrWhiteSpace(host))
{
return Fail("network.dnsLookup requires host.");
}
try
{
var started = DateTimeOffset.UtcNow;
var entries = await Dns.GetHostAddressesAsync(host.Trim()).ConfigureAwait(false);
return JsonOk(new
{
host = host.Trim(),
elapsedMs = (long)(DateTimeOffset.UtcNow - started).TotalMilliseconds,
addresses = entries.Select(address => new
{
value = address.ToString(),
family = address.AddressFamily.ToString()
}).ToArray()
});
}
catch (Exception exception)
{
return Fail(exception.Message);
}
}
async Task<PluginBridgeResponse> TraceRouteAsync(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.NetworkDiagnostics);
var host = payload.ValueKind == JsonValueKind.Object ? ReadString(payload, "host") : JsonValue(payload);
if (string.IsNullOrWhiteSpace(host))
{
return Fail("network.traceRoute requires host.");
}
var maxHops = payload.ValueKind == JsonValueKind.Object ? ReadInt(payload, "maxHops", 12, 1, 30) : 12;
var timeout = payload.ValueKind == JsonValueKind.Object ? ReadInt(payload, "timeoutMs", 2200, 500, 8000) : 2200;
var hops = new List<object>();
var buffer = Encoding.ASCII.GetBytes("ymhut");
using var ping = new Ping();
for (var ttl = 1; ttl <= maxHops; ttl++)
{
try
{
var options = new PingOptions(ttl, dontFragment: true);
var reply = await ping.SendPingAsync(host.Trim(), timeout, buffer, options).ConfigureAwait(false);
hops.Add(new
{
ttl,
status = reply.Status.ToString(),
address = reply.Address?.ToString() ?? "*",
roundtripMs = reply.Status is IPStatus.Success or IPStatus.TtlExpired ? reply.RoundtripTime : -1
});
if (reply.Status == IPStatus.Success)
{
break;
}
}
catch (Exception exception)
{
hops.Add(new { ttl, status = "Error", address = "*", roundtripMs = -1, error = exception.Message });
}
}
return JsonOk(new { host = host.Trim(), maxHops, hops });
}
PluginBridgeResponse NetworkDiagnostics(LoadedPlugin plugin)
{
EnsurePluginPermission(plugin, PluginPermission.NetworkDiagnostics);
var settings = settingsService.Current;
var interfaces = NetworkInterface.GetAllNetworkInterfaces()
.Where(item => item.NetworkInterfaceType != NetworkInterfaceType.Loopback)
.Select(item =>
{
var properties = item.GetIPProperties();
var statistics = item.GetIPv4Statistics();
return new
{
id = item.Id,
name = item.Name,
description = item.Description,
type = item.NetworkInterfaceType.ToString(),
status = item.OperationalStatus.ToString(),
speedMbps = item.Speed > 0 ? Math.Round(item.Speed / 1000d / 1000d, 1) : 0,
dnsSuffix = properties.DnsSuffix,
ipv4 = properties.UnicastAddresses
.Where(address => address.Address.AddressFamily == AddressFamily.InterNetwork)
.Select(address => address.Address.ToString())
.ToArray(),
ipv6 = properties.UnicastAddresses
.Where(address => address.Address.AddressFamily == AddressFamily.InterNetworkV6)
.Select(address => address.Address.ToString())
.ToArray(),
gateways = properties.GatewayAddresses.Select(address => address.Address.ToString()).ToArray(),
dnsServers = properties.DnsAddresses.Select(address => address.ToString()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
bytesReceived = statistics.BytesReceived,
bytesSent = statistics.BytesSent
};
})
.ToArray();
var active = interfaces.Where(item => string.Equals(item.status, "Up", StringComparison.OrdinalIgnoreCase)).ToArray();
var dnsServers = active.SelectMany(item => item.dnsServers).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
return JsonOk(new
{
generatedAt = DateTimeOffset.Now,
machine = Environment.MachineName,
os = Environment.OSVersion.VersionString,
processArchitecture = RuntimeInformation.ProcessArchitecture.ToString(),
uiCulture = CultureInfo.CurrentUICulture.Name,
localTimeZone = TimeZoneInfo.Local.Id,
proxy = new
{
enabled = settings.ProxyEnabled,
mode = settings.ProxyMode,
host = settings.ProxyEnabled ? settings.ProxyHost : string.Empty,
port = settings.ProxyEnabled ? settings.ProxyPort : 0
},
interfaces,
summary = new
{
interfaceCount = interfaces.Length,
activeInterfaceCount = active.Length,
ipv4Count = active.Sum(item => item.ipv4.Length),
ipv6Count = active.Sum(item => item.ipv6.Length),
dnsServers,
defaultGateways = active.SelectMany(item => item.gateways).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
},
offlineMode = true,
note = "Local diagnostics only. Public IP, ASN, external DNS leak, and internet speed require a remote observer and are intentionally not fetched by the built-in sample."
});
}
async Task<PluginBridgeResponse> RunToolAsync(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.RunTool);
var toolId = ReadString(payload, "toolId") ?? throw new InvalidOperationException("ymhut.tool.run requires toolId.");
var input = ReadString(payload, "input") ?? string.Empty;
if (PluginIds.IsPluginToolId(toolId))
{
return Fail("Plugins cannot call plugin tools through ymhut.tool.run.");
}
var catalog = new ToolCatalog();
var module = catalog.GetById(toolId) ?? throw new InvalidOperationException($"Tool was not found: {toolId}");
if (!module.Metadata.OfflineCapable)
{
return Fail("ymhut.tool.run v1 only allows offline built-in tools.");
}
var result = await ToolExecutor.ExecuteAsync(module, input, apiManager: apiManager, referenceDataService: referenceDataService).ConfigureAwait(false);
await logService.WriteAsync("Information", $"plugin:{plugin.Manifest.Id}", "Plugin ran built-in tool", toolId).ConfigureAwait(false);
return JsonOk(new { ok = result.Ok, output = result.Output, error = result.Error });
}
void EnsurePermissionById(string pluginId, PluginPermission permission)
{
var plugin = currentSnapshot?.Plugins.FirstOrDefault(item => string.Equals(item.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))?.ToLoadedPlugin()
?? throw new UnauthorizedAccessException("Plugin is not loaded.");
EnsurePluginPermission(plugin, permission);
}
static void EnsurePluginPermission(LoadedPlugin plugin, PluginPermission permission)
{
if (!plugin.State.GrantedPermissions.Contains(permission))
{
throw new UnauthorizedAccessException($"Plugin permission is not granted: {permission}");
}
}
ConcurrentDictionary<string, string> Runtime(string pluginId)
{
return runtimeValues.GetOrAdd(pluginId, _ => new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase));
}
string GetRuntime(string pluginId, string key, string fallback)
{
return Runtime(pluginId).TryGetValue(key, out var value) ? value : fallback;
}
static PluginBridgeResponse JsonOk(object? value)
{
return new PluginBridgeResponse(true, JsonSerializer.Serialize(value));
}
static PluginBridgeResponse Fail(string error)
{
return new PluginBridgeResponse(false, Error: error);
}
async Task WriteAsync(PluginHostMessage message, CancellationToken cancellationToken)
{
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();
}
}
static string Required(string? value)
{
return string.IsNullOrWhiteSpace(value) ? throw new InvalidOperationException("Missing required value.") : value;
}
static string JsonValue(JsonElement element)
{
return element.ValueKind == JsonValueKind.String ? element.GetString() ?? string.Empty : element.GetRawText();
}
static string? ReadString(JsonElement element, string property)
{
return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(property, out var value) ? JsonValue(value) : null;
}
static IReadOnlyDictionary<string, string>? ReadHeaders(JsonElement payload)
{
if (payload.ValueKind != JsonValueKind.Object ||
!payload.TryGetProperty("headers", out var headersElement) ||
headersElement.ValueKind != JsonValueKind.Object)
{
return null;
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var property in headersElement.EnumerateObject())
{
headers[property.Name] = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: property.Value.GetRawText();
}
return headers;
}
static int ReadInt(JsonElement element, string property, int fallback, int min, int max)
{
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
{
return fallback;
}
var parsed = value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)
? number
: int.TryParse(JsonValue(value), out var textNumber)
? textNumber
: fallback;
return Math.Clamp(parsed, min, max);
}
static long ReadLong(JsonElement element, string property, long fallback)
{
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
{
return fallback;
}
return value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var number)
? number
: long.TryParse(JsonValue(value), out var textNumber)
? textNumber
: fallback;
}
static string? ReadPipeName(string[] args)
{
for (var index = 0; index < args.Length; index++)
{
if (string.Equals(args[index], "--pipe", StringComparison.OrdinalIgnoreCase) && index + 1 < args.Length)
{
return args[index + 1];
}
}
return null;
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\YMhut.Box.Core\YMhut.Box.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,81 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.Data.Sqlite;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Settings;
namespace YMhut.Box.Tests;
[TestClass]
public sealed class AgreementAcceptanceStoreTests
{
[TestMethod]
public void OldBooleanAgreementSettingRequiresResign()
{
var settings = new AppSettings
{
UserAgreementAccepted = true
};
Assert.IsFalse(AgreementDocument.SettingsMatchCurrent(settings));
settings.UserAgreementVersion = AgreementDocument.CurrentVersion;
Assert.IsTrue(AgreementDocument.SettingsMatchCurrent(settings));
}
[TestMethod]
public async Task AgreementAcceptanceUsesMainSqliteAndSurvivesLogCleanup()
{
using var workspace = TestWorkspace.Create("ymhut-agreement-store");
var paths = AppPaths.ForCurrentUser(workspace.Root);
var store = new AgreementAcceptanceStore(paths);
var logService = new SqliteLogService(paths);
var acceptedAt = new DateTimeOffset(2026, 6, 15, 9, 30, 0, TimeSpan.Zero);
await store.RecordAcceptedAsync(
AgreementDocument.CurrentVersion,
AgreementDocument.CurrentRevision,
"2.0.6.2",
"zh-CN",
acceptedAt);
await logService.WriteAsync("Information", "test", "log before cleanup");
await logService.ClearAllAsync();
var latest = await store.GetLatestAsync();
Assert.AreEqual(Path.Combine(paths.Logs, AppDatabasePaths.MainDatabaseFileName), store.DatabasePath);
Assert.IsNotNull(latest);
Assert.AreEqual(AgreementDocument.CurrentVersion, latest.Version);
Assert.AreEqual(AgreementDocument.CurrentRevision, latest.Revision);
Assert.AreEqual("2.0.6.2", latest.AppVersion);
Assert.AreEqual("zh-CN", latest.Language);
Assert.AreEqual(acceptedAt, latest.AcceptedAt);
}
private sealed class TestWorkspace : IDisposable
{
private TestWorkspace(string root)
{
Root = root;
}
public string Root { get; }
public static TestWorkspace Create(string prefix)
{
var root = Path.Combine(Path.GetTempPath(), prefix, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return new TestWorkspace(root);
}
public void Dispose()
{
SqliteConnection.ClearAllPools();
if (Directory.Exists(Root))
{
Directory.Delete(Root, recursive: true);
}
}
}
}
@@ -0,0 +1,185 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YMhut.Box.Core.Settings;
namespace YMhut.Box.Tests;
[TestClass]
public sealed class AppSettingsStoreTests
{
[TestMethod]
public async Task NewSettingsDefaultToDashboardHome()
{
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
var store = new AppSettingsStore(root);
var settings = await store.LoadAsync();
Assert.AreEqual("dashboard", settings.HomePageMode);
}
[TestMethod]
public async Task RecordRecentToolKeepsNewestFirstAndUnique()
{
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
var store = new AppSettingsStore(root);
await store.RecordRecentToolAsync("json_formatter");
await store.RecordRecentToolAsync("safe_browser");
await store.RecordRecentToolAsync("json_formatter");
var settings = await store.LoadAsync();
CollectionAssert.AreEqual(
new[] { "json_formatter", "safe_browser" },
settings.RecentToolIds);
}
[TestMethod]
public async Task LoadAsyncImportsLegacySettingsOnce()
{
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"), "WinUI");
var legacyPath = Path.Combine(Path.GetDirectoryName(root)!, "YMhut Box", "settings.json");
Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!);
await File.WriteAllTextAsync(legacyPath, """
{
"theme": "Dark",
"recentToolIds": [
"safe_browser"
],
"legacyImportCompleted": false
}
""");
var store = new AppSettingsStore(root);
var settings = await store.LoadAsync();
Assert.IsTrue(settings.LegacyImportCompleted);
Assert.AreEqual("Dark", settings.Theme);
CollectionAssert.AreEqual(new[] { "safe_browser" }, settings.RecentToolIds);
}
[TestMethod]
public async Task LoadAsyncImportsLegacySharedPreferences()
{
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"), "WinUI");
var legacyPath = Path.Combine(Path.GetDirectoryName(root)!, "ymhut_box", "shared_preferences.json");
Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!);
var legacyPrefix = string.Concat("flut", "ter.");
await File.WriteAllTextAsync(legacyPath, """
{
"__PREFIX__theme": "dark",
"__PREFIX__pitch_black": true,
"__PREFIX__seed_color": -10458044,
"__PREFIX__card_opacity": 0.64,
"__PREFIX__background_image": "D:\\Images\\wallpaper.png",
"__PREFIX__background_opacity": 0.42,
"__PREFIX__user_agreement_accepted": true,
"__PREFIX__sidebar_collapsed": true,
"__PREFIX__desktop_titlebar": false,
"__PREFIX__recent_tool_ids": [
"media_player",
"safe_browser"
],
"__PREFIX__pinned_tool_ids": [
"json_formatter"
],
"__PREFIX__window_x": 12.5,
"__PREFIX__window_y": 24.5,
"__PREFIX__window_width": 1280,
"__PREFIX__window_height": 760,
"__PREFIX__window_maximized": true,
"__PREFIX__proxy_enabled": true,
"__PREFIX__proxy_mode": "manual",
"__PREFIX__proxy_host": "127.0.0.1",
"__PREFIX__proxy_port": 7891,
"__PREFIX__animations_enabled": false,
"__PREFIX__font_size": 1.15,
"__PREFIX__tool_display_mode": "list",
"__PREFIX__close_behavior": "minimize_then_exit",
"__PREFIX__restore_window_position": false,
"__PREFIX__auto_start": true,
"__PREFIX__log_retention_count": 300,
"__PREFIX__data_refresh_interval": 45,
"__PREFIX__update_notification": false,
"__PREFIX__hardware_acceleration_enabled": false,
"__PREFIX__proxy_test_timeout_seconds": 9
}
""".Replace("__PREFIX__", legacyPrefix));
var store = new AppSettingsStore(root);
var settings = await store.LoadAsync();
Assert.AreEqual("Dark", settings.Theme);
Assert.IsTrue(settings.PitchBlack);
Assert.AreEqual(-10458044, settings.SeedColor);
Assert.AreEqual(0.64, settings.CardOpacity, 0.001);
Assert.AreEqual("D:\\Images\\wallpaper.png", settings.BackgroundImage);
Assert.AreEqual(0.42, settings.BackgroundOpacity, 0.001);
Assert.IsTrue(settings.UserAgreementAccepted);
Assert.IsTrue(settings.SidebarCollapsed);
Assert.IsFalse(settings.DesktopTitlebar);
CollectionAssert.AreEqual(new[] { "media_player", "safe_browser" }, settings.RecentToolIds);
CollectionAssert.AreEqual(new[] { "json_formatter" }, settings.PinnedToolIds);
Assert.AreEqual(12.5, settings.WindowX);
Assert.AreEqual(24.5, settings.WindowY);
Assert.AreEqual(1280, settings.WindowWidth);
Assert.AreEqual(760, settings.WindowHeight);
Assert.IsTrue(settings.WindowMaximized);
Assert.IsTrue(settings.ProxyEnabled);
Assert.AreEqual("manual", settings.ProxyMode);
Assert.AreEqual("127.0.0.1", settings.ProxyHost);
Assert.AreEqual(7891, settings.ProxyPort);
Assert.IsFalse(settings.AnimationsEnabled);
Assert.AreEqual(1.15, settings.FontSize, 0.001);
Assert.AreEqual("list", settings.ToolDisplayMode);
Assert.AreEqual("minimize_then_exit", settings.CloseBehavior);
Assert.IsFalse(settings.RestoreWindowPosition);
Assert.IsTrue(settings.AutoStart);
Assert.AreEqual(300, settings.LogRetentionCount);
Assert.AreEqual(45, settings.DataRefreshInterval);
Assert.IsFalse(settings.UpdateNotification);
Assert.IsFalse(settings.HardwareAccelerationEnabled);
Assert.AreEqual(9, settings.ProxyTestTimeoutSeconds);
}
[TestMethod]
public void PinnedToolSettingsTogglesUniquelyAndTrims()
{
var pinned = Enumerable.Range(0, 70)
.Select(index => $"tool_{index:00}")
.ToList();
pinned.Insert(10, "json_formatter");
PinnedToolSettings.Toggle(pinned, "json_formatter", wasPinned: false);
Assert.HasCount(64, pinned);
Assert.AreEqual("json_formatter", pinned[0]);
Assert.AreEqual(1, pinned.Count(id => string.Equals(id, "json_formatter", StringComparison.OrdinalIgnoreCase)));
PinnedToolSettings.Toggle(pinned, "JSON_FORMATTER", wasPinned: true);
Assert.HasCount(63, pinned);
Assert.IsFalse(pinned.Any(id => string.Equals(id, "json_formatter", StringComparison.OrdinalIgnoreCase)));
}
[TestMethod]
public async Task LoadAsyncNormalizesGlobalMaterialSettings()
{
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
await File.WriteAllTextAsync(Path.Combine(root, "settings.json"), """
{
"legacyImportCompleted": true,
"windowBackdrop": "desktop_acrylic",
"settingsPanelMaterial": "frosted_glass",
"topBarMaterial": "frosted_glass"
}
""");
var store = new AppSettingsStore(root);
var settings = await store.LoadAsync();
Assert.AreEqual("acrylic", settings.WindowBackdrop);
Assert.AreEqual("glass", settings.SettingsPanelMaterial);
Assert.AreEqual("glass", settings.TopBarMaterial);
}
}
+3
View File
@@ -0,0 +1,3 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
@@ -0,0 +1,377 @@
using System.Net;
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YMhut.Box.Core.App;
using YMhut.Box.Core.DevEnvironments;
using YMhut.Box.Core.Downloads;
using YMhut.Box.Core.Net;
using YMhut.Box.Core.Updates;
namespace YMhut.Box.Tests;
[TestClass]
public sealed class DownloadAndDevEnvironmentTests
{
[TestMethod]
public void DevEnvironmentVersionParserRecognizesSupportedTools()
{
Assert.AreEqual("1.23.4", DevEnvironmentVersionParser.Parse("go", "go version go1.23.4 windows/amd64"));
Assert.AreEqual("3.12.7", DevEnvironmentVersionParser.Parse("python", "Python 3.12.7"));
Assert.AreEqual("21.0.2", DevEnvironmentVersionParser.Parse("java", "openjdk version \"21.0.2\" 2024-01-16"));
Assert.AreEqual("27.3.1", DevEnvironmentVersionParser.Parse("docker", "Docker version 27.3.1, build ce12230"));
Assert.AreEqual("8.0.36", DevEnvironmentVersionParser.Parse("mysql", "mysql Ver 8.0.36 for Win64 on x86_64"));
Assert.AreEqual("22.11.0", DevEnvironmentVersionParser.Parse("node", "v22.11.0"));
Assert.AreEqual("10.0.100", DevEnvironmentVersionParser.Parse("dotnet", "10.0.100\r\n9.0.308"));
Assert.AreEqual("2.47.1.windows.2", DevEnvironmentVersionParser.Parse("git", "git version 2.47.1.windows.2"));
Assert.AreEqual("1.82.0", DevEnvironmentVersionParser.Parse("rust", "rustc 1.82.0 (f6e511eec 2024-10-15)"));
Assert.AreEqual("3.31.0", DevEnvironmentVersionParser.Parse("cmake", "cmake version 3.31.0"));
}
[TestMethod]
public async Task DevEnvironmentCatalogReadsOfficialVersionMetadata()
{
var workspace = TempWorkspace();
var paths = AppPaths.ForCurrentUser(workspace);
var service = new DevEnvironmentCatalogService(
paths,
new FakeHttpService(uri => uri.AbsoluteUri switch
{
"https://go.dev/dl/?mode=json" => """
[
{
"version": "go1.23.4",
"stable": true,
"files": [
{ "filename": "go1.23.4.windows-amd64.msi", "sha256": "abc" },
{ "filename": "go1.23.4.src.tar.gz", "sha256": "def" }
]
}
]
""",
"https://nodejs.org/dist/index.json" => """
[
{ "version": "v22.11.0", "date": "2024-10-29" }
]
""",
"https://api.github.com/repos/git-for-windows/git/releases" => """
[
{
"tag_name": "v2.47.1.windows.2",
"published_at": "2024-10-01T00:00:00Z",
"html_url": "https://github.com/git-for-windows/git/releases/tag/v2.47.1.windows.2",
"assets": [
{
"name": "Git-2.47.1-64-bit.exe",
"browser_download_url": "https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.2/Git-2.47.1-64-bit.exe",
"size": 1024
}
]
}
]
""",
_ => """
{
"releases-index": [
{
"channel-version": "10.0",
"latest-sdk": "10.0.100",
"release-date": "2025-11-11"
}
]
}
"""
}));
var go = await service.GetVersionsAsync("go");
var node = await service.GetVersionsAsync("node");
var dotnet = await service.GetVersionsAsync("dotnet");
Assert.AreEqual("1.23.4", go[0].Version);
Assert.AreEqual("go1.23.4.windows-amd64.msi", go[0].Installer?.FileName);
Assert.AreEqual("go1.23.4.src.tar.gz", go[0].SourceArchive?.FileName);
Assert.AreEqual("22.11.0", node[0].Version);
Assert.AreEqual("node-v22.11.0-x64.msi", node[0].Installer?.FileName);
Assert.IsTrue(node[0].AllCandidates.Any(candidate => candidate.SourceType == "MirrorCN"));
Assert.AreEqual("10.0.100", dotnet[0].Version);
Assert.AreEqual("dotnet-sdk-10.0.100-win-x64.exe", dotnet[0].Installer?.FileName);
}
[TestMethod]
public async Task DevEnvironmentCatalogReadsGithubReleaseAssets()
{
var workspace = TempWorkspace();
var service = new DevEnvironmentCatalogService(
AppPaths.ForCurrentUser(workspace),
new FakeHttpService(_ => """
[
{
"tag_name": "v3.31.0",
"published_at": "2024-11-01T00:00:00Z",
"html_url": "https://github.com/Kitware/CMake/releases/tag/v3.31.0",
"assets": [
{
"name": "cmake-3.31.0-windows-x86_64.msi",
"browser_download_url": "https://github.com/Kitware/CMake/releases/download/v3.31.0/cmake-3.31.0-windows-x86_64.msi",
"size": 2048
},
{
"name": "cmake-3.31.0.tar.gz",
"browser_download_url": "https://github.com/Kitware/CMake/releases/download/v3.31.0/cmake-3.31.0.tar.gz",
"size": 4096
}
]
}
]
"""));
var cmake = await service.GetVersionsAsync("cmake");
Assert.HasCount(1, cmake);
Assert.AreEqual("3.31.0", cmake[0].Version);
Assert.IsTrue(cmake[0].AllCandidates.Any(candidate => candidate.Mode == DevEnvironmentInstallMode.QuickInstall && candidate.SourceType == "GitHub"));
Assert.IsTrue(cmake[0].AllCandidates.Any(candidate => candidate.Mode == DevEnvironmentInstallMode.SourceBuild && candidate.Source.SizeBytes == 4096));
}
[TestMethod]
public async Task DownloadQueueStorePersistsAndRestoresItems()
{
var workspace = TempWorkspace();
var store = new DownloadQueueStore(AppPaths.ForCurrentUser(workspace));
var item = DownloadItem.Create(
new DownloadSource("https://example.com/tool.exe", "Tool", "tool.exe"),
Path.Combine(workspace, "tool.exe"),
"installer")
.WithResumeMetadata(
"\"abc\"",
"Wed, 10 Jun 2026 08:00:00 GMT",
"bytes",
2048,
"https://cdn.example.com/tool.exe",
true)
.WithProgress(DownloadState.Completed, 1024, 2048, 0);
await store.SaveAsync([item]);
var loaded = await store.LoadAsync();
Assert.HasCount(1, loaded);
Assert.AreEqual(item.Id, loaded[0].Id);
Assert.AreEqual(DownloadState.Completed, loaded[0].State);
Assert.AreEqual("tool.exe", loaded[0].Source.FileName);
Assert.AreEqual("installer", loaded[0].InstallCommand);
Assert.AreEqual(Path.Combine(workspace, "tool.exe.partial"), loaded[0].EffectivePartialPath);
Assert.AreEqual("\"abc\"", loaded[0].ETag);
Assert.AreEqual("Wed, 10 Jun 2026 08:00:00 GMT", loaded[0].LastModified);
Assert.AreEqual("bytes", loaded[0].AcceptRanges);
Assert.AreEqual(2048, loaded[0].ContentLength);
Assert.AreEqual("https://cdn.example.com/tool.exe", loaded[0].FinalUrl);
Assert.IsTrue(loaded[0].ResumeSupported);
}
[TestMethod]
public async Task DownloadQueueStorePersistsSettings()
{
var workspace = TempWorkspace();
var store = new DownloadQueueStore(AppPaths.ForCurrentUser(workspace));
var directory = Path.Combine(workspace, "custom");
await store.SaveSettingsAsync(new DownloadSettings(directory, 99));
var loaded = await store.LoadSettingsAsync();
Assert.AreEqual(directory, loaded.DefaultDirectory);
Assert.AreEqual(5, loaded.MaxConcurrentDownloads);
}
[TestMethod]
public void DownloadProgressAndProtocolRoundTrip()
{
var progress = new DownloadProgressSnapshot(
"a",
DownloadState.Running,
512,
1024,
2048,
ETag: "\"abc\"",
LastModified: "Wed, 10 Jun 2026 08:00:00 GMT",
AcceptRanges: "bytes",
ContentLength: 1024,
FinalUrl: "https://cdn.example.com/a.zip");
var message = new DownloadHostMessage(DownloadHostProtocol.Progress, progress.Id, DownloadHostProtocol.Version, Progress: progress);
var serialized = DownloadHostProtocol.Serialize(message);
var roundTrip = DownloadHostProtocol.Deserialize(serialized);
Assert.AreEqual(0.5, progress.Progress, 0.001);
Assert.IsNotNull(roundTrip);
Assert.AreEqual(DownloadHostProtocol.Progress, roundTrip.Type);
Assert.AreEqual(DownloadState.Running, roundTrip.Progress?.State);
Assert.AreEqual(2048, roundTrip.Progress?.BytesPerSecond);
Assert.AreEqual("\"abc\"", roundTrip.Progress?.ETag);
Assert.AreEqual("bytes", roundTrip.Progress?.AcceptRanges);
Assert.AreEqual("https://cdn.example.com/a.zip", roundTrip.Progress?.FinalUrl);
}
[TestMethod]
public async Task DirectDownloadValidatorParsesInternetShortcut()
{
using var validator = new DirectDownloadValidator(new HttpClient(new StubHttpHandler(request =>
{
var content = new ByteArrayContent(Encoding.UTF8.GetBytes("package"));
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip");
return new HttpResponseMessage(HttpStatusCode.OK)
{
RequestMessage = request,
Content = content
};
})));
var result = await validator.ValidateAsync("""
[InternetShortcut]
URL=https://example.com/package.zip
""");
Assert.IsTrue(result.IsDirectDownload);
Assert.IsTrue(result.WasInternetShortcut);
Assert.AreEqual("https://example.com/package.zip", result.EffectiveUrl);
}
[TestMethod]
public async Task DirectDownloadValidatorRejectsHtmlDownloadPage()
{
using var validator = new DirectDownloadValidator(new HttpClient(new StubHttpHandler(request =>
{
var content = new ByteArrayContent(Encoding.UTF8.GetBytes("<!doctype html><html></html>"));
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/html");
return new HttpResponseMessage(HttpStatusCode.OK)
{
RequestMessage = request,
Content = content
};
})));
var result = await validator.ValidateAsync("https://example.com/download");
Assert.IsFalse(result.IsDirectDownload);
StringAssert.Contains(result.Message, "web page");
}
[TestMethod]
public async Task ReleaseManifestSerializerRoundTripsFullInstallerManifest()
{
var workspace = TempWorkspace();
var path = Path.Combine(workspace, "manifest.json");
var manifest = new ReleaseManifest(
"2.0.7.0",
"2.0.7.0",
"stable",
DateTimeOffset.Parse("2026-06-13T00:00:00Z"),
[
new ReleaseFileEntry("downloads/YMhut_Box_WinUI_Setup_2.0.7.0.exe", 445_000_000, "installer"),
new ReleaseFileEntry("downloads/YMhut_Box_WinUI_2.0.7.0.msix", 571_000_000, "msix")
],
[],
"https://update.ymhut.cn/downloads/",
"Full");
await ReleaseManifestSerializer.WriteManifestAsync(path, manifest);
var loaded = await ReleaseManifestSerializer.ReadManifestAsync(path);
Assert.AreEqual("2.0.7.0", loaded.Version);
Assert.AreEqual("stable", loaded.Channel);
Assert.AreEqual("Full", loaded.Flavor);
Assert.HasCount(2, loaded.Files);
Assert.IsTrue(loaded.Files.All(file =>
file.Path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ||
file.Path.EndsWith(".msix", StringComparison.OrdinalIgnoreCase)));
}
[TestMethod]
public void UpdateInfoEndpointPolicyPrefersCanonicalUpdateInfo()
{
var endpoints = UpdateInfoEndpointPolicy.BuildDefaultUris("https://update.ymhut.cn/");
CollectionAssert.AreEqual(
new[]
{
"https://update.ymhut.cn/api/client/bootstrap",
"https://update.ymhut.cn/update-info.json",
"https://update.ymhut.cn/update-info",
"https://update.ymhut.cn/api/update-info"
},
endpoints.Select(endpoint => endpoint.ToString()).ToArray());
}
[TestMethod]
public async Task HttpRedirectResolverFollowsMultiHopAndRelativeLocations()
{
using var client = new HttpClient(new StubHttpHandler(request =>
{
return request.RequestUri!.AbsoluteUri switch
{
"https://media.example/start" => new HttpResponseMessage(HttpStatusCode.Redirect)
{
Headers = { Location = new Uri("/hop-1", UriKind.Relative) },
RequestMessage = request
},
"https://media.example/hop-1" => new HttpResponseMessage(HttpStatusCode.TemporaryRedirect)
{
Headers = { Location = new Uri("https://cdn.example/final.mp4") },
RequestMessage = request
},
"https://cdn.example/final.mp4" => new HttpResponseMessage(HttpStatusCode.OK)
{
RequestMessage = request,
Content = new ByteArrayContent(Array.Empty<byte>())
},
_ => new HttpResponseMessage(HttpStatusCode.NotFound)
{
RequestMessage = request,
Content = new StringContent("missing")
}
};
}));
var resolved = await HttpRedirectResolver.ResolveAsync(new Uri("https://media.example/start"), client);
Assert.AreEqual("https://cdn.example/final.mp4", resolved.ToString());
}
private static string TempWorkspace()
{
var path = Path.Combine(Path.GetTempPath(), "ymhut-box-download-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private sealed class FakeHttpService(Func<Uri, string> handler) : IHttpService
{
public Task<HttpServiceResult> GetAsync(Uri uri, CancellationToken cancellationToken = default)
{
return Task.FromResult(new HttpServiceResult(HttpStatusCode.OK, handler(uri), new Dictionary<string, string[]>(), TimeSpan.Zero));
}
public Task<string> GetStringAsync(Uri uri, CancellationToken cancellationToken = default)
{
return Task.FromResult(handler(uri));
}
public Task<HttpServiceResult> SendAsync(
Uri uri,
string method = "GET",
string? body = null,
IReadOnlyDictionary<string, string>? headers = null,
bool ensureSuccess = true,
HttpRequestPolicy? policy = null,
CancellationToken cancellationToken = default)
{
return GetAsync(uri, cancellationToken);
}
}
private sealed class StubHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(handler(request));
}
}
}
+409
View File
@@ -0,0 +1,409 @@
using System.IO.Compression;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Feedback;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.System;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.Tests;
[TestClass]
public sealed class FeedbackServiceTests
{
[TestMethod]
public void FeedbackCodeCreatesServerCompatibleCode()
{
var code = FeedbackCode.Create(DateTimeOffset.Parse("2026-06-04T10:00:00+08:00"));
Assert.IsTrue(Regex.IsMatch(code, "^FB-20260604-[A-F0-9]{6}$"));
Assert.AreEqual("FB-20260604-ABC123", FeedbackCode.Normalize(" fb-20260604-abc123 "));
Assert.IsTrue(FeedbackCode.IsValid("fb-20260604-abc123"));
Assert.IsFalse(FeedbackCode.IsValid("FB-20260604-XYZ123"));
}
[TestMethod]
public async Task FeedbackPackageIncludesSelectedAttachmentsAndLocalizedLogs()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-tests", Guid.NewGuid().ToString("N"));
var paths = AppPaths.ForCurrentUser(tempRoot);
var logs = new JsonFileLogService(paths);
var service = new FeedbackPackageService(logs, new FakeSystemMetricsService(), new ToolCatalog());
await logs.WriteAsync("Information", "navigation", "Open toolbox", "https://example.com/private?token=secret");
var package = await service.BuildPackageAsync(new FeedbackRequest(
"同步状态卡住",
"issue",
"major",
"dev@example.com",
"首页一直显示后台校验中。",
IncludeTodayLogs: true,
IncludeToolStatus: true,
IncludeSystemSummary: true,
Language: "zh-CN",
FeedbackCode: "FB-20260604-ABC123"));
try
{
Assert.IsTrue(File.Exists(package.PackagePath));
Assert.IsGreaterThan(0, package.PackageBytes);
Assert.AreEqual(
Path.GetFullPath(FeedbackPackageService.FeedbackPackagesRoot()),
Path.GetFullPath(Path.GetDirectoryName(package.PackagePath)!));
Assert.IsTrue(package.IncludedFiles.ContainsKey("feedback.json"));
Assert.IsTrue(package.IncludedFiles.ContainsKey("tool-status.json"));
Assert.IsTrue(package.IncludedFiles.ContainsKey("system-summary.json"));
Assert.IsTrue(package.IncludedFiles.Keys.Any(name => name.StartsWith("logs-", StringComparison.OrdinalIgnoreCase)));
Assert.IsTrue(package.SummaryText.Contains("首页一直显示后台校验中。", StringComparison.Ordinal));
Assert.IsTrue(package.SummaryText.Contains("FB-20260604-ABC123", StringComparison.Ordinal));
using var archive = ZipFile.OpenRead(package.PackagePath);
Assert.IsNotNull(archive.GetEntry("feedback.json"));
Assert.IsNotNull(archive.GetEntry("summary.txt"));
var feedbackJson = ReadEntry(archive.GetEntry("feedback.json")!);
using var feedbackDocument = JsonDocument.Parse(feedbackJson);
Assert.AreEqual("FB-20260604-ABC123", feedbackDocument.RootElement.GetProperty("feedbackCode").GetString());
Assert.AreEqual("FB-20260604-ABC123", feedbackDocument.RootElement.GetProperty("request").GetProperty("feedbackCode").GetString());
var logsEntry = archive.Entries.Single(entry => entry.FullName.StartsWith("logs-", StringComparison.OrdinalIgnoreCase));
var logsJson = ReadEntry(logsEntry);
using var logsDocument = JsonDocument.Parse(logsJson);
var firstLog = logsDocument.RootElement[0];
Assert.AreEqual("导航", firstLog.GetProperty("display").GetProperty("category").GetString());
Assert.AreEqual("打开工具箱", firstLog.GetProperty("display").GetProperty("message").GetString());
Assert.AreEqual("远程服务", firstLog.GetProperty("display").GetProperty("detail").GetString());
Assert.AreEqual("远程服务", firstLog.GetProperty("raw").GetProperty("detail").GetString());
Assert.IsFalse(logsJson.Contains("example.com/private", StringComparison.OrdinalIgnoreCase));
var toolStatus = ReadEntry(archive.GetEntry("tool-status.json")!);
StringAssert.Contains(toolStatus, "\"id\": \"compression_codec\"");
StringAssert.Contains(toolStatus, "\"exists\": true");
}
finally
{
DeleteIfExists(package.PackagePath);
Directory.Delete(tempRoot, recursive: true);
}
}
[TestMethod]
public async Task FeedbackPackageCryptoRoundTripsLocalZip()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var sourceZip = Path.Combine(tempRoot, "feedback.zip");
var decryptedZip = Path.Combine(tempRoot, "feedback-decrypted.zip");
try
{
using (var archive = ZipFile.Open(sourceZip, ZipArchiveMode.Create))
{
var entry = archive.CreateEntry("summary.txt");
await using var stream = entry.Open();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync("反馈包加密测试");
}
var encrypted = await FeedbackPackageCrypto.EncryptPackageAsync(sourceZip);
Assert.IsTrue(File.Exists(encrypted.PackagePath));
Assert.AreEqual(".ymfb", Path.GetExtension(encrypted.PackagePath));
Assert.IsGreaterThan(new FileInfo(sourceZip).Length, encrypted.PackageBytes);
await FeedbackPackageCrypto.DecryptPackageAsync(encrypted.PackagePath, decryptedZip);
CollectionAssert.AreEqual(
await File.ReadAllBytesAsync(sourceZip),
await File.ReadAllBytesAsync(decryptedZip));
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[TestMethod]
public async Task FeedbackPackageRejectsScreenshotOverFiveMegabytes()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-tests", Guid.NewGuid().ToString("N"));
var paths = AppPaths.ForCurrentUser(tempRoot);
var logs = new JsonFileLogService(paths);
var service = new FeedbackPackageService(logs, new FakeSystemMetricsService(), new ToolCatalog());
var screenshot = Path.Combine(tempRoot, "large.png");
await File.WriteAllBytesAsync(screenshot, new byte[FeedbackPackageService.MaxScreenshotBytes + 1]);
try
{
await Assert.ThrowsExactlyAsync<InvalidOperationException>(() =>
service.BuildPackageAsync(new FeedbackRequest(
"截图过大",
"issue",
"normal",
string.Empty,
"测试截图限制。",
IncludeTodayLogs: false,
IncludeToolStatus: false,
IncludeSystemSummary: false,
ScreenshotPath: screenshot)));
}
finally
{
Directory.Delete(tempRoot, recursive: true);
}
}
[TestMethod]
public void FeedbackSubmissionSignatureIsStable()
{
var signature = FeedbackSubmissionService.Sign("1760000000", "abc123", new string('a', 64), "{\"ok\":true}");
Assert.AreEqual(64, signature.Length);
Assert.IsTrue(signature.All(character => Uri.IsHexDigit(character) && !char.IsUpper(character)));
Assert.AreEqual(signature, FeedbackSubmissionService.Sign("1760000000", "abc123", new string('a', 64), "{\"ok\":true}"));
}
[TestMethod]
public void FeedbackStatusResponseParsesExtendedServerFields()
{
var response = ParseStatusResponse("""
{
"ok": true,
"code": "FB-20260604-ABC123",
"status": "investigating",
"statusLabel": "处理中",
"statusDetail": "已收到日志,正在定位。",
"category": "issue",
"priority": "major",
"hasReply": true,
"reply": "请先保留现场文件。",
"receivedAt": "2026-06-04T10:00:00+08:00",
"updatedAt": "2026-06-04T10:30:00+08:00",
"mailSent": true
}
""");
Assert.IsTrue(response.Ok);
Assert.AreEqual("FB-20260604-ABC123", response.Code);
Assert.AreEqual("investigating", response.Status);
Assert.AreEqual("处理中", response.StatusLabel);
Assert.AreEqual("已收到日志,正在定位。", response.StatusDetail);
Assert.AreEqual("issue", response.Category);
Assert.AreEqual("major", response.Priority);
Assert.IsTrue(response.HasReply);
Assert.AreEqual("请先保留现场文件。", response.Reply);
Assert.AreEqual("2026-06-04T10:30:00+08:00", response.UpdatedAt);
Assert.IsTrue(response.MailSent);
}
[TestMethod]
public async Task FeedbackRecordStoreReadsOldRecordsWithoutExtendedFields()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-record-tests", Guid.NewGuid().ToString("N"));
var storagePath = Path.Combine(tempRoot, "feedback-records.json");
Directory.CreateDirectory(tempRoot);
await File.WriteAllTextAsync(storagePath, """
[
{
"code": "FB-20260604-ABC123",
"title": "Old record",
"type": "issue",
"severity": "normal",
"createdAtUtc": "2026-06-04T02:00:00+00:00",
"updatedAtUtc": "2026-06-04T02:10:00+00:00",
"state": "Sent",
"statusLabel": "Sent",
"lastMessage": "The service received it."
}
]
""");
try
{
var record = await new FeedbackRecordStore(storagePath).FindAsync("fb-20260604-abc123");
Assert.IsNotNull(record);
Assert.AreEqual("FB-20260604-ABC123", record.Code);
Assert.AreEqual("Sent", record.StatusLabel);
Assert.IsNull(record.ServiceStatusDetail);
Assert.IsNull(record.ServiceCategory);
Assert.IsNull(record.ServicePriority);
Assert.IsNull(record.ServiceMailSent);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[TestMethod]
public async Task FeedbackRecordStoreKeepsStatusMessageAndPublicReplySeparate()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-record-tests", Guid.NewGuid().ToString("N"));
var storagePath = Path.Combine(tempRoot, "feedback-records.json");
var store = new FeedbackRecordStore(storagePath);
var now = DateTimeOffset.Parse("2026-06-04T03:00:00Z");
try
{
await store.UpsertAsync(new FeedbackRecord(
"FB-20260604-ABC123",
"Status query",
"issue",
"major",
now,
now,
FeedbackRecordState.StatusUpdated,
"处理中",
"已收到日志,正在定位。",
LastQueryAtUtc: now,
ServiceStatus: "investigating",
PublicReply: "请先保留现场文件。",
ServiceStatusDetail: "已收到日志,正在定位。",
ServiceCategory: "issue",
ServicePriority: "major",
ServiceMailSent: true,
ServiceReceivedAt: "2026-06-04T10:00:00+08:00",
ServiceUpdatedAt: "2026-06-04T10:30:00+08:00"));
var record = await store.FindAsync("FB-20260604-ABC123");
Assert.IsNotNull(record);
Assert.AreEqual("已收到日志,正在定位。", record.LastMessage);
Assert.IsFalse(record.LastMessage.Contains("请先保留现场文件。", StringComparison.Ordinal));
Assert.AreEqual("请先保留现场文件。", record.PublicReply);
Assert.AreEqual("investigating", record.ServiceStatus);
Assert.AreEqual("issue", record.ServiceCategory);
Assert.AreEqual("major", record.ServicePriority);
Assert.IsTrue(record.ServiceMailSent);
Assert.AreEqual("2026-06-04T10:30:00+08:00", record.ServiceUpdatedAt);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[TestMethod]
public async Task FeedbackRecordStoreArchivesOldRecordsAndKeepsThemQueryable()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-record-tests", Guid.NewGuid().ToString("N"));
var storagePath = Path.Combine(tempRoot, "feedback-records.json");
var store = new FeedbackRecordStore(storagePath);
var now = DateTimeOffset.Parse("2026-06-05T12:00:00Z");
var oldCreated = now.AddDays(-11);
try
{
await store.UpsertAsync(new FeedbackRecord(
"FB-20260525-ABC123",
"旧反馈",
"issue",
"normal",
oldCreated,
oldCreated,
FeedbackRecordState.Sent,
"发送成功",
"服务端已接收。",
PackagePath: Path.Combine(tempRoot, "feedback.zip"),
PackageSha256: new string('a', 64),
PackageBytes: 128,
SubmittedAtUtc: oldCreated));
var archived = await store.ArchiveOlderThanAsync(TimeSpan.FromDays(10), now);
var record = archived.Single();
Assert.IsTrue(record.IsArchived);
Assert.AreEqual(FeedbackRecordState.Archived, record.State);
Assert.AreEqual("已归档", record.StatusLabel);
Assert.IsNotNull(record.ArchivedAtUtc);
var updated = await store.UpsertAsync(record with
{
State = FeedbackRecordState.StatusUpdated,
StatusLabel = "状态已更新",
LastMessage = "当前状态:已处理。",
UpdatedAtUtc = now.AddMinutes(5),
LastQueryAtUtc = now.AddMinutes(5)
});
Assert.IsTrue(updated.IsArchived);
Assert.AreEqual(FeedbackRecordState.StatusUpdated, updated.State);
Assert.AreEqual("状态已更新", updated.StatusLabel);
Assert.AreEqual("当前状态:已处理。", updated.LastMessage);
var reloaded = await new FeedbackRecordStore(storagePath).FindAsync("fb-20260525-abc123");
Assert.IsNotNull(reloaded);
Assert.IsTrue(reloaded.IsArchived);
Assert.AreEqual("FB-20260525-ABC123", reloaded.Code);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
private static string ReadEntry(ZipArchiveEntry entry)
{
using var stream = entry.Open();
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static FeedbackStatusResponse ParseStatusResponse(string json)
{
var method = typeof(FeedbackSubmissionService).GetMethod("ParseStatusResponse", BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(method);
return (FeedbackStatusResponse)method.Invoke(null, new object?[] { json })!;
}
private static void DeleteIfExists(string path)
{
if (File.Exists(path))
{
File.Delete(path);
}
}
private sealed class FakeSystemMetricsService : ISystemMetricsService
{
public SystemMetricsSnapshot Capture()
{
return new SystemMetricsSnapshot(
"TEST-PC",
"Windows Test",
8,
1024,
2048,
42.5,
16UL * 1024 * 1024 * 1024,
8UL * 1024 * 1024 * 1024,
8UL * 1024 * 1024 * 1024,
50,
1.2,
TimeSpan.FromMinutes(12),
24,
512,
256UL * 1024 * 1024 * 1024,
128UL * 1024 * 1024 * 1024,
128UL * 1024 * 1024 * 1024,
50,
true,
".NET 10.0",
DateTimeOffset.Parse("2026-06-04T10:00:00+08:00"));
}
}
}
@@ -0,0 +1,266 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YMhut.Box.Core.App;
namespace YMhut.Box.Tests;
[TestClass]
[DoNotParallelize]
public sealed class InstallLayoutPathsTests
{
[TestMethod]
public void ResolveInstallRootPrefersCurrentInstallRootEnvironment()
{
using var workspace = TestWorkspace.Create("ymhut-install-root-tests");
var installRoot = workspace.CreateDirectory("install");
var baseRoot = workspace.CreateDirectory("runtime");
using var environment = EnvironmentScope.Capture(
InstallLayoutPaths.InstallRootEnvironmentVariable,
InstallLayoutPaths.ArchivedLayoutEnvironmentVariable);
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
environment.Set(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable, null);
Assert.AreEqual(installRoot, InstallLayoutPaths.ResolveInstallRoot(baseRoot));
Assert.AreEqual(installRoot, InstallLayoutPaths.CandidateRoots(baseRoot).First());
}
[TestMethod]
public void ResolveInstallRootIgnoresLegacyArchivedLayoutForHealth()
{
using var workspace = TestWorkspace.Create("ymhut-archived-layout-tests");
var archivedRoot = workspace.CreateDirectory("archived");
var baseRoot = workspace.CreateDirectory("runtime");
workspace.CreateFile(["runtime", "YMhutBox.exe"], string.Empty);
using var environment = EnvironmentScope.Capture(
InstallLayoutPaths.InstallRootEnvironmentVariable,
InstallLayoutPaths.ArchivedLayoutEnvironmentVariable);
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, null);
environment.Set(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable, archivedRoot);
Assert.AreEqual(baseRoot, InstallLayoutPaths.ResolveInstallRoot(baseRoot));
Assert.IsFalse(InstallLayoutPaths.CandidateRoots(baseRoot).Contains(archivedRoot));
Assert.AreEqual(archivedRoot, InstallLayoutPaths.ResolveArchivedLayoutRoot());
}
[TestMethod]
public void ResolveAssetsRootAndExecutableUseInstallRoot()
{
using var workspace = TestWorkspace.Create("ymhut-install-assets-tests");
var installRoot = workspace.CreateDirectory("install");
var assetsRoot = workspace.CreateDirectory("install", "Assets");
var executable = workspace.CreateFile(["install", "YMhutBox.exe"], string.Empty);
var runtimeRoot = workspace.CreateDirectory("runtime");
using var environment = EnvironmentScope.Capture(
InstallLayoutPaths.InstallRootEnvironmentVariable,
InstallLayoutPaths.ArchivedLayoutEnvironmentVariable);
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
environment.Set(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable, null);
Assert.AreEqual(assetsRoot, InstallLayoutPaths.ResolveAssetsRoot(runtimeRoot));
Assert.AreEqual(executable, InstallLayoutPaths.ResolveInstalledExecutablePath(runtimeRoot));
}
[TestMethod]
public void RuntimeLayoutPolicySkipsLargeToolsAndUserDataRoots()
{
foreach (var relativePath in new[]
{
"Tools/vendor/tool.exe",
"Metadata/tools.json",
"data/settings.json",
"FeedbackPackages/feedback.zip"
})
{
Assert.IsTrue(RuntimeLayoutPolicy.ShouldSkipRelativePath(relativePath), relativePath);
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile(relativePath), relativePath);
}
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("unins000.exe"));
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("YMhutBox.exe"));
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("YMhutBox.dll"));
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("resources.pri"));
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("zh-CN/Microsoft.ui.xaml.dll.mui"));
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("lang/zh-CN/Microsoft.ui.xaml.dll.mui"));
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("runtimes/win-x64/native/Microsoft.ui.xaml.dll"));
}
[TestMethod]
public void InstallManifestParserReadsReleaseRequiredFilesAndPayloadFiles()
{
var manifest = InstallManifest.Parse(
"""
[Release]
Version=2.0.6
Build=2
Channel=stable
PackageVersion=2.0.6.2
[RequiredFiles]
YMhutBox.exe
YMhutBox.dll
[Files]
YMhutBox.exe
lang/zh-CN/Microsoft.ui.xaml.dll.mui
Tools/SampleTool/tool.exe
""");
Assert.IsNotNull(manifest.Release);
Assert.AreEqual("2.0.6", manifest.Release.Version);
Assert.AreEqual("2", manifest.Release.Build);
Assert.AreEqual("stable", manifest.Release.Channel);
Assert.AreEqual("2.0.6.2", manifest.Release.PackageVersion);
Assert.IsTrue(manifest.RequiredFiles.SequenceEqual(["YMhutBox.exe", "YMhutBox.dll"]));
Assert.IsTrue(manifest.ContainsFile(@"lang\zh-CN\Microsoft.ui.xaml.dll.mui"));
Assert.IsTrue(manifest.ContainsFile("Tools/SampleTool/tool.exe"));
}
[TestMethod]
public void RuntimeLayoutPolicySkipsRuntimePayloadRoots()
{
foreach (var relativePath in new[]
{
"lang/en-US/Microsoft.UI.Xaml.Phone.dll.mui",
"Microsoft.UI.Xaml/Microsoft.UI.Xaml.dll",
"runtimes/win-x64/native/WebView2Loader.dll",
"worker/YMhut.Box.Worker.exe"
})
{
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile(relativePath), relativePath);
}
}
[TestMethod]
public void SourceFilesDoNotContainCommonMojibakeSequences()
{
var root = FindRepositoryRoot();
var scanRoots = new[]
{
Path.Combine(root, "src", "YMhut.Box.Core"),
Path.Combine(root, "src", "box-winUI"),
Path.Combine(root, "installer")
};
var extensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".cs", ".xaml", ".iss" };
var mojibakeMarkers = new[]
{
"Ã",
"Â",
"â",
"鈥",
"锛",
"銆",
"鐑",
"澶",
"璁",
"鏍",
"棰",
"缃"
};
var offenders = scanRoots
.Where(Directory.Exists)
.SelectMany(scanRoot => Directory.EnumerateFiles(scanRoot, "*.*", SearchOption.AllDirectories))
.Where(path => extensions.Contains(Path.GetExtension(path)))
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
.SelectMany(path => FindMojibakeMarkers(path, mojibakeMarkers))
.Take(12)
.ToArray();
Assert.IsEmpty(offenders, string.Join(Environment.NewLine, offenders));
}
private static IEnumerable<string> FindMojibakeMarkers(string path, IReadOnlyList<string> markers)
{
var text = File.ReadAllText(path);
foreach (var marker in markers)
{
if (text.Contains(marker, StringComparison.Ordinal))
{
yield return $"{Path.GetFileName(path)} contains '{marker}'";
}
}
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (Directory.Exists(Path.Combine(directory.FullName, "src")) &&
Directory.Exists(Path.Combine(directory.FullName, "installer")))
{
return directory.FullName;
}
directory = directory.Parent;
}
Assert.Fail("Repository root could not be found from test output directory.");
return AppContext.BaseDirectory;
}
private sealed class EnvironmentScope : IDisposable
{
private readonly Dictionary<string, string?> _snapshot;
private EnvironmentScope(IEnumerable<string> names)
{
_snapshot = names.ToDictionary(name => name, Environment.GetEnvironmentVariable, StringComparer.Ordinal);
}
public static EnvironmentScope Capture(params string[] names) => new(names);
public void Set(string name, string? value) => Environment.SetEnvironmentVariable(name, value);
public void Dispose()
{
foreach (var item in _snapshot)
{
Environment.SetEnvironmentVariable(item.Key, item.Value);
}
}
}
private sealed class TestWorkspace : IDisposable
{
private TestWorkspace(string root)
{
Root = root;
}
public string Root { get; }
public static TestWorkspace Create(string prefix)
{
var root = Path.Combine(Path.GetTempPath(), prefix, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return new TestWorkspace(root);
}
public string CreateDirectory(params string[] parts)
{
var directory = Path.Combine([Root, .. parts]);
Directory.CreateDirectory(directory);
return directory;
}
public string CreateFile(string[] parts, string contents)
{
var path = Path.Combine([Root, .. parts]);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, contents);
return path;
}
public void Dispose()
{
if (Directory.Exists(Root))
{
Directory.Delete(Root, recursive: true);
}
}
}
}
+131
View File
@@ -0,0 +1,131 @@
using Microsoft.Data.Sqlite;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.Tests;
[TestClass]
public sealed class LogServiceTests
{
[TestMethod]
public void LogDisplayLocalizerUsesEmptyDetailTextInsteadOfUnknownError()
{
Assert.AreEqual("暂无详情", LogDisplayLocalizer.Detail(null, "zh-CN"));
Assert.AreEqual("No details", LogDisplayLocalizer.Detail(" ", "en-US"));
Assert.AreEqual("真实详情", LogDisplayLocalizer.Detail("真实详情", "zh-CN"));
}
[TestMethod]
public void LogDisplayLocalizerTranslatesCommonEnglishMessagesInChineseMode()
{
Assert.AreEqual("打开启动自检结果", LogDisplayLocalizer.Message("Open startup check results", "zh-CN"));
Assert.AreEqual("工具目录已重建", LogDisplayLocalizer.Message("Tool catalog rebuilt", "zh-CN"));
StringAssert.Contains(LogDisplayLocalizer.Message("External tool success: launch", "zh-CN"), "外部工具成功");
Assert.AreEqual("外部工具成功", LogDisplayLocalizer.Message("External tool success", "zh-CN"));
StringAssert.Contains(LogDisplayLocalizer.Message("Tool result canceled: export", "zh-CN"), "工具结果已取消");
Assert.AreEqual("工具工作进程已就绪", LogDisplayLocalizer.Message("Tool worker ready", "zh-CN"));
Assert.AreEqual("下载宿主进程已启动", LogDisplayLocalizer.Message("Download host process started", "zh-CN"));
Assert.AreEqual("打开插件页面:sample/main", LogDisplayLocalizer.Message("Open plugin surface: sample/main", "zh-CN"));
Assert.AreEqual("打开 JSON formatter", LogDisplayLocalizer.Message("Open JSON formatter", "zh-CN"));
Assert.AreEqual("\u66f4\u65b0\u901a\u77e5\u68c0\u67e5\u5931\u8d25", LogDisplayLocalizer.Message("Update notice check failed", "zh-CN"));
Assert.AreEqual("\u542f\u52a8\u81ea\u68c0\u7ed3\u679c\u9884\u52a0\u8f7d\u5931\u8d25", LogDisplayLocalizer.Message("Startup check result preload failed", "zh-CN"));
Assert.AreEqual("\u8bbe\u7f6e\u9875\u52a0\u8f7d\u5931\u8d25", LogDisplayLocalizer.Message("Settings page load failed", "zh-CN"));
Assert.AreEqual("\u5df2\u8bb0\u4f4f\u5173\u95ed\u786e\u8ba4\u9009\u62e9", LogDisplayLocalizer.Message("Close confirmation remembered", "zh-CN"));
}
[TestMethod]
public void LogDisplayLocalizerTranslatesCommonDetailKeysInChineseMode()
{
var detail = LogDisplayLocalizer.Detail("result=success; operation=launch; elapsedMs=12; root=C:\\Tools; count=8", "zh-CN");
StringAssert.Contains(detail, "结果=成功");
StringAssert.Contains(detail, "操作=启动");
StringAssert.Contains(detail, "耗时毫秒=12");
StringAssert.Contains(detail, "根目录=C:\\Tools");
StringAssert.Contains(detail, "数量=8");
var windowDetail = LogDisplayLocalizer.Detail("source=tray; behavior=minimize_to_tray; result=primary; dialog=com_exception", "zh-CN");
StringAssert.Contains(windowDetail, "来源=托盘");
StringAssert.Contains(windowDetail, "行为=最小化到托盘");
StringAssert.Contains(windowDetail, "结果=主按钮");
StringAssert.Contains(windowDetail, "弹窗=COM 异常");
}
[TestMethod]
public async Task ClearFilteredTodayDeletesOnlyMatchingRows()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
try
{
var service = new SqliteLogService(AppPaths.ForCurrentUser(tempRoot));
await service.WriteAsync("Information", "tool", "Open tool: JSON formatter");
await service.WriteAsync("Warning", "network", "Proxy diagnostic failed");
await service.ClearFilteredTodayAsync("Information", "JSON");
var entries = await service.ReadAsync(take: 10);
Assert.HasCount(1, entries);
Assert.AreEqual("network", entries[0].Category);
}
finally
{
SqliteConnection.ClearAllPools();
Directory.Delete(tempRoot, recursive: true);
}
}
[TestMethod]
public async Task ReadByDateReturnsAllMatchingRowsForSelectedDay()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
try
{
var service = new SqliteLogService(AppPaths.ForCurrentUser(tempRoot));
await service.WriteAsync("Information", "tool", "Open JSON formatter");
await service.WriteAsync("Information", "tool", "Open URL codec");
await service.WriteAsync("Warning", "network", "Proxy diagnostic failed");
var allToday = await service.ReadByDateAsync(DateOnly.FromDateTime(DateTime.Now));
var filtered = await service.ReadByDateAsync(DateOnly.FromDateTime(DateTime.Now), "Information", "Open");
Assert.HasCount(3, allToday);
Assert.HasCount(2, filtered);
}
finally
{
SqliteConnection.ClearAllPools();
Directory.Delete(tempRoot, recursive: true);
}
}
[TestMethod]
public async Task ReadByDatePageReturnsStablePages()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
try
{
var service = new SqliteLogService(AppPaths.ForCurrentUser(tempRoot));
for (var index = 0; index < 25; index++)
{
await service.WriteAsync("Information", "stream", $"Row {index:00}");
}
var today = DateOnly.FromDateTime(DateTime.Now);
var first = await service.ReadByDatePageAsync(today, "Information", "Row", skip: 0, take: 20);
var second = await service.ReadByDatePageAsync(today, "Information", "Row", skip: 20, take: 20);
Assert.HasCount(20, first);
Assert.HasCount(5, second);
CollectionAssert.AreNotEquivalent(
first.Select(entry => entry.Message).ToArray(),
second.Select(entry => entry.Message).ToArray());
}
finally
{
SqliteConnection.ClearAllPools();
Directory.Delete(tempRoot, recursive: true);
}
}
}

Some files were not shown because too many files have changed in this diff Show More