This commit is contained in:
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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..]));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 渲染器 运行 HTML、CSS、JS、引用脚本并查看输出日志 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 压缩编解码 GZip、Deflate、Brotli 文本与 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/代码压缩 压缩 HTML、CSS 或 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 键值对转换 JSON、ENV、Query 等键值格式互转 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 哈希计算 计算 MD5、SHA1、SHA256 和 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 解析 查询 A、MX、TXT 等 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 数据容量换算 B、KB、MB、GB、TB 互转 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 清单模板导出 输出 JSON、CSV、TSV 清单模板 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
|
||||
""";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user