完善音乐下载、下载管理和开发工具

This commit is contained in:
2026-08-17 01:58:54 +08:00
parent c9fa6f7a88
commit 8b2dd89d2f
27 changed files with 4052 additions and 748 deletions
+2
View File
@@ -8,6 +8,8 @@ copyrights, licenses, and trademarks remain with their respective owners.
| NexBox | Local source under `参考/NexBox-main` | GPL-3.0 | Reference implementation for hardware monitoring, optimization, overlays, music, network diagnostics, and installer workflows. NexBox branding and site assets are not included. |
| LibreHardwareMonitor | LibreHardwareMonitorLib 0.9.6 | MPL-2.0 | Optional sensor provider in the isolated hardware-monitor host. |
| QRCoder | 1.8.0 | MIT | QR code rendering for provider login. |
| MoeKoeMusic | Commit [`4581660209c9cdd07669e8bd5bcbe136b7d91e02`](https://github.com/MoeKoeMusic/MoeKoeMusic/tree/4581660209c9cdd07669e8bd5bcbe136b7d91e02) | GPL-3.0 | Reference for the KuGou client login and account workflow. No MoeKoeMusic code or branding is bundled. |
| KuGouMusicApi | Commit [`06560e3e053bda1ab830750db6f645bab703f824`](https://github.com/MakcRe/KuGouMusicApi/tree/06560e3e053bda1ab830750db6f645bab703f824) | MIT | Protocol reference for KuGou request signing, device registration, QR login, account playlists, and entitlement-aware HTTPS playback. The required protocol subset is independently implemented in C#. |
| Microsoft Windows App SDK | 1.8.260416003 | Microsoft license | WinUI 3 desktop application and installer bootstrap. |
| Microsoft WebView2 | 1.0.3967.48 | Microsoft license | Embedded browser and tool surfaces. |
| Microsoft.Data.Sqlite | 10.0.9 | MIT | Local application data and logs. |
@@ -0,0 +1,616 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Win32;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
namespace YMhut.Box.Core.DevEnvironments;
public sealed record DevTerminalSnapshot(
bool WingetAvailable,
bool WindowsTerminalInstalled,
string WindowsTerminalVersion,
string WindowsTerminalPath,
bool PowerShellInstalled,
string PowerShellVersion,
string PowerShellPath,
bool SupportsDefaultTerminal,
bool IsWindowsTerminalDefault,
bool IsPowerShellDefaultProfile,
int WindowsBuild,
string Architecture,
string Error = "")
{
public bool IsReady => WindowsTerminalInstalled && PowerShellInstalled;
}
public sealed record DevTerminalInstallProgress(string Stage, string Message, int Percent);
public sealed record DevTerminalInstallResult(
bool WindowsTerminalSucceeded,
bool PowerShellSucceeded,
bool DefaultsConfigured,
DevTerminalSnapshot Snapshot,
IReadOnlyList<string> Messages)
{
public bool Succeeded => WindowsTerminalSucceeded && PowerShellSucceeded;
}
public sealed record DevTerminalCommandResult(int ExitCode, string Output, string Error)
{
public bool Succeeded => ExitCode == 0;
}
public interface IDevTerminalPlatform
{
int WindowsBuild { get; }
string Architecture { get; }
Task<string> ResolveCommandAsync(string command, CancellationToken cancellationToken = default);
Task<DevTerminalCommandResult> RunAsync(string fileName, string arguments, CancellationToken cancellationToken = default);
Task<bool> InstallWindowsTerminalFallbackAsync(CancellationToken cancellationToken = default);
Task<bool> InstallPowerShellFallbackAsync(CancellationToken cancellationToken = default);
Task<bool> ConfigureDefaultsAsync(string powerShellPath, CancellationToken cancellationToken = default);
bool IsWindowsTerminalDefault();
bool IsPowerShellDefaultProfile();
Task<bool> OpenTerminalAsync(CancellationToken cancellationToken = default);
}
public interface IDevTerminalSetupService
{
Task<DevTerminalSnapshot> DetectAsync(CancellationToken cancellationToken = default);
Task<DevTerminalInstallResult> InstallOrRepairAsync(
IProgress<DevTerminalInstallProgress>? progress = null,
CancellationToken cancellationToken = default);
Task<bool> OpenTerminalAsync(CancellationToken cancellationToken = default);
}
public sealed class DevTerminalSetupService(
IDevTerminalPlatform platform,
ILogService? logService = null) : IDevTerminalSetupService
{
public async Task<DevTerminalSnapshot> DetectAsync(CancellationToken cancellationToken = default)
{
try
{
var wingetPathTask = platform.ResolveCommandAsync("winget.exe", cancellationToken);
var terminalPathTask = platform.ResolveCommandAsync("wt.exe", cancellationToken);
var powerShellPathTask = platform.ResolveCommandAsync("pwsh.exe", cancellationToken);
await Task.WhenAll(wingetPathTask, terminalPathTask, powerShellPathTask).ConfigureAwait(false);
var terminalPath = await terminalPathTask.ConfigureAwait(false);
var powerShellPath = await powerShellPathTask.ConfigureAwait(false);
var terminalVersion = string.IsNullOrWhiteSpace(terminalPath)
? string.Empty
: await ReadVersionAsync("wt.exe", "--version", cancellationToken).ConfigureAwait(false);
var powerShellVersion = string.IsNullOrWhiteSpace(powerShellPath)
? string.Empty
: await ReadVersionAsync("pwsh.exe", "-NoLogo -NoProfile -Command \"$PSVersionTable.PSVersion.ToString()\"", cancellationToken).ConfigureAwait(false);
return new DevTerminalSnapshot(
!string.IsNullOrWhiteSpace(await wingetPathTask.ConfigureAwait(false)),
!string.IsNullOrWhiteSpace(terminalPath),
terminalVersion,
terminalPath,
!string.IsNullOrWhiteSpace(powerShellPath),
powerShellVersion,
powerShellPath,
SupportsDefaultTerminal(platform.WindowsBuild),
platform.IsWindowsTerminalDefault(),
platform.IsPowerShellDefaultProfile(),
platform.WindowsBuild,
platform.Architecture);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
await WriteLogAsync("Warning", "Terminal environment detection failed", exception.Message, cancellationToken).ConfigureAwait(false);
return new DevTerminalSnapshot(
false, false, string.Empty, string.Empty, false, string.Empty, string.Empty,
SupportsDefaultTerminal(platform.WindowsBuild), false, false,
platform.WindowsBuild, platform.Architecture, exception.Message);
}
}
public async Task<DevTerminalInstallResult> InstallOrRepairAsync(
IProgress<DevTerminalInstallProgress>? progress = null,
CancellationToken cancellationToken = default)
{
var messages = new List<string>();
progress?.Report(new DevTerminalInstallProgress("detect", "Checking the current terminal environment...", 5));
var before = await DetectAsync(cancellationToken).ConfigureAwait(false);
var terminalSucceeded = before.WindowsTerminalInstalled;
var powerShellSucceeded = before.PowerShellInstalled;
if (before.WingetAvailable)
{
progress?.Report(new DevTerminalInstallProgress("terminal", "Installing or upgrading Windows Terminal...", 18));
terminalSucceeded = await InstallWithWingetAsync("Microsoft.WindowsTerminal", cancellationToken).ConfigureAwait(false);
if (!terminalSucceeded)
{
messages.Add("Windows Terminal could not be installed with winget; using the official package fallback.");
terminalSucceeded = await platform.InstallWindowsTerminalFallbackAsync(cancellationToken).ConfigureAwait(false);
}
progress?.Report(new DevTerminalInstallProgress("powershell", "Installing or upgrading PowerShell 7...", 48));
powerShellSucceeded = await InstallWithWingetAsync("Microsoft.PowerShell", cancellationToken).ConfigureAwait(false);
if (!powerShellSucceeded)
{
messages.Add("PowerShell 7 could not be installed with winget; using the official user-level fallback.");
powerShellSucceeded = await platform.InstallPowerShellFallbackAsync(cancellationToken).ConfigureAwait(false);
}
}
else
{
messages.Add("winget is unavailable; official Microsoft release packages were used.");
progress?.Report(new DevTerminalInstallProgress("terminal", "Installing Windows Terminal from its official release...", 18));
terminalSucceeded = await platform.InstallWindowsTerminalFallbackAsync(cancellationToken).ConfigureAwait(false);
progress?.Report(new DevTerminalInstallProgress("powershell", "Installing PowerShell 7 for the current user...", 48));
powerShellSucceeded = await platform.InstallPowerShellFallbackAsync(cancellationToken).ConfigureAwait(false);
}
progress?.Report(new DevTerminalInstallProgress("verify", "Refreshing PATH and verifying terminal commands...", 72));
var installed = await DetectAsync(cancellationToken).ConfigureAwait(false);
terminalSucceeded = terminalSucceeded && installed.WindowsTerminalInstalled;
powerShellSucceeded = powerShellSucceeded && installed.PowerShellInstalled;
var defaultsConfigured = false;
if (powerShellSucceeded)
{
progress?.Report(new DevTerminalInstallProgress("defaults", "Configuring Windows Terminal defaults...", 86));
defaultsConfigured = await platform.ConfigureDefaultsAsync(installed.PowerShellPath, cancellationToken).ConfigureAwait(false);
if (!installed.SupportsDefaultTerminal)
{
messages.Add("This Windows build does not support changing the system default terminal; PowerShell 7 remains available in the existing console host.");
}
}
progress?.Report(new DevTerminalInstallProgress("complete", "Terminal setup completed.", 100));
var snapshot = await DetectAsync(cancellationToken).ConfigureAwait(false);
await WriteLogAsync(
snapshot.IsReady ? "Information" : "Warning",
"Terminal environment setup completed",
$"terminal={snapshot.WindowsTerminalInstalled}; powershell={snapshot.PowerShellInstalled}; defaults={defaultsConfigured}",
cancellationToken).ConfigureAwait(false);
return new DevTerminalInstallResult(
terminalSucceeded,
powerShellSucceeded,
defaultsConfigured,
snapshot,
messages);
}
public Task<bool> OpenTerminalAsync(CancellationToken cancellationToken = default)
=> platform.OpenTerminalAsync(cancellationToken);
public static bool SupportsDefaultTerminal(int windowsBuild)
=> windowsBuild >= 22000 || windowsBuild >= 19045;
private async Task<bool> InstallWithWingetAsync(string packageId, CancellationToken cancellationToken)
{
var common = $"--id {packageId} --exact --silent --accept-package-agreements --accept-source-agreements --disable-interactivity";
var upgrade = await platform.RunAsync("winget.exe", $"upgrade {common}", cancellationToken).ConfigureAwait(false);
if (upgrade.Succeeded || ContainsNoUpgrade(upgrade))
{
return true;
}
var install = await platform.RunAsync("winget.exe", $"install {common}", cancellationToken).ConfigureAwait(false);
if (!install.Succeeded)
{
await WriteLogAsync("Warning", $"winget installation failed: {packageId}", install.Error, cancellationToken).ConfigureAwait(false);
}
return install.Succeeded;
}
private async Task<string> ReadVersionAsync(string fileName, string arguments, CancellationToken cancellationToken)
{
var result = await platform.RunAsync(fileName, arguments, cancellationToken).ConfigureAwait(false);
return result.Succeeded
? result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? string.Empty
: string.Empty;
}
private static bool ContainsNoUpgrade(DevTerminalCommandResult result)
{
var message = result.Output + Environment.NewLine + result.Error;
return message.Contains("No available upgrade", StringComparison.OrdinalIgnoreCase) ||
message.Contains("No applicable upgrade", StringComparison.OrdinalIgnoreCase) ||
message.Contains("没有可用的升级", StringComparison.OrdinalIgnoreCase);
}
private Task WriteLogAsync(string level, string message, string detail, CancellationToken cancellationToken)
=> logService?.WriteAsync(level, "dev-terminal", message, detail, cancellationToken) ?? Task.CompletedTask;
}
[SupportedOSPlatform("windows")]
public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposable
{
private const string ManagedPowerShellProfileGuid = "{5e9b8765-14c5-4f71-b2e0-6e3d9ddf2f45}";
private const string WindowsTerminalDelegationTerminal = "{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}";
private const string WindowsTerminalDelegationConsole = "{2EACA947-7F5F-4CFA-BA87-8F7FBEEFBE69}";
private const string ConsoleStartupKey = @"Console\%%Startup";
private readonly AppPaths _paths;
private readonly HttpClient _httpClient;
private readonly bool _disposeClient;
public WindowsDevTerminalPlatform(AppPaths paths, HttpMessageHandler? handler = null)
{
_paths = paths;
_disposeClient = handler is null;
_httpClient = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("YMhut-Box/1.0");
_httpClient.Timeout = TimeSpan.FromMinutes(10);
}
public int WindowsBuild => Environment.OSVersion.Version.Build;
public string Architecture => RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant();
public async Task<string> ResolveCommandAsync(string command, CancellationToken cancellationToken = default)
{
var result = await RunAsync("where.exe", command, cancellationToken).ConfigureAwait(false);
return result.Succeeded
? result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? string.Empty
: string.Empty;
}
public async Task<DevTerminalCommandResult> RunAsync(string fileName, string arguments, CancellationToken cancellationToken = default)
{
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
}
};
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
return new DevTerminalCommandResult(
process.ExitCode,
(await outputTask.ConfigureAwait(false)).Trim(),
(await errorTask.ConfigureAwait(false)).Trim());
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
return new DevTerminalCommandResult(-1, string.Empty, exception.Message);
}
}
public async Task<bool> InstallWindowsTerminalFallbackAsync(CancellationToken cancellationToken = default)
{
if (WindowsBuild < 19041)
{
return false;
}
var asset = await FindReleaseAssetAsync(
"https://api.github.com/repos/microsoft/terminal/releases/latest",
name => name.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase) &&
!name.Contains("Preview", StringComparison.OrdinalIgnoreCase),
cancellationToken).ConfigureAwait(false);
if (asset is null)
{
return false;
}
var package = await DownloadAssetAsync(asset.Value.Url, asset.Value.Name, cancellationToken).ConfigureAwait(false);
var escaped = package.Replace("'", "''", StringComparison.Ordinal);
var result = await RunAsync(
"powershell.exe",
$"-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \"Add-AppxPackage -Path '{escaped}'\"",
cancellationToken).ConfigureAwait(false);
return result.Succeeded;
}
public async Task<bool> InstallPowerShellFallbackAsync(CancellationToken cancellationToken = default)
{
var architecture = RuntimeInformation.OSArchitecture == global::System.Runtime.InteropServices.Architecture.Arm64 ? "arm64" : "x64";
var asset = await FindReleaseAssetAsync(
"https://api.github.com/repos/PowerShell/PowerShell/releases/latest",
name => name.EndsWith($"win-{architecture}.zip", StringComparison.OrdinalIgnoreCase) &&
!name.Contains("fxdependent", StringComparison.OrdinalIgnoreCase),
cancellationToken).ConfigureAwait(false);
if (asset is null)
{
return false;
}
var archive = await DownloadAssetAsync(asset.Value.Url, asset.Value.Name, cancellationToken).ConfigureAwait(false);
var installRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Programs",
"PowerShell",
"7");
Directory.CreateDirectory(installRoot);
ZipFile.ExtractToDirectory(archive, installRoot, overwriteFiles: true);
AddUserPath(installRoot);
return File.Exists(Path.Combine(installRoot, "pwsh.exe"));
}
public async Task<bool> ConfigureDefaultsAsync(string powerShellPath, CancellationToken cancellationToken = default)
{
var configured = false;
if (DevTerminalSetupService.SupportsDefaultTerminal(WindowsBuild) &&
!string.IsNullOrWhiteSpace(await ResolveCommandAsync("wt.exe", cancellationToken).ConfigureAwait(false)))
{
BackupDefaultTerminalRegistry();
using var key = Registry.CurrentUser.CreateSubKey(ConsoleStartupKey, writable: true);
key?.SetValue("DelegationTerminal", WindowsTerminalDelegationTerminal, RegistryValueKind.String);
key?.SetValue("DelegationConsole", WindowsTerminalDelegationConsole, RegistryValueKind.String);
configured = key is not null;
}
if (!string.IsNullOrWhiteSpace(powerShellPath) && File.Exists(powerShellPath))
{
configured = ConfigureTerminalSettings(powerShellPath) || configured;
}
return configured;
}
public bool IsWindowsTerminalDefault()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(ConsoleStartupKey, writable: false);
return string.Equals(key?.GetValue("DelegationTerminal")?.ToString(), WindowsTerminalDelegationTerminal, StringComparison.OrdinalIgnoreCase) &&
string.Equals(key?.GetValue("DelegationConsole")?.ToString(), WindowsTerminalDelegationConsole, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
public bool IsPowerShellDefaultProfile()
{
try
{
var path = ResolveTerminalSettingsPath(createDirectory: false);
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return false;
}
var root = JsonNode.Parse(File.ReadAllText(path)) as JsonObject;
return string.Equals(root?["defaultProfile"]?.GetValue<string>(), ManagedPowerShellProfileGuid, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
public async Task<bool> OpenTerminalAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var command = await ResolveCommandAsync("wt.exe", cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(command))
{
command = await ResolveCommandAsync("pwsh.exe", cancellationToken).ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(command))
{
command = "powershell.exe";
}
try
{
_ = Process.Start(new ProcessStartInfo { FileName = command, UseShellExecute = true });
return true;
}
catch
{
return false;
}
}
public void Dispose()
{
if (_disposeClient)
{
_httpClient.Dispose();
}
}
private async Task<(string Name, string Url)?> FindReleaseAssetAsync(
string apiUrl,
Func<string, bool> predicate,
CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (!document.RootElement.TryGetProperty("assets", out var assets))
{
return null;
}
foreach (var item in assets.EnumerateArray())
{
var name = item.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? string.Empty : string.Empty;
var url = item.TryGetProperty("browser_download_url", out var urlElement) ? urlElement.GetString() ?? string.Empty : string.Empty;
if (predicate(name) && Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps)
{
return (name, url);
}
}
return null;
}
private async Task<string> DownloadAssetAsync(string url, string fileName, CancellationToken cancellationToken)
{
var directory = Path.Combine(_paths.Cache, "terminal-setup");
Directory.CreateDirectory(directory);
var target = Path.Combine(directory, Path.GetFileName(fileName));
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using var destination = File.Create(target);
await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false);
return target;
}
private void BackupDefaultTerminalRegistry()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(ConsoleStartupKey, writable: false);
var backup = new JsonObject
{
["delegationTerminal"] = key?.GetValue("DelegationTerminal")?.ToString(),
["delegationConsole"] = key?.GetValue("DelegationConsole")?.ToString(),
["createdAt"] = DateTimeOffset.UtcNow.ToString("O")
};
var directory = Path.Combine(_paths.Data, "terminal-backups");
Directory.CreateDirectory(directory);
File.WriteAllText(Path.Combine(directory, "default-terminal.json"), backup.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
catch
{
}
}
private bool ConfigureTerminalSettings(string powerShellPath)
{
try
{
var path = ResolveTerminalSettingsPath(createDirectory: true);
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
JsonObject root;
if (File.Exists(path))
{
var backup = path + $".ymhut-{DateTime.Now:yyyyMMddHHmmss}.bak";
File.Copy(path, backup, overwrite: false);
root = JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject();
}
else
{
root = new JsonObject();
}
var profiles = root["profiles"] as JsonObject ?? new JsonObject();
root["profiles"] = profiles;
var list = profiles["list"] as JsonArray ?? new JsonArray();
profiles["list"] = list;
var profile = list
.OfType<JsonObject>()
.FirstOrDefault(item => string.Equals(item["guid"]?.GetValue<string>(), ManagedPowerShellProfileGuid, StringComparison.OrdinalIgnoreCase));
if (profile is null)
{
profile = new JsonObject();
list.Add(profile);
}
profile["guid"] = ManagedPowerShellProfileGuid;
profile["name"] = "PowerShell 7";
profile["commandline"] = powerShellPath;
profile["hidden"] = false;
root["defaultProfile"] = ManagedPowerShellProfileGuid;
var temp = path + ".tmp";
File.WriteAllText(temp, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
File.Move(temp, path, overwrite: true);
return true;
}
catch
{
return false;
}
}
private static string ResolveTerminalSettingsPath(bool createDirectory)
{
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var candidates = new[]
{
Path.Combine(local, "Packages", "Microsoft.WindowsTerminal_8wekyb3d8bbwe", "LocalState", "settings.json"),
Path.Combine(local, "Microsoft", "Windows Terminal", "settings.json")
};
var path = candidates.FirstOrDefault(File.Exists) ??
candidates.FirstOrDefault(candidate => Directory.Exists(Path.GetDirectoryName(candidate)!)) ??
candidates[1];
if (createDirectory)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
}
return path;
}
private static void AddUserPath(string directory)
{
var current = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty;
var entries = current.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (!entries.Contains(directory, StringComparer.OrdinalIgnoreCase))
{
entries.Add(directory);
Environment.SetEnvironmentVariable("PATH", string.Join(';', entries), EnvironmentVariableTarget.User);
}
var processPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
var processEntries = processPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (!processEntries.Contains(directory, StringComparer.OrdinalIgnoreCase))
{
processEntries.Add(directory);
Environment.SetEnvironmentVariable("PATH", string.Join(';', processEntries));
}
_ = SendMessageTimeout(
new nint(0xffff),
0x001A,
0,
"Environment",
0x0002,
3000,
out _);
}
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern nint SendMessageTimeout(
nint hWnd,
uint msg,
nuint wParam,
string lParam,
uint flags,
uint timeout,
out nuint result);
}
+96 -5
View File
@@ -13,6 +13,14 @@ public enum DownloadState
Canceled
}
public enum DownloadOpenKind
{
None,
Installer,
File,
ExternalUri
}
public sealed record DownloadSource(
string Url,
string DisplayName,
@@ -38,7 +46,8 @@ public sealed record DownloadOptions(
string? InstallCommand = null,
string? InstallArguments = null,
bool IsInstaller = false,
bool DeleteAfterInstall = false);
bool DeleteAfterInstall = false,
DownloadOpenKind OpenKind = DownloadOpenKind.None);
public sealed record DownloadSettings(
string DefaultDirectory,
@@ -70,7 +79,8 @@ public sealed record DownloadItem(
string AcceptRanges = "",
long? ContentLength = null,
string FinalUrl = "",
bool ResumeSupported = false)
bool ResumeSupported = false,
DownloadOpenKind OpenKind = DownloadOpenKind.None)
{
[JsonIgnore]
public string EffectivePartialPath => string.IsNullOrWhiteSpace(PartialPath) ? TargetPath + ".partial" : PartialPath;
@@ -81,7 +91,8 @@ public sealed record DownloadItem(
string? installCommand = null,
string? installArguments = null,
bool isInstaller = false,
bool deleteAfterInstall = false)
bool deleteAfterInstall = false,
DownloadOpenKind openKind = DownloadOpenKind.None)
{
var now = DateTimeOffset.UtcNow;
return new DownloadItem(
@@ -95,7 +106,10 @@ public sealed record DownloadItem(
InstallArguments: installArguments,
IsInstaller: isInstaller,
DeleteAfterInstall: deleteAfterInstall,
PartialPath: targetPath + ".partial");
PartialPath: targetPath + ".partial",
OpenKind: openKind == DownloadOpenKind.None
? DownloadOpenPolicy.Classify(source, targetPath, installCommand, isInstaller)
: openKind);
}
public DownloadItem WithProgress(DownloadState state, long receivedBytes, long? totalBytes, double bytesPerSecond, string? error = null)
@@ -143,6 +157,79 @@ public sealed record DownloadItem(
}
}
public static class DownloadOpenPolicy
{
private static readonly HashSet<string> InstallerExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".exe", ".msi", ".msix", ".msixbundle", ".appx", ".appxbundle"
};
public static DownloadOpenKind Classify(
DownloadSource source,
string targetPath,
string? installCommand = null,
bool legacyInstaller = false)
{
var extension = Path.GetExtension(targetPath);
if (string.Equals(extension, ".url", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Path.GetExtension(source.FileName), ".url", StringComparison.OrdinalIgnoreCase) ||
string.Equals(source.SourceKind, "Manual", StringComparison.OrdinalIgnoreCase))
{
return Uri.TryCreate(source.Url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"
? DownloadOpenKind.ExternalUri
: DownloadOpenKind.None;
}
if (legacyInstaller || !string.IsNullOrWhiteSpace(installCommand))
{
return IsSupportedInstaller(targetPath, installCommand)
? DownloadOpenKind.Installer
: DownloadOpenKind.File;
}
return string.IsNullOrWhiteSpace(targetPath) ? DownloadOpenKind.None : DownloadOpenKind.File;
}
public static DownloadItem Normalize(DownloadItem item)
{
var kind = item.OpenKind == DownloadOpenKind.None
? Classify(item.Source, item.TargetPath, item.InstallCommand, item.IsInstaller)
: item.OpenKind;
if (kind == DownloadOpenKind.Installer && !IsSupportedInstaller(item.TargetPath, item.InstallCommand))
{
kind = DownloadOpenKind.File;
}
return item with
{
OpenKind = kind,
IsInstaller = kind == DownloadOpenKind.Installer,
DeleteAfterInstall = kind == DownloadOpenKind.Installer && item.DeleteAfterInstall
};
}
public static bool IsSupportedInstaller(string targetPath, string? installCommand = null)
{
if (!string.IsNullOrWhiteSpace(installCommand) &&
!string.Equals(installCommand, "installer", StringComparison.OrdinalIgnoreCase))
{
return IsExecutableCommand(installCommand);
}
return InstallerExtensions.Contains(Path.GetExtension(targetPath));
}
private static bool IsExecutableCommand(string command)
{
var executable = Path.GetFileName(command.Trim().Trim('"'));
return executable.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) &&
!executable.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase) &&
!executable.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase) &&
!executable.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase);
}
}
public sealed record DownloadProgressSnapshot(
string Id,
DownloadState State,
@@ -235,7 +322,11 @@ public static class DownloadHostProtocol
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter<DownloadState>() }
Converters =
{
new JsonStringEnumConverter<DownloadState>(),
new JsonStringEnumConverter<DownloadOpenKind>()
}
};
public static string Serialize(DownloadHostMessage message)
@@ -20,7 +20,11 @@ public sealed class DownloadQueueStore(AppPaths paths) : IDownloadQueueStore
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter<DownloadState>() }
Converters =
{
new JsonStringEnumConverter<DownloadState>(),
new JsonStringEnumConverter<DownloadOpenKind>()
}
};
private readonly string _path = Path.Combine(paths.Data, "downloads.json");
+593
View File
@@ -0,0 +1,593 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Numerics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace YMhut.Box.Core.Music;
internal sealed record KugouDeviceCredential(
int Version,
string Guid,
string Mid,
string Dev,
string Mac,
string Dfid);
internal sealed record KugouAccountCredential(
int Version,
string UserId,
string Token,
string Nickname,
string AvatarUrl,
string VipLevel,
string VipToken = "",
string T1 = "");
internal sealed record KugouApiResponse(JsonObject Json, IReadOnlyDictionary<string, string> Cookies);
internal sealed class KugouApiException(
string message,
bool authenticationFailure = false,
HttpStatusCode? statusCode = null,
Exception? innerException = null) : Exception(message, innerException)
{
public bool AuthenticationFailure { get; } = authenticationFailure;
public HttpStatusCode? StatusCode { get; } = statusCode;
}
/// <summary>
/// Minimal, HTTPS-only KuGou protocol adapter based on KuGouMusicApi commit
/// 06560e3e053bda1ab830750db6f645bab703f824.
/// </summary>
internal sealed class KugouApiClient : IDisposable
{
internal const int AppId = 1005;
internal const int ClientVersion = 20489;
internal const int SourceAppId = 2919;
private const string Gateway = "https://gateway.kugou.com";
private const string WebSalt = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt";
private const string AndroidSalt = "OIlwieks28dk2k092lksi2UIkp";
private const string TrackKeySalt = "57ae12eb6890223e355ccfcb74edf70d";
private const string AndroidUserAgent = "Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi";
private const string RsaPublicKey = """
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB
-----END PUBLIC KEY-----
""";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = false
};
private static readonly Regex SensitiveProviderField = new(
"(?i)([\\\"']?\\b(?:token|vip_token|t1|dfid|mid)\\b[\\\"']?\\s*[:=]\\s*[\\\"']?)[^,\\s;\\\"'}]+",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly HttpClient _client;
private readonly bool _disposeHandler;
private readonly Func<string>? _playlistKeyFactory;
public KugouApiClient(HttpMessageHandler? handler = null, Func<string>? playlistKeyFactory = null)
{
_disposeHandler = handler is null;
_playlistKeyFactory = playlistKeyFactory;
_client = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
_client.Timeout = TimeSpan.FromSeconds(15);
}
public KugouDeviceCredential Device { get; set; } = CreateDeviceCredential();
public static KugouDeviceCredential CreateDeviceCredential()
{
var guid = Md5Hex(global::System.Guid.NewGuid().ToString("D", CultureInfo.InvariantCulture));
return new KugouDeviceCredential(
1,
guid,
CalculateMid(guid),
RandomString(10),
"02:00:00:00:00:00",
"-");
}
public async Task<KugouDeviceCredential> RegisterDeviceAsync(CancellationToken cancellationToken)
{
var payload = new JsonObject
{
["availableRamSize"] = 4_983_533_568L,
["availableRomSize"] = 48_114_719,
["availableSDSize"] = 48_114_717,
["basebandVer"] = "",
["batteryLevel"] = 100,
["batteryStatus"] = 3,
["brand"] = "Windows",
["buildSerial"] = "unknown",
["device"] = "desktop",
["imei"] = Device.Guid,
["imsi"] = "",
["manufacturer"] = "Microsoft",
["uuid"] = Device.Guid,
["accelerometer"] = false,
["accelerometerValue"] = "",
["gravity"] = false,
["gravityValue"] = "",
["gyroscope"] = false,
["gyroscopeValue"] = "",
["light"] = false,
["lightValue"] = "",
["magnetic"] = false,
["magneticValue"] = "",
["orientation"] = false,
["orientationValue"] = "",
["pressure"] = false,
["pressureValue"] = "",
["step_counter"] = false,
["step_counterValue"] = "",
["temperature"] = false,
["temperatureValue"] = ""
};
var envelope = EncryptPlaylistPayload(payload.ToJsonString(JsonOptions), _playlistKeyFactory?.Invoke());
var rsa = RsaEncrypt(new JsonObject
{
["aes"] = envelope.Key,
["uid"] = 0,
["token"] = ""
}.ToJsonString(JsonOptions), uppercase: false);
var response = await SendAndroidRawAsync(
HttpMethod.Post,
"https://userservice.kugou.com",
"/risk/v2/r_register_dev",
new Dictionary<string, string>
{
["part"] = "1",
["platid"] = "1",
["p"] = rsa
},
envelope.Value,
null,
null,
cancellationToken).ConfigureAwait(false);
var json = ParseEncryptedJson(response, envelope.Key, "设备注册");
EnsureProviderSuccess(json, "设备注册", accountRequest: false);
var dfid = Text(json["data"], "dfid");
if (string.IsNullOrWhiteSpace(dfid)) return Device;
Device = Device with { Dfid = dfid };
return Device;
}
public async Task<string> CreateQrKeyAsync(CancellationToken cancellationToken)
{
var result = await SendWebAsync(
"https://login-user.kugou.com",
"/v2/qrcode",
new Dictionary<string, string>
{
["appid"] = "1001",
["type"] = "1",
["plat"] = "4",
["qrcode_txt"] = $"https://h5.kugou.com/apps/loginQRCode/html/index.html?appid={AppId}&",
["srcappid"] = SourceAppId.ToString(CultureInfo.InvariantCulture)
},
cancellationToken).ConfigureAwait(false);
var key = Text(result.Json["data"], "qrcode") ?? Text(result.Json, "qrcode");
if (string.IsNullOrWhiteSpace(key)) throw new KugouApiException("酷狗未返回二维码登录标识。");
return key;
}
public Task<KugouApiResponse> CheckQrAsync(string key, CancellationToken cancellationToken)
=> SendWebAsync(
"https://login-user.kugou.com",
"/v2/get_userinfo_qrcode",
new Dictionary<string, string>
{
["plat"] = "4",
["appid"] = AppId.ToString(CultureInfo.InvariantCulture),
["srcappid"] = SourceAppId.ToString(CultureInfo.InvariantCulture),
["qrcode"] = key,
["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)
},
cancellationToken);
public Task<KugouApiResponse> SendAndroidAsync(
HttpMethod method,
string path,
IReadOnlyDictionary<string, string>? query,
JsonNode? data,
KugouAccountCredential? account,
string? router,
CancellationToken cancellationToken,
string baseUrl = Gateway,
bool addTrackKey = false)
{
var body = data?.ToJsonString(JsonOptions) ?? string.Empty;
return SendAndroidCoreAsync(method, baseUrl, path, query, body, "application/json", account, router, addTrackKey, cancellationToken);
}
public async Task DeleteCollectedPlaylistAsync(
KugouAccountCredential account,
long listId,
CancellationToken cancellationToken)
{
var envelope = EncryptPlaylistPayload(new JsonObject
{
["listid"] = listId,
["total_ver"] = 0,
["type"] = 1
}.ToJsonString(JsonOptions), _playlistKeyFactory?.Invoke());
var rsa = RsaEncrypt(new JsonObject
{
["aes"] = envelope.Key,
["uid"] = account.UserId,
["token"] = account.Token
}.ToJsonString(JsonOptions), uppercase: true);
var clientTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
var response = await SendAndroidRawAsync(
HttpMethod.Post,
Gateway,
"/v2/delete_list",
new Dictionary<string, string>
{
["clienttime"] = clientTime,
["key"] = SignParamsKey(clientTime),
["last_area"] = "gztx",
["clientver"] = ClientVersion.ToString(CultureInfo.InvariantCulture),
["appid"] = AppId.ToString(CultureInfo.InvariantCulture),
["last_time"] = clientTime,
["p"] = rsa
},
envelope.Value,
account,
"cloudlist.service.kugou.com",
cancellationToken).ConfigureAwait(false);
var json = ParseEncryptedJson(response, envelope.Key, "取消收藏歌单");
EnsureProviderSuccess(json, "取消收藏歌单", accountRequest: true);
}
public void Dispose()
{
_client.Dispose();
if (_disposeHandler)
{
// HttpClient already owns and disposes its internally-created handler.
}
}
internal static string SignatureWeb(IReadOnlyDictionary<string, string> parameters)
=> Md5Hex(WebSalt + string.Concat(parameters.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}")) + WebSalt);
internal static string SignatureAndroid(IReadOnlyDictionary<string, string> parameters, string body = "")
=> Md5Hex(AndroidSalt + string.Concat(parameters.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}")) + body + AndroidSalt);
internal static string SignatureRegister(IReadOnlyDictionary<string, string> parameters)
=> Md5Hex("1014" + string.Concat(parameters.Values.OrderBy(value => value, StringComparer.Ordinal)) + "1014");
internal static string SignParamsKey(string value)
=> Md5Hex($"{AppId}{AndroidSalt}{ClientVersion}{value}");
internal static string CalculateMid(string value)
=> BigInteger.Parse("0" + Md5Hex(value), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture)
.ToString(CultureInfo.InvariantCulture);
internal static string TrackKey(string hash, string mid, string userId)
=> Md5Hex($"{hash}{TrackKeySalt}{AppId}{mid}{userId}");
internal static string? Text(JsonNode? node, string property)
{
var value = node?[property];
if (value is null) return null;
if (value is JsonValue text && text.TryGetValue<string>(out var result)) return result;
if (value is JsonValue number && number.TryGetValue<long>(out var integer)) return integer.ToString(CultureInfo.InvariantCulture);
if (value is JsonValue boolean && boolean.TryGetValue<bool>(out var flag)) return flag ? "true" : "false";
return value.ToJsonString(JsonOptions).Trim('"');
}
internal static int Integer(JsonNode? node, string property)
=> int.TryParse(Text(node, property), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : 0;
internal static long Long(JsonNode? node, string property)
=> long.TryParse(Text(node, property), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : 0;
internal static IReadOnlyList<JsonNode> Array(JsonNode? node)
=> node is JsonArray array ? array.Where(item => item is not null).Cast<JsonNode>().ToArray() : [];
private async Task<KugouApiResponse> SendWebAsync(
string baseUrl,
string path,
IReadOnlyDictionary<string, string> values,
CancellationToken cancellationToken)
{
var parameters = DefaultParameters();
foreach (var pair in values) parameters[pair.Key] = pair.Value;
parameters["signature"] = SignatureWeb(parameters);
using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri(baseUrl, path, parameters));
ApplyHeaders(request, parameters["clienttime"], web: true, router: null);
return await SendJsonAsync(request, "酷狗登录", accountRequest: false, cancellationToken).ConfigureAwait(false);
}
private async Task<KugouApiResponse> SendAndroidCoreAsync(
HttpMethod method,
string baseUrl,
string path,
IReadOnlyDictionary<string, string>? query,
string body,
string contentType,
KugouAccountCredential? account,
string? router,
bool addTrackKey,
CancellationToken cancellationToken)
{
var parameters = DefaultParameters(account);
if (query is not null)
{
foreach (var pair in query) parameters[pair.Key] = pair.Value;
}
if (addTrackKey)
{
var hash = parameters.GetValueOrDefault("hash") ?? string.Empty;
parameters["key"] = TrackKey(hash, parameters["mid"], parameters.GetValueOrDefault("userid") ?? "0");
}
parameters["signature"] = SignatureAndroid(parameters, body);
using var request = new HttpRequestMessage(method, BuildUri(baseUrl, path, parameters));
ApplyHeaders(request, parameters["clienttime"], web: false, router);
if (method != HttpMethod.Get && method != HttpMethod.Head)
{
request.Content = new StringContent(body, Encoding.UTF8, contentType);
}
return await SendJsonAsync(request, OperationName(path), account is not null, cancellationToken).ConfigureAwait(false);
}
private async Task<byte[]> SendAndroidRawAsync(
HttpMethod method,
string baseUrl,
string path,
IReadOnlyDictionary<string, string>? query,
string body,
KugouAccountCredential? account,
string? router,
CancellationToken cancellationToken)
{
var parameters = DefaultParameters(account);
if (query is not null)
{
foreach (var pair in query) parameters[pair.Key] = pair.Value;
}
parameters["signature"] = SignatureAndroid(parameters, body);
using var request = new HttpRequestMessage(method, BuildUri(baseUrl, path, parameters));
ApplyHeaders(request, parameters["clienttime"], web: false, router);
request.Content = new StringContent(body, Encoding.UTF8, "text/plain");
try
{
using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new KugouApiException($"酷狗服务返回 HTTP {(int)response.StatusCode}。", response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, response.StatusCode);
}
return bytes;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new KugouApiException("连接酷狗服务超时。", innerException: null);
}
catch (HttpRequestException exception)
{
throw new KugouApiException("无法连接酷狗服务。", statusCode: exception.StatusCode, innerException: exception);
}
}
private async Task<KugouApiResponse> SendJsonAsync(
HttpRequestMessage request,
string operation,
bool accountRequest,
CancellationToken cancellationToken)
{
try
{
using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new KugouApiException(
$"{operation}失败,酷狗服务返回 HTTP {(int)response.StatusCode}。",
response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden,
response.StatusCode);
}
var json = ParseJson(text, operation);
EnsureProviderSuccess(json, operation, accountRequest);
var cookies = response.Headers.TryGetValues("Set-Cookie", out var values)
? ParseResponseCookies(values)
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return new KugouApiResponse(json, cookies);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new KugouApiException($"{operation}超时。");
}
catch (HttpRequestException exception)
{
throw new KugouApiException($"{operation}无法连接酷狗服务。", statusCode: exception.StatusCode, innerException: exception);
}
}
private Dictionary<string, string> DefaultParameters(KugouAccountCredential? account = null)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal)
{
["dfid"] = string.IsNullOrWhiteSpace(Device.Dfid) ? "-" : Device.Dfid,
["mid"] = Device.Mid,
["uuid"] = "-",
["appid"] = AppId.ToString(CultureInfo.InvariantCulture),
["clientver"] = ClientVersion.ToString(CultureInfo.InvariantCulture),
["clienttime"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)
};
if (account is not null)
{
result["token"] = account.Token;
result["userid"] = account.UserId;
}
return result;
}
private void ApplyHeaders(HttpRequestMessage request, string clientTime, bool web, string? router)
{
request.Headers.TryAddWithoutValidation("User-Agent", web
? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36"
: AndroidUserAgent);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.TryAddWithoutValidation("dfid", string.IsNullOrWhiteSpace(Device.Dfid) ? "-" : Device.Dfid);
request.Headers.TryAddWithoutValidation("clienttime", clientTime);
request.Headers.TryAddWithoutValidation("mid", Device.Mid);
request.Headers.TryAddWithoutValidation("kg-rc", "1");
request.Headers.TryAddWithoutValidation("kg-thash", "5d816a0");
request.Headers.TryAddWithoutValidation("kg-rec", "1");
request.Headers.TryAddWithoutValidation("kg-rf", "B9EDA08A64250DEFFBCADDEE00F8F25F");
if (!string.IsNullOrWhiteSpace(router)) request.Headers.TryAddWithoutValidation("x-router", router);
}
private static Uri BuildUri(string baseUrl, string path, IReadOnlyDictionary<string, string> parameters)
{
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var origin) || origin.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException("Kugou API requests must use HTTPS.");
}
var query = string.Join("&", parameters.Select(pair =>
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
return new Uri(origin, path + "?" + query);
}
private static JsonObject ParseJson(string text, string operation)
{
try
{
return JsonNode.Parse(text) as JsonObject ?? throw new JsonException();
}
catch (JsonException exception)
{
throw new KugouApiException($"{operation}返回了无效数据。", innerException: exception);
}
}
private static void EnsureProviderSuccess(JsonObject json, string operation, bool accountRequest)
{
var status = Integer(json, "status");
var errorCode = Integer(json, "error_code");
if ((status != 0 || !json.ContainsKey("status")) && errorCode == 0) return;
var code = errorCode != 0 ? errorCode : Integer(json, "errcode");
var message = Text(json, "error") ?? Text(json, "msg") ?? Text(json, "message") ?? "服务方拒绝了请求";
var authenticationFailure = accountRequest && IsAuthenticationFailure(code, message);
throw new KugouApiException($"{operation}失败:{SanitizeProviderMessage(message)}{(code == 0 ? string.Empty : $" [{code}]")}", authenticationFailure);
}
private static bool IsAuthenticationFailure(int code, string message)
=> code is 401 or 403 or 1002 or 1003 or 20001 or 20002 or 20017 or 20018 or 20022 or 30000 ||
message.Contains("token", StringComparison.OrdinalIgnoreCase) ||
message.Contains("登录", StringComparison.OrdinalIgnoreCase) ||
message.Contains("login", StringComparison.OrdinalIgnoreCase);
internal static string SanitizeProviderMessage(string value)
{
if (string.IsNullOrWhiteSpace(value)) return "服务方拒绝了请求";
var compact = SensitiveProviderField.Replace(value.Replace('\r', ' ').Replace('\n', ' ').Trim(), "$1[redacted]");
return compact.Length <= 160 ? compact : compact[..160];
}
private static IReadOnlyDictionary<string, string> ParseResponseCookies(IEnumerable<string> values)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var header in values)
{
var pair = header.Split(';', 2)[0].Split('=', 2);
if (pair.Length == 2 && !string.IsNullOrWhiteSpace(pair[0])) result[pair[0].Trim()] = pair[1].Trim();
}
return result;
}
internal static (string Key, string Value) EncryptPlaylistPayload(string plainText, string? keySeed = null)
{
keySeed = string.IsNullOrWhiteSpace(keySeed) ? RandomString(6).ToLowerInvariant() : keySeed;
var digest = Md5Hex(keySeed);
var key = Encoding.UTF8.GetBytes(digest[..16]);
var iv = Encoding.UTF8.GetBytes(digest[16..32]);
using var aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var encryptor = aes.CreateEncryptor();
var encrypted = encryptor.TransformFinalBlock(Encoding.UTF8.GetBytes(plainText), 0, Encoding.UTF8.GetByteCount(plainText));
return (keySeed, Convert.ToBase64String(encrypted));
}
internal static string DecryptPlaylistPayload(byte[] cipherText, string keySeed)
{
var digest = Md5Hex(keySeed);
using var aes = Aes.Create();
aes.Key = Encoding.UTF8.GetBytes(digest[..16]);
aes.IV = Encoding.UTF8.GetBytes(digest[16..32]);
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var decryptor = aes.CreateDecryptor();
var decrypted = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length);
return Encoding.UTF8.GetString(decrypted);
}
internal static string RsaEncrypt(string value, bool uppercase)
{
using var rsa = RSA.Create();
rsa.ImportFromPem(RsaPublicKey);
var encrypted = rsa.Encrypt(Encoding.UTF8.GetBytes(value), RSAEncryptionPadding.Pkcs1);
var hex = Convert.ToHexString(encrypted);
return uppercase ? hex : hex.ToLowerInvariant();
}
private static JsonObject ParseEncryptedJson(byte[] cipherText, string keySeed, string operation)
{
try
{
return ParseJson(DecryptPlaylistPayload(cipherText, keySeed), operation);
}
catch (KugouApiException)
{
throw;
}
catch (CryptographicException exception)
{
throw new KugouApiException($"{operation}返回了无法解密的数据。", innerException: exception);
}
}
private static string Md5Hex(string value)
=> Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string RandomString(int length)
{
const string alphabet = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
return string.Create(length, alphabet, static (span, chars) =>
{
for (var index = 0; index < span.Length; index++) span[index] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
});
}
private static string OperationName(string path) => path switch
{
"/v7/get_all_list" => "获取酷狗用户歌单",
"/everyday_song_recommend" => "获取酷狗每日推荐",
"/v2/special_recommend" => "获取酷狗推荐歌单",
"/v4/get_list_all_file" => "获取酷狗歌单歌曲",
"/pubsongs/v2/get_other_list_file_nofilt" => "获取酷狗歌单歌曲",
"/v5/url" => "解析酷狗播放地址",
"/cloudlist.service/v6/add_song" => "收藏歌曲",
"/v4/delete_songs" => "取消收藏歌曲",
"/cloudlist.service/v5/add_list" => "收藏歌单",
_ => "酷狗请求"
};
}
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -41,6 +41,8 @@ public sealed record MusicSong(
bool Playable = true)
{
public string? MvId { get; init; }
internal object? ProviderData { get; init; }
}
public sealed record MusicPlaylist(
@@ -50,7 +52,12 @@ public sealed record MusicPlaylist(
string CoverUrl,
int TrackCount,
string Creator,
bool Subscribed = false);
bool Subscribed = false)
{
public bool CanSubscribe { get; init; }
internal object? ProviderData { get; init; }
}
public sealed record MusicLoginState(
bool LoggedIn,
@@ -173,9 +180,11 @@ public interface IMusicProvider
Task<MusicMv?> GetMvAsync(string songId, string? mvId = null, CancellationToken cancellationToken = default);
Task SetFavoriteAsync(string songId, bool favorite, CancellationToken cancellationToken = default);
Task<bool?> GetFavoriteStateAsync(MusicSong song, CancellationToken cancellationToken = default);
Task SetPlaylistSubscribedAsync(string playlistId, bool subscribed, CancellationToken cancellationToken = default);
Task SetFavoriteAsync(MusicSong song, bool favorite, CancellationToken cancellationToken = default);
Task SetPlaylistSubscribedAsync(MusicPlaylist playlist, bool subscribed, CancellationToken cancellationToken = default);
}
public interface IMusicPlaybackService
@@ -421,24 +421,27 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
uri is null ? "提供方未返回可播放的 MV 地址。" : null);
}
public async Task SetFavoriteAsync(string songId, bool favorite, CancellationToken cancellationToken = default)
public Task<bool?> GetFavoriteStateAsync(MusicSong song, CancellationToken cancellationToken = default)
=> Task.FromResult<bool?>(null);
public async Task SetFavoriteAsync(MusicSong song, bool favorite, CancellationToken cancellationToken = default)
{
EnsureLoggedIn();
var result = await PostApiAsync("/api/song/like", new Dictionary<string, string>
{
["trackId"] = songId,
["trackId"] = song.Id,
["like"] = favorite ? "true" : "false",
["csrf_token"] = CsrfToken()
}, cancellationToken).ConfigureAwait(false);
EnsureSuccess(result, "收藏歌曲");
}
public async Task SetPlaylistSubscribedAsync(string playlistId, bool subscribed, CancellationToken cancellationToken = default)
public async Task SetPlaylistSubscribedAsync(MusicPlaylist playlist, bool subscribed, CancellationToken cancellationToken = default)
{
EnsureLoggedIn();
var result = await PostApiAsync(subscribed ? "/api/playlist/subscribe" : "/api/playlist/unsubscribe", new Dictionary<string, string>
{
["id"] = playlistId,
["id"] = playlist.Id,
["csrf_token"] = CsrfToken()
}, cancellationToken).ConfigureAwait(false);
EnsureSuccess(result, "收藏歌单");
@@ -0,0 +1,83 @@
using System.Globalization;
using System.Text;
namespace YMhut.Box.Core.Tools;
public enum SerialParityMode
{
None,
Odd,
Even,
Mark,
Space
}
public enum SerialStopBitsMode
{
One,
OnePointFive,
Two
}
public sealed record SerialConnectionOptions(
string PortName,
int BaudRate,
int DataBits = 8,
SerialParityMode Parity = SerialParityMode.None,
SerialStopBitsMode StopBits = SerialStopBitsMode.One);
public sealed class SerialDataReceivedEventArgs(ReadOnlyMemory<byte> data) : EventArgs
{
public ReadOnlyMemory<byte> Data { get; } = data;
}
public interface ISerialPortTransport : IDisposable
{
bool IsOpen { get; }
event EventHandler<SerialDataReceivedEventArgs>? DataReceived;
IReadOnlyList<string> GetPortNames();
Task OpenAsync(SerialConnectionOptions options, CancellationToken cancellationToken = default);
Task WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default);
Task CloseAsync(CancellationToken cancellationToken = default);
}
public static class SerialPayloadCodec
{
public static byte[] Parse(string value, bool hexadecimal)
{
if (!hexadecimal)
{
return Encoding.UTF8.GetBytes(value ?? string.Empty);
}
var compact = new string((value ?? string.Empty).Where(Uri.IsHexDigit).ToArray());
if (compact.Length == 0)
{
return [];
}
if (compact.Length % 2 != 0)
{
throw new FormatException("Hexadecimal input must contain complete byte pairs.");
}
var bytes = new byte[compact.Length / 2];
for (var index = 0; index < bytes.Length; index++)
{
if (!byte.TryParse(compact.AsSpan(index * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out bytes[index]))
{
throw new FormatException("Hexadecimal input contains an invalid byte.");
}
}
return bytes;
}
public static string Format(ReadOnlySpan<byte> data, bool hexadecimal)
=> hexadecimal
? Convert.ToHexString(data).Chunk(2).Select(chars => new string(chars)).Aggregate(string.Empty, (current, item) => string.IsNullOrEmpty(current) ? item : current + " " + item)
: Encoding.UTF8.GetString(data);
}
+668 -245
View File
@@ -1,5 +1,7 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YMhut.Box.Core.Music;
@@ -8,305 +10,726 @@ namespace YMhut.Box.Tests;
[TestClass]
public sealed class KugouMusicProviderTests
{
private const string AccountToken = "account-token-value";
[TestMethod]
public async Task SearchMapsSongsToKugouProvider()
public void ProtocolSignaturesAndMidMatchPinnedVectors()
{
HttpRequestMessage? captured = null;
var handler = new StubHttpHandler(request =>
var parameters = new Dictionary<string, string>
{
captured = request;
return Task.FromResult(JsonResponse("""
{"data":{"lists":[{"FileHash":"ABC123","AlbumID":"456","SongName":"Song","SingerName":"Artist","AlbumName":"Album","Duration":180,"Image":"https://img.test/{size}.jpg"}]}}
"""));
});
using var provider = new KugouMusicProvider(new MemoryCredentialStore(), handler);
["q"] = "hello world",
["b"] = "two",
["a"] = "1"
};
var result = await provider.SearchAsync("test", MusicSearchKind.Songs);
Assert.AreEqual("92ba8f8357f6e64a82b6cf6016ff007b", KugouApiClient.SignatureWeb(parameters));
Assert.AreEqual("33cbaa65993186fa666b7fd90547100d", KugouApiClient.SignatureAndroid(parameters, "{\"x\":\"中\"}"));
Assert.AreEqual("80313980e472853765eee2a9da0b1563", KugouApiClient.SignatureRegister(new Dictionary<string, string>
{
["z"] = "alpha",
["a"] = "2",
["m"] = "10"
}));
Assert.AreEqual("a1f65b6a8fe7e191521406ce8661ae02", KugouApiClient.SignParamsKey("1700000000"));
Assert.AreEqual("7041712d522dab6bb3163670d52e135a", KugouApiClient.TrackKey("abcdef", "123456", "42"));
Assert.AreEqual("158516822156227256334142177873666215952", KugouApiClient.CalculateMid("device-guid-value"));
}
[TestMethod]
public void PlaylistAesAndRsaRequestFieldsMatchReferenceStructure()
{
const string plainText = "{\"listid\":42,\"type\":1}";
var encrypted = KugouApiClient.EncryptPlaylistPayload(plainText, "abc123");
Assert.AreEqual("abc123", encrypted.Key);
Assert.AreEqual("YjN124J1gpe3gia1UvybR+67MGkaadfr/KWCRbV3CY4=", encrypted.Value);
Assert.AreEqual(plainText, KugouApiClient.DecryptPlaylistPayload(Convert.FromBase64String(encrypted.Value), encrypted.Key));
var lower = KugouApiClient.RsaEncrypt("{\"aes\":\"abc123\",\"uid\":42,\"token\":\"t\"}", uppercase: false);
var upper = KugouApiClient.RsaEncrypt("{\"aes\":\"abc123\",\"uid\":42,\"token\":\"t\"}", uppercase: true);
Assert.AreEqual(256, lower.Length);
Assert.AreEqual(256, upper.Length);
Assert.IsTrue(lower.All(character => char.IsAsciiHexDigit(character) && !char.IsUpper(character)));
Assert.IsTrue(upper.All(character => char.IsAsciiHexDigit(character) && !char.IsLower(character)));
}
[TestMethod]
public async Task RegisterDeviceBuildsSignedHttpsEncryptedRequest()
{
CapturedRequest? captured = null;
using var client = new KugouApiClient(new StubHttpHandler(async (request, _) =>
{
captured = await CapturedRequest.FromAsync(request);
return BytesResponse([1, 2, 3]);
}), () => "abc123")
{
Device = TestDevice("-")
};
await Assert.ThrowsExactlyAsync<KugouApiException>(() => client.RegisterDeviceAsync(CancellationToken.None));
Assert.HasCount(1, result.Songs);
Assert.AreEqual("kugou", result.Songs[0].Provider);
Assert.AreEqual("ABC123|456", result.Songs[0].Id);
Assert.AreEqual(TimeSpan.FromMinutes(3), result.Songs[0].Duration);
Assert.IsNotNull(captured);
Assert.AreEqual("songsearch.kugou.com", captured.RequestUri?.Host);
Assert.IsFalse(captured.Headers.UserAgent.ToString().Contains("Android", StringComparison.OrdinalIgnoreCase));
Assert.AreEqual(Uri.UriSchemeHttps, captured.Uri.Scheme);
Assert.AreEqual("userservice.kugou.com", captured.Uri.Host);
Assert.AreEqual("/risk/v2/r_register_dev", captured.Uri.AbsolutePath);
var query = ParseQuery(captured.Uri);
Assert.AreEqual("1", query["part"]);
Assert.AreEqual("1", query["platid"]);
Assert.AreEqual(256, query["p"].Length);
_ = Convert.FromBase64String(captured.Body);
var unsigned = query.Where(pair => pair.Key != "signature").ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
Assert.AreEqual(query["signature"], KugouApiClient.SignatureAndroid(unsigned, captured.Body));
}
[TestMethod]
public async Task ResolveStreamUsesHashAndAlbumIdFromSongIdentity()
public async Task QrFlowMapsWaitingScannedSuccessAndExpiredStates()
{
HttpRequestMessage? captured = null;
var handler = new StubHttpHandler(request =>
var credentials = DeviceStore();
var checks = 0;
Uri? createQrUri = null;
var handler = new StubHttpHandler((request, _) => Task.FromResult(request.RequestUri!.AbsolutePath switch
{
captured = request;
return Task.FromResult(JsonResponse("""
{"status":1,"errcode":0,"url":"https://media.test/song.mp3","bitRate":128}
"""));
});
using var provider = new KugouMusicProvider(new MemoryCredentialStore(), handler);
var result = await provider.ResolveStreamAsync("ABC123|456", MusicPlaybackQuality.High);
Assert.IsTrue(result.Playable);
Assert.AreEqual(new Uri("https://media.test/song.mp3"), result.Uri);
Assert.AreEqual(128_000, result.Bitrate);
Assert.AreEqual(MusicPlaybackQuality.Standard, result.Quality);
Assert.IsNotNull(result.ProviderReason);
Assert.IsNotNull(captured?.RequestUri);
Assert.AreEqual("m.kugou.com", captured.RequestUri.Host);
Assert.IsTrue(captured.RequestUri.Query.Contains("hash=ABC123", StringComparison.Ordinal));
Assert.IsTrue(captured.RequestUri.Query.Contains("album_id=456", StringComparison.Ordinal));
}
[TestMethod]
public async Task LyricsUseHashWithoutSerializedAlbumId()
{
var requests = new List<Uri>();
var handler = new StubHttpHandler(request =>
{
requests.Add(request.RequestUri!);
if (request.RequestUri!.AbsolutePath.EndsWith("/search", StringComparison.Ordinal))
"/v2/qrcode" => CaptureQrRequest(request.RequestUri, out createQrUri),
"/v2/get_userinfo_qrcode" => JsonResponse(++checks switch
{
return Task.FromResult(JsonResponse("""
{"candidates":[{"id":"42","accesskey":"key"}]}
"""));
}
1 => "{\"status\":1,\"data\":{\"status\":1}}",
2 => "{\"status\":1,\"data\":{\"status\":2}}",
_ => "{\"status\":1,\"data\":{\"status\":4,\"token\":\"account-token-value\",\"userid\":\"42\",\"nickname\":\"Listener\",\"vip_type\":\"2\"}}"
}),
"/v7/get_all_list" => JsonResponse(UserLists()),
_ => JsonResponse("{\"status\":1}")
}));
using var provider = new KugouMusicProvider(credentials, handler);
return Task.FromResult(JsonResponse("""
{"content":"WzAwOjAwLjAwXVRlc3Q="}
"""));
});
using var provider = new KugouMusicProvider(new MemoryCredentialStore(), handler);
var session = await provider.CreateQrSessionAsync();
var waiting = await provider.CheckQrSessionAsync(session);
var scanned = await provider.CheckQrSessionAsync(session);
var success = await provider.CheckQrSessionAsync(session);
var expired = await provider.CheckQrSessionAsync(new MusicQrSession("old", "https://example.test", DateTimeOffset.Now.AddSeconds(-1)));
var lyrics = await provider.GetLyricsAsync("ABC123|456");
Assert.HasCount(1, lyrics.Lines);
Assert.HasCount(2, requests);
Assert.IsTrue(requests[0].Query.Contains("hash=ABC123", StringComparison.Ordinal));
Assert.IsFalse(requests[0].Query.Contains("456", StringComparison.Ordinal));
Assert.IsTrue(session.LoginUrl.StartsWith("https://h5.kugou.com/", StringComparison.Ordinal));
Assert.IsNotNull(createQrUri);
StringAssert.Contains(createQrUri.Query, "qrcode_txt=https%3A%2F%2Fh5.kugou.com%2F");
Assert.AreEqual(801, waiting.Code);
Assert.AreEqual(802, scanned.Code);
Assert.AreEqual(803, success.Code);
Assert.IsTrue(success.Completed);
Assert.AreEqual(800, expired.Code);
Assert.IsTrue(expired.Expired);
Assert.IsTrue(provider.LoginState.LoggedIn);
Assert.AreEqual("Listener", provider.LoginState.Nickname);
StringAssert.Contains(await credentials.LoadAsync("kugou"), "account-token-value");
}
[TestMethod]
public async Task LoginRequiresCompleteKugouCookieAndOfficialSessionValidation()
public async Task QrNetworkFailureCanRetryTheSameSession()
{
HttpRequestMessage? captured = null;
var credentials = new MemoryCredentialStore();
var handler = new StubHttpHandler(request =>
var attempts = 0;
using var provider = new KugouMusicProvider(DeviceStore(), new StubHttpHandler((_, _) =>
{
captured = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
attempts++;
if (attempts == 1) throw new HttpRequestException("temporary outage");
return Task.FromResult(JsonResponse("{\"status\":1,\"data\":{\"status\":1}}"));
}));
var session = FutureQr();
await Assert.ThrowsExactlyAsync<KugouApiException>(() => provider.CheckQrSessionAsync(session));
var retried = await provider.CheckQrSessionAsync(session);
Assert.AreEqual(801, retried.Code);
Assert.IsFalse(retried.Terminal);
Assert.AreEqual(2, attempts);
}
[TestMethod]
public async Task QrSuccessRequiresTokenAndServerValidation()
{
var missingCredentials = DeviceStore();
using var missingProvider = new KugouMusicProvider(missingCredentials, new StubHttpHandler((request, _) =>
Task.FromResult(JsonResponse("{\"status\":1,\"data\":{\"status\":4,\"userid\":\"42\"}}"))));
var missing = await missingProvider.CheckQrSessionAsync(FutureQr());
Assert.AreEqual(805, missing.Code);
Assert.IsTrue(missing.Terminal);
Assert.AreEqual(string.Empty, await missingCredentials.LoadAsync("kugou"));
var rejectedCredentials = DeviceStore();
using var rejectedProvider = new KugouMusicProvider(rejectedCredentials, new StubHttpHandler((request, _) =>
Task.FromResult(request.RequestUri!.AbsolutePath == "/v7/get_all_list"
? JsonResponse("{\"status\":0,\"error_code\":20017,\"message\":\"token invalid\"}")
: JsonResponse("{\"status\":1,\"data\":{\"status\":4,\"userid\":\"42\",\"token\":\"rejected-token\"}}"))));
var rejected = await rejectedProvider.CheckQrSessionAsync(FutureQr());
Assert.AreEqual(805, rejected.Code);
Assert.IsFalse(rejectedProvider.LoginState.LoggedIn);
Assert.AreEqual(string.Empty, await rejectedCredentials.LoadAsync("kugou"));
}
[TestMethod]
public async Task QrPollingHonorsCancellation()
{
using var provider = new KugouMusicProvider(DeviceStore(), new StubHttpHandler(async (_, cancellationToken) =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return JsonResponse("{}");
}));
using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(30));
try
{
await provider.CheckQrSessionAsync(FutureQr(), cancellation.Token);
Assert.Fail("QR polling should have been canceled.");
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
}
}
[TestMethod]
public async Task RestoreKeepsCredentialsOnNetworkFailureAndRetriesLater()
{
var credentials = AccountStore();
var attempts = 0;
using var provider = new KugouMusicProvider(credentials, new StubHttpHandler((_, _) =>
{
attempts++;
if (attempts == 1) throw new HttpRequestException("offline");
return Task.FromResult(JsonResponse(UserLists()));
}));
await Assert.ThrowsExactlyAsync<KugouApiException>(() => provider.InitializeAsync());
StringAssert.Contains(await credentials.LoadAsync("kugou"), AccountToken);
Assert.IsFalse(provider.LoginState.LoggedIn);
await provider.InitializeAsync();
Assert.IsTrue(provider.LoginState.LoggedIn);
Assert.AreEqual(2, attempts);
}
[TestMethod]
public async Task RestoreClearsOnlyExplicitlyRejectedAccountCredential()
{
var credentials = AccountStore();
var deviceBefore = await credentials.LoadAsync("kugou-device");
using var provider = new KugouMusicProvider(credentials, new StubHttpHandler((_, _) => Task.FromResult(
JsonResponse("{\"status\":0,\"error_code\":20017,\"message\":\"login rejected\"}"))));
await provider.InitializeAsync();
Assert.IsFalse(provider.LoginState.LoggedIn);
Assert.AreEqual(string.Empty, await credentials.LoadAsync("kugou"));
AssertDeviceIdentityEqual(deviceBefore, await credentials.LoadAsync("kugou-device"));
}
[TestMethod]
public async Task LegacyCookieMigratesToVersionedAccountJson()
{
var credentials = DeviceStore();
credentials.Seed("kugou", "KuGoo=KugooID%3D42%26t%3Dlegacy-token%26NickName%3DLegacy%26VipType%3D1");
using var provider = new KugouMusicProvider(credentials, new StubHttpHandler((_, _) => Task.FromResult(JsonResponse(UserLists()))));
await provider.InitializeAsync();
Assert.IsTrue(provider.LoginState.LoggedIn);
Assert.AreEqual("Legacy", provider.LoginState.Nickname);
var stored = await credentials.LoadAsync("kugou");
Assert.StartsWith("{", stored);
StringAssert.Contains(stored, "\"version\":2");
StringAssert.Contains(stored, "legacy-token");
Assert.IsFalse(stored.Contains("KuGoo=", StringComparison.Ordinal));
}
[TestMethod]
public async Task LogoutClearsAccountButPreservesDeviceIdentity()
{
var credentials = AccountStore();
var deviceBefore = await credentials.LoadAsync("kugou-device");
using var provider = new KugouMusicProvider(credentials, new StubHttpHandler((_, _) => Task.FromResult(JsonResponse(UserLists()))));
await provider.InitializeAsync();
await provider.LogoutAsync();
Assert.IsFalse(provider.LoginState.LoggedIn);
Assert.AreEqual(string.Empty, await credentials.LoadAsync("kugou"));
AssertDeviceIdentityEqual(deviceBefore, await credentials.LoadAsync("kugou-device"));
}
[TestMethod]
public async Task AccountFixturesMapPlaylistsDailyRecommendationsAndPagedTracks()
{
var handler = new StubHttpHandler((request, _) =>
{
var path = request.RequestUri!.AbsolutePath;
return Task.FromResult(path switch
{
Content = new StringContent("var error_code = 0;", Encoding.UTF8, "application/javascript")
"/v7/get_all_list" => JsonResponse(UserLists(includeCollected: true)),
"/everyday_song_recommend" => JsonResponse("{\"status\":1,\"data\":{\"song_list\":[{\"hash\":\"DAILY\",\"album_id\":7,\"songname\":\"Daily\",\"author_name\":\"Singer\",\"duration\":180}]}}"),
"/v2/special_recommend" => JsonResponse(RecommendedPlaylists()),
"/v4/get_list_all_file" => JsonResponse("{\"status\":1,\"data\":{\"info\":[{\"hash\":\"LIKED\",\"album_id\":8,\"filename\":\"Liked - Singer\",\"fileid\":700,\"duration\":190}]}}"),
"/pubsongs/v2/get_other_list_file_nofilt" => JsonResponse("{\"status\":1,\"data\":{\"song_list\":[{\"hash\":\"PUBLIC\",\"album_id\":9,\"songname\":\"Public\",\"author_name\":\"Artist\",\"duration\":200}]}}"),
_ => JsonResponse("{\"status\":1}")
});
});
using var provider = new KugouMusicProvider(credentials, handler);
var cookie = "KuGoo=a_id%3D1014%26KugooID%3D9001%26t%3Dtoken-value%26ct%3D1786874000%26NickName%3DListener";
using var provider = new KugouMusicProvider(AccountStore(), handler);
await provider.InitializeAsync();
var state = await provider.LoginWithCookieAsync(cookie);
var user = await provider.GetUserPlaylistsAsync();
var daily = await provider.GetDailySongsAsync();
var recommended = await provider.GetRecommendedPlaylistsAsync();
var likedTracks = await provider.GetPlaylistTracksAsync("list:10");
var publicTracks = await provider.GetPlaylistTracksAsync("collection:gid-source");
Assert.IsTrue(state.LoggedIn);
Assert.AreEqual("9001", state.UserId);
Assert.AreEqual("Listener", state.Nickname);
Assert.AreEqual("login-user.kugou.com", captured?.RequestUri?.Host);
Assert.IsTrue(captured!.RequestUri!.Query.Contains("userid=9001", StringComparison.Ordinal));
Assert.AreEqual(cookie, await credentials.LoadAsync("kugou"));
Assert.HasCount(3, user);
Assert.IsFalse(user[0].CanSubscribe);
Assert.IsTrue(user[2].Subscribed);
Assert.IsTrue(user[2].CanSubscribe);
Assert.AreEqual("DAILY|7", daily[0].Id);
Assert.AreEqual("collection:gid-source", recommended[0].Id);
Assert.IsTrue(recommended[0].CanSubscribe);
Assert.IsTrue(recommended[0].Subscribed);
Assert.AreEqual("LIKED|8", likedTracks[0].Id);
Assert.AreEqual("PUBLIC|9", publicTracks[0].Id);
Assert.IsFalse(JsonSerializer.Serialize(recommended[0]).Contains("ProviderData", StringComparison.Ordinal));
}
[TestMethod]
public async Task LoginRejectsPartialOrProviderRejectedCookie()
public async Task CollectionPaginationCapsAtFiveHundredSongs()
{
var requestCount = 0;
var credentials = new MemoryCredentialStore();
var handler = new StubHttpHandler(_ =>
var requests = 0;
var handler = new StubHttpHandler((request, _) =>
{
requestCount++;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
if (request.RequestUri!.AbsolutePath != "/pubsongs/v2/get_other_list_file_nofilt")
{
Content = new StringContent("var error_code = 20017;", Encoding.UTF8, "application/javascript")
});
});
using var provider = new KugouMusicProvider(credentials, handler);
var partial = await provider.LoginWithCookieAsync("userid=9001");
var rejected = await provider.LoginWithCookieAsync("KuGoo=a_id%3D1014%26KugooID%3D9001%26t%3Dbad%26ct%3D1786874000");
Assert.IsFalse(partial.LoggedIn);
Assert.IsFalse(rejected.LoggedIn);
Assert.AreEqual(1, requestCount);
Assert.AreEqual(string.Empty, await credentials.LoadAsync("kugou"));
}
[TestMethod]
public async Task OfficialQrCookieCanUseDefaultWebAppIdAndTopLevelSessionFields()
{
HttpRequestMessage? captured = null;
var credentials = new MemoryCredentialStore();
var handler = new StubHttpHandler(request =>
{
captured = request;
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"error_code\":0}", Encoding.UTF8, "application/json")
};
response.Headers.TryAddWithoutValidation("Set-Cookie", "kg_mid=refreshed-mid; Path=/; Domain=.kugou.com");
return Task.FromResult(response);
});
using var provider = new KugouMusicProvider(credentials, handler);
var cookie = "KuGoo=KugooID%3D9002%26NickName%3DQRUser; t=qr-token; ct=1786874001";
var state = await provider.LoginWithCookieAsync(cookie);
Assert.IsTrue(state.LoggedIn);
Assert.AreEqual("9002", state.UserId);
Assert.AreEqual("QRUser", state.Nickname);
Assert.IsNotNull(captured?.RequestUri);
StringAssert.Contains(captured.RequestUri.Query, "a_id=1014");
StringAssert.Contains(captured.RequestUri.Query, "plat=4");
StringAssert.Contains(captured.RequestUri.Query, "dfid=-");
StringAssert.Contains(await credentials.LoadAsync("kugou"), "kg_mid=refreshed-mid");
}
[TestMethod]
public async Task OfficialWebCompletionAcceptsProviderCookieWithoutRedundantAutologin()
{
var requestCount = 0;
var credentials = new MemoryCredentialStore();
var handler = new StubHttpHandler(_ =>
{
requestCount++;
return Task.FromResult(JsonResponse("{}"));
});
using var provider = new KugouMusicProvider(credentials, handler);
var cookie = "KuGoo=a_id%3D1014%26KugooID%3D9004%26t%3Dtoken%26ct%3D1786874003%26NickName%3DOfficialUser%26VipType%3D2";
var state = await provider.CompleteOfficialWebLoginAsync(
cookie,
new Uri("https://staticssl.kugou.com/common/html/login/regok.html?from=qr"));
Assert.IsTrue(state.LoggedIn);
Assert.AreEqual("9004", state.UserId);
Assert.AreEqual("OfficialUser", state.Nickname);
Assert.AreEqual("2", state.VipLevel);
Assert.AreEqual(0, requestCount);
Assert.AreEqual(cookie, await credentials.LoadAsync("kugou"));
}
[TestMethod]
public async Task OfficialWebCompletionRejectsNonOfficialRedirect()
{
var credentials = new MemoryCredentialStore();
using var provider = new KugouMusicProvider(credentials, new StubHttpHandler(_ => Task.FromResult(JsonResponse("{}"))));
var cookie = "KuGoo=a_id%3D1014%26KugooID%3D9005%26t%3Dtoken%26ct%3D1786874004";
var state = await provider.CompleteOfficialWebLoginAsync(cookie, new Uri("https://example.com/regok.html"));
Assert.IsFalse(state.LoggedIn);
Assert.AreEqual(string.Empty, await credentials.LoadAsync("kugou"));
}
[TestMethod]
public void BrowserCookieMergeKeepsCompleteKugouSessionAcrossProviderDomains()
{
var complete = "KugooID%3D9003%26t%3Dtoken%26ct%3D1786874002%26NickName%3DListener";
var header = KugouMusicProvider.MergeBrowserCookies([
("KuGoo", complete),
("kg_mid", "mid-value"),
("KuGoo", "KugooID%3D9003")
]);
Assert.IsTrue(KugouMusicProvider.HasWebLoginCredential(header));
StringAssert.Contains(header, $"KuGoo={complete}");
StringAssert.Contains(header, "kg_mid=mid-value");
}
[TestMethod]
public void BrowserCookieMergePrefersFreshLoginServiceSession()
{
var stale = "KugooID%3D9003%26t%3Dstale%26ct%3D1786874002%26NickName%3DStale";
var fresh = "KugooID%3D9004%26t%3Dfresh%26ct%3D1786874003%26NickName%3DFresh";
var header = KugouMusicProvider.MergeBrowserCookies([
("KuGoo", stale, 20),
("KuGoo", fresh, 40),
("KuGoo", "KugooID%3D9004", 40)
]);
StringAssert.Contains(header, $"KuGoo={fresh}");
Assert.IsFalse(header.Contains(stale, StringComparison.Ordinal));
}
[TestMethod]
public void BrowserCookieCaptureIncludesOfficialLoginServiceFirst()
{
var sources = KugouMusicProvider.BrowserCookieSources;
Assert.AreEqual("https://loginservice.kugou.com/", sources[0].Uri);
Assert.IsGreaterThan(sources.Skip(1).Max(source => source.Priority), sources[0].Priority);
}
[TestMethod]
public async Task ChartsAndTracksUsePublicKugouWebContracts()
{
var requests = new List<Uri>();
var handler = new StubHttpHandler(request =>
{
requests.Add(request.RequestUri!);
if (request.RequestUri!.AbsolutePath.Contains("/rank/list", StringComparison.Ordinal))
{
return Task.FromResult(JsonResponse("""
{"rank":{"list":[{"rankid":8888,"rankname":"TOP500","imgurl":"http://imge.kugou.com/mcommon/{size}/rank.png"}]}}
"""));
return Task.FromResult(JsonResponse("{\"status\":1}"));
}
return Task.FromResult(JsonResponse("""
{"songs":{"list":[{"hash":"HASH1","album_id":"77","songname":"Song","h5_author_name":"Artist","duration":180,"trans_param":{"union_cover":"http://imge.kugou.com/stdmusic/{size}/cover.jpg"},"mvdata":[{"hash":"MV1"}]}]}}
"""));
requests++;
var begin = int.Parse(ParseQuery(request.RequestUri)["begin_idx"]);
var songs = new JsonArray(Enumerable.Range(begin, 100).Select(index => (JsonNode)new JsonObject
{
["hash"] = $"HASH{index}",
["album_id"] = index,
["songname"] = $"Song {index}",
["duration"] = 180
}).ToArray());
return Task.FromResult(JsonResponse(new JsonObject
{
["status"] = 1,
["data"] = new JsonObject { ["song_list"] = songs }
}.ToJsonString()));
});
using var provider = new KugouMusicProvider(new MemoryCredentialStore(), handler);
using var provider = new KugouMusicProvider(DeviceStore(), handler);
var songs = await provider.GetPlaylistTracksAsync("collection:paged", 0, 700);
Assert.HasCount(500, songs);
Assert.AreEqual(5, requests);
Assert.AreEqual("HASH499|499", songs[^1].Id);
}
[TestMethod]
public async Task AccountPlaybackFallsBackByQualityAndRejectsHttpAddress()
{
var qualities = new List<string>();
var handler = new StubHttpHandler((request, _) =>
{
if (request.RequestUri!.AbsolutePath == "/v7/get_all_list") return Task.FromResult(JsonResponse(UserLists()));
if (request.RequestUri.AbsolutePath == "/v5/url")
{
var quality = ParseQuery(request.RequestUri)["quality"];
qualities.Add(quality);
return Task.FromResult(JsonResponse(quality == "super"
? "{\"status\":1,\"data\":{\"url\":\"http://media.test/insecure.flac\"}}"
: "{\"status\":1,\"data\":{\"url\":\"https://media.test/song.flac\",\"bitRate\":900}}"));
}
return Task.FromResult(JsonResponse("{\"status\":1}"));
});
using var provider = new KugouMusicProvider(AccountStore(), handler);
await provider.InitializeAsync();
var stream = await provider.ResolveStreamAsync("ABCDEF|7", MusicPlaybackQuality.Master);
Assert.IsTrue(stream.Playable);
Assert.AreEqual(MusicPlaybackQuality.HiRes, stream.Quality);
Assert.AreEqual(new Uri("https://media.test/song.flac"), stream.Uri);
CollectionAssert.AreEqual(new[] { "super", "high" }, qualities);
StringAssert.Contains(stream.ProviderReason, "回退");
}
[TestMethod]
[DataRow(MusicPlaybackQuality.Master, "super")]
[DataRow(MusicPlaybackQuality.HiRes, "high")]
[DataRow(MusicPlaybackQuality.Lossless, "flac")]
[DataRow(MusicPlaybackQuality.High, "320")]
[DataRow(MusicPlaybackQuality.Standard, "128")]
public async Task PlaybackQualityMapsToKugouQualityParameter(MusicPlaybackQuality quality, string expectedParameter)
{
string? actualParameter = null;
using var provider = new KugouMusicProvider(DeviceStore(), new StubHttpHandler((request, _) =>
{
actualParameter = ParseQuery(request.RequestUri!)["quality"];
return Task.FromResult(JsonResponse("{\"status\":1,\"data\":{\"url\":\"https://media.test/song\",\"bitRate\":320}}"));
}));
var stream = await provider.ResolveStreamAsync("HASH|1", quality);
Assert.AreEqual(expectedParameter, actualParameter);
Assert.AreEqual(quality, stream.Quality);
Assert.IsTrue(stream.Playable);
}
[TestMethod]
public async Task FavoriteAndUnfavoriteUseLikedListAndRefreshedFileId()
{
var liked = false;
var added = 0;
var deleted = 0;
string deleteBody = string.Empty;
var handler = new StubHttpHandler(async (request, _) =>
{
var path = request.RequestUri!.AbsolutePath;
if (path == "/v7/get_all_list") return JsonResponse(UserLists());
if (path == "/v4/get_list_all_file")
{
return JsonResponse(liked
? "{\"status\":1,\"data\":{\"info\":[{\"hash\":\"FAVHASH\",\"album_id\":7,\"songname\":\"Favorite\",\"fileid\":808,\"duration\":180}]}}"
: "{\"status\":1,\"data\":{\"info\":[]}}");
}
if (path == "/cloudlist.service/v6/add_song")
{
added++;
liked = true;
}
if (path == "/v4/delete_songs")
{
deleted++;
deleteBody = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync();
liked = false;
}
return JsonResponse("{\"status\":1}");
});
using var provider = new KugouMusicProvider(AccountStore(), handler);
await provider.InitializeAsync();
var song = Song("FAVHASH|7", "Favorite");
Assert.IsFalse(await provider.GetFavoriteStateAsync(song));
await provider.SetFavoriteAsync(song, true);
Assert.IsTrue(await provider.GetFavoriteStateAsync(song));
await provider.SetFavoriteAsync(song, false);
Assert.IsFalse(await provider.GetFavoriteStateAsync(song));
Assert.AreEqual(1, added);
Assert.AreEqual(1, deleted);
StringAssert.Contains(deleteBody, "\"fileid\":808");
}
[TestMethod]
public async Task FavoriteWriteStopsWhenLikedPlaylistCannotBeIdentified()
{
var mutationRequests = 0;
using var provider = new KugouMusicProvider(AccountStore(), new StubHttpHandler((request, _) =>
{
if (request.RequestUri!.AbsolutePath.Contains("add_song", StringComparison.Ordinal)) mutationRequests++;
return Task.FromResult(JsonResponse(request.RequestUri.AbsolutePath == "/v7/get_all_list"
? UserLists(includeLiked: false)
: "{\"status\":1}"));
}));
await provider.InitializeAsync();
var exception = await Assert.ThrowsExactlyAsync<InvalidOperationException>(() => provider.SetFavoriteAsync(Song("HASH|1", "Song"), true));
StringAssert.Contains(exception.Message, "我喜欢");
Assert.AreEqual(0, mutationRequests);
}
[TestMethod]
public async Task PlaylistSubscriptionCopiesSongsAndDeletesOnlyConfirmedCopy()
{
var subscribed = false;
var addListRequests = 0;
var copyRequests = 0;
var deleteRequests = 0;
var handler = new StubHttpHandler((request, _) =>
{
var path = request.RequestUri!.AbsolutePath;
if (path == "/v7/get_all_list") return Task.FromResult(JsonResponse(UserLists(includeCollected: subscribed)));
if (path == "/v2/special_recommend") return Task.FromResult(JsonResponse(RecommendedPlaylists()));
if (path == "/pubsongs/v2/get_other_list_file_nofilt") return Task.FromResult(JsonResponse(CollectionSongs()));
if (path == "/cloudlist.service/v5/add_list")
{
addListRequests++;
return Task.FromResult(JsonResponse("{\"status\":1,\"data\":{\"listid\":55}}"));
}
if (path == "/cloudlist.service/v6/add_song")
{
copyRequests++;
subscribed = true;
return Task.FromResult(JsonResponse("{\"status\":1}"));
}
if (path == "/v2/delete_list")
{
deleteRequests++;
subscribed = false;
return Task.FromResult(EncryptedResponse("{\"status\":1}"));
}
return Task.FromResult(JsonResponse("{\"status\":1}"));
});
using var provider = new KugouMusicProvider(AccountStore(), handler, () => "abc123");
await provider.InitializeAsync();
var playlist = (await provider.GetRecommendedPlaylistsAsync())[0];
await provider.SetPlaylistSubscribedAsync(playlist, true);
await provider.SetPlaylistSubscribedAsync(playlist, false);
Assert.AreEqual(1, addListRequests);
Assert.AreEqual(1, copyRequests);
Assert.AreEqual(1, deleteRequests);
}
[TestMethod]
public async Task PlaylistCopyFailureRollsBackNewAccountPlaylist()
{
var rollbackRequests = 0;
var handler = new StubHttpHandler((request, _) =>
{
var path = request.RequestUri!.AbsolutePath;
if (path == "/v7/get_all_list") return Task.FromResult(JsonResponse(UserLists()));
if (path == "/v2/special_recommend") return Task.FromResult(JsonResponse(RecommendedPlaylists()));
if (path == "/cloudlist.service/v5/add_list") return Task.FromResult(JsonResponse("{\"status\":1,\"data\":{\"listid\":66}}"));
if (path == "/pubsongs/v2/get_other_list_file_nofilt") throw new HttpRequestException("copy failed");
if (path == "/v2/delete_list")
{
rollbackRequests++;
return Task.FromResult(EncryptedResponse("{\"status\":1}"));
}
return Task.FromResult(JsonResponse("{\"status\":1}"));
});
using var provider = new KugouMusicProvider(AccountStore(), handler, () => "abc123");
await provider.InitializeAsync();
var playlist = (await provider.GetRecommendedPlaylistsAsync())[0];
await Assert.ThrowsExactlyAsync<KugouApiException>(() => provider.SetPlaylistSubscribedAsync(playlist, true));
Assert.AreEqual(1, rollbackRequests);
}
[TestMethod]
public async Task OwnedAndLikedPlaylistsCannotBeDeletedAsSubscriptions()
{
var deleteRequests = 0;
using var provider = new KugouMusicProvider(AccountStore(), new StubHttpHandler((request, _) =>
{
if (request.RequestUri!.AbsolutePath == "/v2/delete_list") deleteRequests++;
return Task.FromResult(JsonResponse(request.RequestUri.AbsolutePath == "/v7/get_all_list" ? ProtectedUserLists() : "{\"status\":1}"));
}));
await provider.InitializeAsync();
var playlists = await provider.GetUserPlaylistsAsync();
await Assert.ThrowsExactlyAsync<InvalidOperationException>(() => provider.SetPlaylistSubscribedAsync(playlists[0], false));
await Assert.ThrowsExactlyAsync<InvalidOperationException>(() => provider.SetPlaylistSubscribedAsync(playlists[1], false));
await Assert.ThrowsExactlyAsync<InvalidOperationException>(() => provider.SetPlaylistSubscribedAsync(playlists[2], false));
Assert.AreEqual(0, deleteRequests);
}
[TestMethod]
public async Task AuthenticationFailureDuringAccountWriteClearsOnlyAccountToken()
{
var credentials = AccountStore();
var validationComplete = false;
using var provider = new KugouMusicProvider(credentials, new StubHttpHandler((request, _) =>
{
if (request.RequestUri!.AbsolutePath == "/v7/get_all_list" && !validationComplete)
{
validationComplete = true;
return Task.FromResult(JsonResponse(UserLists()));
}
return Task.FromResult(JsonResponse("{\"status\":0,\"error_code\":20017,\"message\":\"token=account-token-value rejected\"}"));
}));
await provider.InitializeAsync();
var exception = await Assert.ThrowsExactlyAsync<InvalidOperationException>(() => provider.GetFavoriteStateAsync(Song("HASH|1", "Song")));
Assert.IsFalse(exception.ToString().Contains(AccountToken, StringComparison.Ordinal));
Assert.AreEqual(string.Empty, await credentials.LoadAsync("kugou"));
Assert.AreNotEqual(string.Empty, await credentials.LoadAsync("kugou-device"));
Assert.IsFalse(provider.LoginState.LoggedIn);
}
[TestMethod]
public void ProviderErrorSanitizerRedactsSensitiveFields()
{
var sanitized = KugouApiClient.SanitizeProviderMessage("token=secret vip_token:'vip-secret' mid=mid-secret ordinary text");
Assert.IsFalse(sanitized.Contains("secret", StringComparison.Ordinal));
Assert.IsFalse(sanitized.Contains("mid-secret", StringComparison.Ordinal));
StringAssert.Contains(sanitized, "[redacted]");
StringAssert.Contains(sanitized, "ordinary text");
}
[TestMethod]
public async Task SearchChartsLyricsAndMvKeepPublicHttpsBehavior()
{
var handler = new StubHttpHandler((request, _) =>
{
var uri = request.RequestUri!;
if (uri.Host == "songsearch.kugou.com") return Task.FromResult(JsonResponse("{\"data\":{\"lists\":[{\"FileHash\":\"ABC123\",\"AlbumID\":\"456\",\"SongName\":\"Song\",\"SingerName\":\"Artist\",\"AlbumName\":\"Album\",\"Duration\":180,\"Image\":\"https://img.test/{size}.jpg\"}]}}"));
if (uri.AbsolutePath.Contains("/rank/list", StringComparison.Ordinal)) return Task.FromResult(JsonResponse("{\"rank\":{\"list\":[{\"rankid\":8888,\"rankname\":\"TOP500\",\"imgurl\":\"http://imge.kugou.com/mcommon/{size}/rank.png\"}]}}"));
if (uri.AbsolutePath.Contains("/rank/info/", StringComparison.Ordinal)) return Task.FromResult(JsonResponse("{\"songs\":{\"list\":[{\"hash\":\"HASH1\",\"album_id\":\"77\",\"songname\":\"Song\",\"h5_author_name\":\"Artist\",\"duration\":180,\"mvdata\":[{\"hash\":\"MV1\"}] }]}}"));
if (uri.Host == "lyrics.kugou.com" && uri.AbsolutePath.EndsWith("/search", StringComparison.Ordinal)) return Task.FromResult(JsonResponse("{\"candidates\":[{\"id\":\"42\",\"accesskey\":\"key\"}]}"));
if (uri.Host == "lyrics.kugou.com") return Task.FromResult(JsonResponse("{\"content\":\"WzAwOjAwLjAwXVRlc3Q=\"}"));
if (uri.AbsolutePath.EndsWith("/mv.php", StringComparison.Ordinal)) return Task.FromResult(JsonResponse("{\"songname\":\"Song MV\",\"singer\":\"Artist\",\"timelength\":120000,\"mvdata\":{\"sq\":{\"bitrate\":2000,\"downurl\":\"http://media.test/video.mp4\"}}}"));
return Task.FromResult(JsonResponse("{\"status\":1}"));
});
using var provider = new KugouMusicProvider(DeviceStore(), handler);
var search = await provider.SearchAsync("test", MusicSearchKind.Songs);
var charts = await provider.GetChartsAsync();
var songs = await provider.GetPlaylistTracksAsync(charts[0].Id);
var tracks = await provider.GetPlaylistTracksAsync(charts[0].Id);
var lyrics = await provider.GetLyricsAsync("ABC123|456");
var mv = await provider.GetMvAsync("HASH1|77", "MV1");
Assert.HasCount(1, charts);
Assert.AreEqual("ABC123|456", search.Songs[0].Id);
Assert.AreEqual("rank:8888", charts[0].Id);
Assert.IsTrue(charts[0].CoverUrl.StartsWith("https://", StringComparison.Ordinal));
Assert.HasCount(1, songs);
Assert.AreEqual("HASH1|77", songs[0].Id);
Assert.AreEqual("Artist", songs[0].Artist);
Assert.AreEqual("MV1", songs[0].MvId);
Assert.IsTrue(requests.Any(uri => uri.AbsolutePath.Contains("/rank/info/", StringComparison.Ordinal)));
}
[TestMethod]
public async Task KugouMvRejectsInsecureProviderMediaAddress()
{
var handler = new StubHttpHandler(_ => Task.FromResult(JsonResponse("""
{"songname":"Song MV","singer":"Artist","timelength":120000,"mvdata":{"sq":{"bitrate":2000,"downurl":"http://media.test/video.mp4"}}}
""")));
using var provider = new KugouMusicProvider(new MemoryCredentialStore(), handler);
var mv = await provider.GetMvAsync("HASH|77", "MV1");
Assert.AreEqual("MV1", tracks[0].MvId);
Assert.HasCount(1, lyrics.Lines);
Assert.IsNotNull(mv);
Assert.IsFalse(mv.Playable);
Assert.IsNull(mv.Uri);
StringAssert.Contains(mv.ProviderReason, "非加密");
}
private static MusicQrSession FutureQr()
=> new("qr-key", "https://h5.kugou.com/", DateTimeOffset.Now.AddMinutes(1));
private static MusicSong Song(string id, string name)
=> new("kugou", id, name, "Artist", [], "Album", "", TimeSpan.FromMinutes(3), 0);
private static KugouDeviceCredential TestDevice(string dfid = "device-dfid")
=> new(1, "0123456789abcdef0123456789abcdef", "123456789012345678901234567890", "DEVICE0001", "02:00:00:00:00:00", dfid);
private static MemoryCredentialStore DeviceStore()
{
var store = new MemoryCredentialStore();
store.Seed("kugou-device", JsonSerializer.Serialize(TestDevice()));
return store;
}
private static MemoryCredentialStore AccountStore()
{
var store = DeviceStore();
store.Seed("kugou", JsonSerializer.Serialize(new KugouAccountCredential(2, "42", AccountToken, "Listener", "https://img.test/avatar.jpg", "2")));
return store;
}
private static string UserLists(bool includeLiked = true, bool includeCollected = false)
{
var items = new JsonArray();
if (includeLiked)
{
items.Add(new JsonObject
{
["listid"] = 10,
["name"] = "我喜欢",
["userid"] = 42,
["is_like"] = 1,
["count"] = 1
});
}
items.Add(new JsonObject
{
["listid"] = 11,
["name"] = "My playlist",
["userid"] = 42,
["count"] = 2
});
if (includeCollected)
{
items.Add(new JsonObject
{
["listid"] = 55,
["name"] = "Recommended",
["list_create_userid"] = 99,
["list_create_listid"] = 77,
["list_create_gid"] = "gid-source",
["count"] = 2
});
}
return new JsonObject
{
["status"] = 1,
["data"] = new JsonObject { ["info"] = items }
}.ToJsonString();
}
private static string RecommendedPlaylists()
=> "{\"status\":1,\"data\":{\"special_list\":[{\"specialname\":\"Recommended\",\"global_collection_id\":\"gid-source\",\"specialid\":77,\"userid\":99,\"song_count\":2,\"imgurl\":\"https://img.test/list.jpg\"}]}}";
private static string ProtectedUserLists()
=> "{\"status\":1,\"data\":{\"info\":[{\"listid\":10,\"name\":\"我喜欢\",\"userid\":42,\"is_like\":1},{\"listid\":11,\"name\":\"Owned\",\"userid\":42},{\"listid\":12,\"name\":\"Unknown ownership\"}]}}";
private static string CollectionSongs()
=> "{\"status\":1,\"data\":{\"song_list\":[{\"hash\":\"ONE\",\"album_id\":1,\"songname\":\"One\",\"duration\":180},{\"hash\":\"TWO\",\"album_id\":2,\"songname\":\"Two\",\"duration\":181}]}}";
private static HttpResponseMessage EncryptedResponse(string json)
{
var encrypted = KugouApiClient.EncryptPlaylistPayload(json, "abc123");
return BytesResponse(Convert.FromBase64String(encrypted.Value));
}
private static HttpResponseMessage JsonResponse(string json)
=> new(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
private sealed class StubHttpHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> responseFactory) : HttpMessageHandler
private static HttpResponseMessage CaptureQrRequest(Uri uri, out Uri? captured)
{
captured = uri;
return JsonResponse("{\"status\":1,\"data\":{\"qrcode\":\"qr-key\"}}");
}
private static HttpResponseMessage BytesResponse(byte[] bytes)
=> new(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) };
private static Dictionary<string, string> ParseQuery(Uri uri)
=> uri.Query.TrimStart('?')
.Split('&', StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Split('=', 2))
.ToDictionary(
part => Uri.UnescapeDataString(part[0]),
part => Uri.UnescapeDataString(part.Length > 1 ? part[1] : string.Empty),
StringComparer.Ordinal);
private static void AssertDeviceIdentityEqual(string expectedJson, string actualJson)
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var expected = JsonSerializer.Deserialize<KugouDeviceCredential>(expectedJson, options);
var actual = JsonSerializer.Deserialize<KugouDeviceCredential>(actualJson, options);
Assert.IsNotNull(expected);
Assert.IsNotNull(actual);
Assert.AreEqual(expected.Guid, actual.Guid);
Assert.AreEqual(expected.Mid, actual.Mid);
Assert.AreEqual(expected.Dev, actual.Dev);
Assert.AreEqual(expected.Mac, actual.Mac);
Assert.AreEqual(expected.Dfid, actual.Dfid);
}
private sealed record CapturedRequest(HttpMethod Method, Uri Uri, string Body)
{
public static async Task<CapturedRequest> FromAsync(HttpRequestMessage request)
=> new(request.Method, request.RequestUri!, request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync());
}
private sealed class StubHttpHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responseFactory) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> responseFactory(request);
=> responseFactory(request, cancellationToken);
}
private sealed class MemoryCredentialStore : IMusicCredentialStore
{
private readonly Dictionary<string, string> _secrets = new(StringComparer.OrdinalIgnoreCase);
public void Seed(string provider, string secret) => _secrets[provider] = secret;
public Task SaveAsync(string provider, string secret, CancellationToken cancellationToken = default)
{
_secrets[provider] = secret;
+1
View File
@@ -248,6 +248,7 @@ public partial class App : Application
var details = exception.ToString();
return exception is System.Runtime.InteropServices.COMException ||
exception is System.ComponentModel.Win32Exception ||
details.Contains("ToolboxPage", StringComparison.OrdinalIgnoreCase) ||
details.Contains("MeasureOverride", StringComparison.OrdinalIgnoreCase) ||
details.Contains("Window object has already been closed", StringComparison.OrdinalIgnoreCase);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

+11 -4
View File
@@ -1645,7 +1645,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
var installCandidates = _downloadManager.Items
.Where(item =>
item.State == DownloadState.Completed &&
!string.IsNullOrWhiteSpace(item.InstallCommand) &&
DownloadOpenPolicy.Normalize(item).OpenKind == DownloadOpenKind.Installer &&
!_installPromptsShown.Contains(item.Id))
.ToArray();
@@ -1692,13 +1692,20 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
var result = await _shellDialogService.ShowAsync(dialog);
if (result == ContentDialogResult.Primary)
{
var process = DownloadManagerService.LaunchInstaller(item);
var launch = await DownloadItemLauncher.LaunchAsync(item);
if (!launch.Succeeded)
{
await _logService.WriteAsync("Warning", "download", "Downloaded installer could not be opened", launch.Error);
ToastService.Show(AppLocalizer.T("无法启动安装程序。", "Could not start the installer."), ToastKind.Warning);
return;
}
await _downloadManager.MarkInstallLaunchedAsync(item.Id);
if (process is not null && item.DeleteAfterInstall)
if (launch.Process is not null && item.DeleteAfterInstall)
{
try
{
await process.WaitForExitAsync();
await launch.Process.WaitForExitAsync();
await _downloadManager.TryCleanupAfterInstallAsync(item.Id);
}
catch
+4
View File
@@ -73,6 +73,10 @@ public static class AppServices
provider.GetRequiredService<IDownloadHostProcessService>(),
provider.GetService<ILogService>()));
services.AddSingleton<IDevEnvironmentDetectionService>(provider => new DevEnvironmentDetectionService(provider.GetService<ILogService>()));
services.AddSingleton<IDevTerminalPlatform>(provider => new WindowsDevTerminalPlatform(provider.GetRequiredService<AppPaths>()));
services.AddSingleton<IDevTerminalSetupService>(provider => new DevTerminalSetupService(
provider.GetRequiredService<IDevTerminalPlatform>(),
provider.GetService<ILogService>()));
services.AddSingleton<IDevEnvironmentCatalogService>(provider => new DevEnvironmentCatalogService(
provider.GetRequiredService<AppPaths>(),
provider.GetRequiredService<IHttpService>(),
@@ -0,0 +1,52 @@
using System.Diagnostics;
using Windows.Storage;
using Windows.System;
using YMhut.Box.Core.Downloads;
namespace YMhut.Box.WinUI.Services;
public sealed record DownloadLaunchResult(bool Succeeded, Process? Process = null, string Error = "");
public static class DownloadItemLauncher
{
public static async Task<DownloadLaunchResult> LaunchAsync(DownloadItem item)
{
try
{
var normalized = DownloadOpenPolicy.Normalize(item);
switch (normalized.OpenKind)
{
case DownloadOpenKind.Installer:
var process = DownloadManagerService.LaunchInstaller(normalized);
return process is null
? new DownloadLaunchResult(false, Error: "The installer file no longer exists.")
: new DownloadLaunchResult(true, process);
case DownloadOpenKind.ExternalUri:
if (!Uri.TryCreate(normalized.Source.Url, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
{
return new DownloadLaunchResult(false, Error: "The download page address is invalid.");
}
return await Launcher.LaunchUriAsync(uri)
? new DownloadLaunchResult(true)
: new DownloadLaunchResult(false, Error: "Windows could not open the download page.");
case DownloadOpenKind.File:
if (!File.Exists(normalized.TargetPath))
{
return new DownloadLaunchResult(false, Error: "The downloaded file no longer exists.");
}
var file = await StorageFile.GetFileFromPathAsync(normalized.TargetPath);
return await Launcher.LaunchFileAsync(file)
? new DownloadLaunchResult(true)
: new DownloadLaunchResult(false, Error: "Windows has no application associated with this file.");
default:
return new DownloadLaunchResult(false, Error: "This download item cannot be opened.");
}
}
catch (Exception exception)
{
return new DownloadLaunchResult(false, Error: AppLocalizer.SanitizeSensitiveText(exception.Message, 220));
}
}
}
@@ -115,7 +115,8 @@ public sealed class DownloadManagerService : IDownloadManagerService, IDisposabl
options.InstallCommand,
options.InstallArguments,
options.IsInstaller,
options.DeleteAfterInstall);
options.DeleteAfterInstall,
options.OpenKind);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
@@ -333,6 +334,13 @@ public sealed class DownloadManagerService : IDownloadManagerService, IDisposabl
return null;
}
item = DownloadOpenPolicy.Normalize(item);
if (item.OpenKind != DownloadOpenKind.Installer ||
!DownloadOpenPolicy.IsSupportedInstaller(item.TargetPath, item.InstallCommand))
{
throw new InvalidOperationException("The downloaded item is not a supported installer.");
}
var command = item.InstallCommand;
var fileName = string.IsNullOrWhiteSpace(command) || string.Equals(command, "installer", StringComparison.OrdinalIgnoreCase)
? item.TargetPath
@@ -601,9 +609,10 @@ public sealed class DownloadManagerService : IDownloadManagerService, IDisposabl
private static DownloadItem NormalizeLoadedItem(DownloadItem item)
{
var withPartialPath = string.IsNullOrWhiteSpace(item.PartialPath)
? item with { PartialPath = item.TargetPath + ".partial" }
: item;
var normalized = DownloadOpenPolicy.Normalize(item);
var withPartialPath = string.IsNullOrWhiteSpace(normalized.PartialPath)
? normalized with { PartialPath = normalized.TargetPath + ".partial" }
: normalized;
var partialBytes = FileLength(withPartialPath.EffectivePartialPath);
return withPartialPath.State == DownloadState.Running
? withPartialPath.WithProgress(DownloadState.Queued, partialBytes, withPartialPath.TotalBytes ?? withPartialPath.ContentLength, 0)
@@ -0,0 +1,147 @@
using System.IO.Ports;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.WinUI.Services;
public sealed class SerialPortTransport : ISerialPortTransport
{
private readonly object _gate = new();
private SerialPort? _port;
public bool IsOpen
{
get
{
lock (_gate)
{
return _port?.IsOpen == true;
}
}
}
public event EventHandler<YMhut.Box.Core.Tools.SerialDataReceivedEventArgs>? DataReceived;
public IReadOnlyList<string> GetPortNames()
=> SerialPort.GetPortNames().Order(StringComparer.OrdinalIgnoreCase).ToArray();
public Task OpenAsync(SerialConnectionOptions options, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
CloseCore();
var port = new SerialPort(
options.PortName,
options.BaudRate,
MapParity(options.Parity),
options.DataBits,
MapStopBits(options.StopBits))
{
Encoding = System.Text.Encoding.UTF8,
ReadTimeout = 1000,
WriteTimeout = 2000
};
port.DataReceived += Port_DataReceived;
port.Open();
_port = port;
}
return Task.CompletedTask;
}
public Task WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
if (_port?.IsOpen != true)
{
throw new InvalidOperationException("The serial port is not connected.");
}
_port.BaseStream.Write(data.Span);
_port.BaseStream.Flush();
}
return Task.CompletedTask;
}
public Task CloseAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
CloseCore();
}
return Task.CompletedTask;
}
public void Dispose()
{
lock (_gate)
{
CloseCore();
}
}
private void Port_DataReceived(object sender, System.IO.Ports.SerialDataReceivedEventArgs args)
{
try
{
byte[] data;
lock (_gate)
{
if (_port?.IsOpen != true)
{
return;
}
var count = _port.BytesToRead;
if (count <= 0)
{
return;
}
data = new byte[count];
_ = _port.Read(data, 0, data.Length);
}
DataReceived?.Invoke(this, new YMhut.Box.Core.Tools.SerialDataReceivedEventArgs(data));
}
catch
{
}
}
private void CloseCore()
{
var port = _port;
_port = null;
if (port is null)
{
return;
}
port.DataReceived -= Port_DataReceived;
try
{
if (port.IsOpen)
{
port.Close();
}
}
finally
{
port.Dispose();
}
}
private static Parity MapParity(SerialParityMode value) => value switch
{
SerialParityMode.Odd => Parity.Odd,
SerialParityMode.Even => Parity.Even,
SerialParityMode.Mark => Parity.Mark,
SerialParityMode.Space => Parity.Space,
_ => Parity.None
};
private static StopBits MapStopBits(SerialStopBitsMode value) => value switch
{
SerialStopBitsMode.OnePointFive => StopBits.OnePointFive,
SerialStopBitsMode.Two => StopBits.Two,
_ => StopBits.One
};
}
+19 -4
View File
@@ -6,6 +6,7 @@ using Windows.ApplicationModel.DataTransfer;
using Windows.Storage.Pickers;
using Windows.System;
using YMhut.Box.Core.Downloads;
using YMhut.Box.Core.Logging;
using YMhut.Box.WinUI.Services;
namespace YMhut.Box.WinUI.Views;
@@ -14,6 +15,7 @@ public sealed class DownloadManagerPage : Page
{
private readonly IDownloadManagerService _downloads = AppServices.GetRequiredService<IDownloadManagerService>();
private readonly IDirectDownloadValidator _validator = AppServices.GetRequiredService<IDirectDownloadValidator>();
private readonly ILogService _logService = AppServices.GetRequiredService<ILogService>();
private readonly StackPanel _activeList = new() { Spacing = 10 };
private readonly StackPanel _completedList = new() { Spacing = 10 };
private readonly StackPanel _failedList = new() { Spacing = 10 };
@@ -222,7 +224,13 @@ public sealed class DownloadManagerPage : Page
if (item.State == DownloadState.Completed)
{
actions.Children.Add(ModernUi.IconButton("\uE8A7", AppLocalizer.T("安装 / 打开", "Install / open"), async () => await LaunchCompletedAsync(item)));
var openKind = DownloadOpenPolicy.Normalize(item).OpenKind;
var label = openKind == DownloadOpenKind.Installer
? AppLocalizer.T("安装", "Install")
: openKind == DownloadOpenKind.ExternalUri
? AppLocalizer.T("打开官网", "Open official page")
: AppLocalizer.T("打开", "Open");
actions.Children.Add(ModernUi.IconButton("\uE8A7", label, async () => await LaunchCompletedAsync(item)));
}
actions.Children.Add(ModernUi.IconButton("\uE838", AppLocalizer.T("打开目录", "Open folder"), async () => await OpenFolderAsync(item)));
@@ -399,13 +407,20 @@ public sealed class DownloadManagerPage : Page
private async Task LaunchCompletedAsync(DownloadItem item)
{
var process = DownloadManagerService.LaunchInstaller(item);
var result = await DownloadItemLauncher.LaunchAsync(item);
if (!result.Succeeded)
{
await _logService.WriteAsync("Warning", "download", "Downloaded item could not be opened", result.Error);
ToastService.Show(AppLocalizer.T("无法打开该下载项。", "Could not open this download item."), ToastKind.Warning);
return;
}
await _downloads.MarkInstallLaunchedAsync(item.Id);
if (process is not null && item.DeleteAfterInstall)
if (result.Process is not null && item.DeleteAfterInstall)
{
try
{
await process.WaitForExitAsync();
await result.Process.WaitForExitAsync();
await _downloads.TryCleanupAfterInstallAsync(item.Id);
}
catch
+138 -159
View File
@@ -18,8 +18,6 @@ namespace YMhut.Box.WinUI.Views;
public sealed class NetworkMusicPage : Page
{
private static readonly Uri KugouOfficialLoginUri = new(
"https://login-user.kugou.com/login/?appid=1014&ref=https%3A%2F%2Fwww.kugou.com%2Freg%2Fweb%2F&redirect_uri=https%3A%2F%2Fstaticssl.kugou.com%2Fcommon%2Fhtml%2Flogin%2Fregok.html&callback=UsLoginCallback");
private static SolidColorBrush BackgroundBrush => ModernUi.AppBackground;
private static SolidColorBrush SurfaceBrush => ModernUi.Surface;
private static SolidColorBrush SurfaceAltBrush => ModernUi.SurfaceAlt;
@@ -33,7 +31,6 @@ public sealed class NetworkMusicPage : Page
private readonly IMusicHistoryStore _historyStore = AppServices.GetRequiredService<IMusicHistoryStore>();
private readonly IAgreementAcceptanceStore _agreementStore = AppServices.GetRequiredService<IAgreementAcceptanceStore>();
private readonly IAppVersionService _appVersionService = AppServices.GetRequiredService<IAppVersionService>();
private readonly WebView2EnvironmentFactory _webViewFactory = AppServices.GetRequiredService<WebView2EnvironmentFactory>();
private readonly IMusicPlaybackService _playback = AppServices.GetRequiredService<IMusicPlaybackService>();
private readonly IDesktopOverlayService _overlayService = AppServices.GetRequiredService<IDesktopOverlayService>();
private readonly AutoSuggestBox _search = new() { PlaceholderText = AppLocalizer.T("搜索歌曲、歌手或歌单", "Search songs, artists, or playlists"), MinWidth = 0 };
@@ -69,6 +66,9 @@ public sealed class NetworkMusicPage : Page
private bool _musicDisclaimerAccepted;
private bool _qualitySyncing;
private string _displayedPlaybackFailure = string.Empty;
private string _favoriteStateSongKey = string.Empty;
private bool? _favoriteState;
private CancellationTokenSource? _favoriteStateCts;
private IMusicProvider Provider => _providers.Current;
@@ -78,6 +78,7 @@ public sealed class NetworkMusicPage : Page
_playPauseButton = IconButton("\uE768", AppLocalizer.T("播放或暂停", "Play or pause"), () => _playback.PlayPause());
_modeButton = IconButton("\uE8EE", AppLocalizer.T("顺序播放", "Play in order"), ToggleMode);
_favoriteButton = IconButton("\uEB51", AppLocalizer.T("收藏当前歌曲", "Favorite current song"), async () => await FavoriteCurrentAsync());
_favoriteButton.IsEnabled = false;
_desktopLyricsButton = IconButton("\uE8D2", AppLocalizer.T("桌面歌词", "Desktop lyrics"), async () => await ToggleDesktopLyricsAsync());
_mvButton = IconButton("\uE714", AppLocalizer.T("播放当前歌曲 MV", "Play current song MV"), async () => await ShowMvAsync());
_accountButton = new Button
@@ -470,6 +471,9 @@ public sealed class NetworkMusicPage : Page
private void NetworkMusicPage_Unloaded(object sender, RoutedEventArgs e)
{
_uiTimer.Stop();
_favoriteStateCts?.Cancel();
_favoriteStateCts?.Dispose();
_favoriteStateCts = null;
if (_subscribed)
{
_playback.StateChanged -= Playback_StateChanged;
@@ -582,10 +586,7 @@ public sealed class NetworkMusicPage : Page
{
var playlists = await Provider.GetRecommendedPlaylistsAsync();
_sectionTitle.Text = AppLocalizer.T("发现音乐", "Discover");
var section = string.Equals(Provider.Id, "kugou", StringComparison.OrdinalIgnoreCase)
? AppLocalizer.T("精选榜单", "Featured charts")
: AppLocalizer.T("推荐歌单", "Recommended playlists");
_sectionMeta.Text = section;
_sectionMeta.Text = AppLocalizer.T("推荐歌单", "Recommended playlists");
RenderPlaylists(playlists);
}
@@ -780,9 +781,37 @@ public sealed class NetworkMusicPage : Page
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(44) });
grid.ColumnDefinitions.Add(new ColumnDefinition());
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(100) });
if (playlist.CanSubscribe) grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(44) });
grid.Children.Add(Cover(playlist.CoverUrl, 38));
AddColumn(grid, new StackPanel { Spacing = 2, Children = { Text(playlist.Name, 13, FontWeights.SemiBold, maxLines: 1), Text(playlist.Creator, 11, foreground: SecondaryTextBrush, maxLines: 1) } }, 1);
AddColumn(grid, Text(SongCount(playlist.TrackCount), 11, foreground: SecondaryTextBrush, maxLines: 1), 2);
if (playlist.CanSubscribe)
{
var subscribed = playlist.Subscribed;
var action = IconButton(
subscribed ? "\uEB52" : "\uEB51",
PlaylistSubscriptionTooltip(subscribed),
() => { });
action.Width = 36;
action.Height = 36;
action.Tapped += (_, args) => args.Handled = true;
action.Click += async (_, _) => await RunBusyAsync(async () =>
{
var target = !subscribed;
await _providers.GetRequired(playlist.Provider).SetPlaylistSubscribedAsync(playlist, target);
subscribed = target;
if (action.Content is FontIcon icon) icon.Glyph = subscribed ? "\uEB52" : "\uEB51";
ToolTipService.SetToolTip(action, PlaylistSubscriptionTooltip(subscribed));
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(action, PlaylistSubscriptionTooltip(subscribed));
ShowStatus(
subscribed ? AppLocalizer.T("已收藏歌单", "Playlist favorited") : AppLocalizer.T("已取消收藏", "Playlist unfavorited"),
subscribed
? AppLocalizer.T("歌单已复制到当前酷狗账户。", "The playlist was copied to the current Kugou account.")
: AppLocalizer.T("当前账户中的收藏副本已删除。", "The subscribed copy was removed from the current account."),
InfoBarSeverity.Success);
});
AddColumn(grid, action, 3);
}
return RowItem(playlist, grid, () => LoadPlaylistAsync(playlist));
}
@@ -831,6 +860,7 @@ public sealed class NetworkMusicPage : Page
_trackArtist.Text = song is null ? AppLocalizer.T("从搜索或歌单中选择歌曲", "Choose a song from search or a playlist") : $"{song.Artist} · {song.Album}";
if (_playPauseButton.Content is FontIcon icon) icon.Glyph = _playback.IsPlaying ? "\uE769" : "\uE768";
_mvButton.IsEnabled = song is not null;
BeginFavoriteStateRefresh(song);
if (_seekInteraction.ShouldSynchronize)
{
_syncingPosition = true;
@@ -969,11 +999,83 @@ public sealed class NetworkMusicPage : Page
if (_playback.CurrentSong is not { } song) return;
await RunBusyAsync(async () =>
{
await _providers.GetRequired(song.Provider).SetFavoriteAsync(song.Id, true);
ShowStatus(AppLocalizer.T("已收藏", "Favorited"), AppLocalizer.T("歌曲已加入我喜欢的音乐。", "The song was added to your liked music."), InfoBarSeverity.Success);
var provider = _providers.GetRequired(song.Provider);
var key = FavoriteSongKey(song);
var current = string.Equals(_favoriteStateSongKey, key, StringComparison.Ordinal) ? _favoriteState : null;
current ??= await provider.GetFavoriteStateAsync(song);
var target = current is not true;
await provider.SetFavoriteAsync(song, target);
if (string.Equals(_favoriteStateSongKey, key, StringComparison.Ordinal))
{
_favoriteState = target;
ApplyFavoriteButtonState(song, target);
}
ShowStatus(
target ? AppLocalizer.T("已收藏", "Favorited") : AppLocalizer.T("已取消收藏", "Unfavorited"),
target
? AppLocalizer.T("歌曲已加入我喜欢的音乐。", "The song was added to your liked music.")
: AppLocalizer.T("歌曲已从我喜欢的音乐中移除。", "The song was removed from your liked music."),
InfoBarSeverity.Success);
});
}
private void BeginFavoriteStateRefresh(MusicSong? song)
{
var key = song is null ? string.Empty : FavoriteSongKey(song);
if (string.Equals(_favoriteStateSongKey, key, StringComparison.Ordinal)) return;
_favoriteStateCts?.Cancel();
_favoriteStateCts?.Dispose();
_favoriteStateCts = null;
_favoriteStateSongKey = key;
_favoriteState = null;
ApplyFavoriteButtonState(song, null);
if (song is null) return;
_favoriteStateCts = new CancellationTokenSource();
_ = RefreshFavoriteStateAsync(song, key, _favoriteStateCts.Token);
}
private async Task RefreshFavoriteStateAsync(MusicSong song, string key, CancellationToken cancellationToken)
{
bool? state = null;
try
{
var provider = _providers.GetRequired(song.Provider);
if (provider.LoginState.LoggedIn)
{
state = await provider.GetFavoriteStateAsync(song, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
catch
{
// A passive state refresh must not interrupt playback or expose credentials.
}
if (cancellationToken.IsCancellationRequested || !string.Equals(_favoriteStateSongKey, key, StringComparison.Ordinal)) return;
_favoriteState = state;
ApplyFavoriteButtonState(song, state);
}
private void ApplyFavoriteButtonState(MusicSong? song, bool? favorite)
{
_favoriteButton.IsEnabled = song is not null;
if (_favoriteButton.Content is FontIcon icon) icon.Glyph = favorite is true ? "\uEB52" : "\uEB51";
var tooltip = favorite is true
? AppLocalizer.T("取消收藏当前歌曲", "Unfavorite current song")
: AppLocalizer.T("收藏当前歌曲", "Favorite current song");
ToolTipService.SetToolTip(_favoriteButton, tooltip);
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(_favoriteButton, tooltip);
}
private static string FavoriteSongKey(MusicSong song) => $"{song.Provider}:{song.Id}";
private static string PlaylistSubscriptionTooltip(bool subscribed)
=> subscribed
? AppLocalizer.T("取消收藏歌单", "Unfavorite playlist")
: AppLocalizer.T("收藏歌单", "Favorite playlist");
private async Task ShowAccountDialogAsync()
{
var sourceChanged = false;
@@ -1011,8 +1113,7 @@ public sealed class NetworkMusicPage : Page
ContentDialog? dialog = null;
var content = new StackPanel { Spacing = 12, MinWidth = 440 };
var signedIn = currentProvider.LoginState.LoggedIn;
var useKugouWebLogin = !signedIn && string.Equals(currentProvider.Id, "kugou", StringComparison.OrdinalIgnoreCase);
WebView2? providerLoginWeb = null;
var supportsCookieSignIn = !string.Equals(currentProvider.Id, "kugou", StringComparison.OrdinalIgnoreCase);
var sourceOptions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 18 };
var sourceGroup = $"music-provider-{Guid.NewGuid():N}";
@@ -1086,61 +1187,16 @@ public sealed class NetworkMusicPage : Page
if (!signedIn)
{
content.Children.Add(qrStatus);
if (useKugouWebLogin)
{
qrStatus.Text = AppLocalizer.T("请使用酷狗音乐 App 扫码并在手机上确认,软件会自动检测登录状态。", "Scan with the Kugou Music app and confirm on your phone. Sign-in will be detected automatically.");
providerLoginWeb = new WebView2
{
Width = 440,
Height = 376,
HorizontalAlignment = HorizontalAlignment.Center
};
var web = providerLoginWeb;
qrCts = new CancellationTokenSource();
var loginToken = qrCts.Token;
web.Loaded += async (_, _) =>
{
try
{
var environment = await _webViewFactory.CreateAsync("Music/KugouLogin");
await web.EnsureCoreWebView2Async(environment);
await web.CoreWebView2.Profile.ClearBrowsingDataAsync();
web.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
web.CoreWebView2.Settings.AreDevToolsEnabled = false;
web.CoreWebView2.Settings.IsGeneralAutofillEnabled = false;
web.CoreWebView2.Settings.IsPasswordAutosaveEnabled = false;
var completion = new TaskCompletionSource<Uri>(TaskCreationOptions.RunContinuationsAsynchronously);
web.CoreWebView2.NavigationStarting += (_, args) =>
{
if (Uri.TryCreate(args.Uri, UriKind.Absolute, out var uri) &&
KugouMusicProvider.IsOfficialWebLoginCompletionUri(uri))
{
completion.TrySetResult(uri);
}
};
web.Source = KugouOfficialLoginUri;
_ = MonitorKugouWebLoginAsync(web, currentProvider, dialog, qrStatus, accountState, completion.Task, loginToken);
}
catch (Exception exception)
{
qrStatus.Text = AppLocalizer.SanitizeSensitiveText(exception.Message, 180);
}
};
content.Children.Add(web);
}
else
{
content.Children.Add(qrImage);
qrActions.Children.Add(DarkButton(AppLocalizer.T("刷新二维码", "Refresh QR code"), "\uE72C", StartQr));
qrActions.Children.Add(DarkButton(AppLocalizer.T("取消扫码", "Cancel QR sign-in"), "\uE71A", () =>
CancelQr(AppLocalizer.T("已取消扫码登录", "QR sign-in canceled"))));
content.Children.Add(qrActions);
content.Children.Add(cookie);
}
content.Children.Add(qrImage);
qrActions.Children.Add(DarkButton(AppLocalizer.T("刷新二维码", "Refresh QR code"), "\uE72C", StartQr));
qrActions.Children.Add(DarkButton(AppLocalizer.T("取消扫码", "Cancel QR sign-in"), "\uE71A", () =>
CancelQr(AppLocalizer.T("已取消扫码登录", "QR sign-in canceled"))));
content.Children.Add(qrActions);
if (supportsCookieSignIn) content.Children.Add(cookie);
}
var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
if (!signedIn && !useKugouWebLogin) actions.Children.Add(DarkButton(AppLocalizer.T("Cookie 登录", "Cookie sign-in"), "\uE8D7", async () =>
if (!signedIn && supportsCookieSignIn) actions.Children.Add(DarkButton(AppLocalizer.T("Cookie 登录", "Cookie sign-in"), "\uE8D7", async () =>
{
try
{
@@ -1189,20 +1245,9 @@ public sealed class NetworkMusicPage : Page
CloseButtonText = AppLocalizer.T("关闭", "Close"),
DefaultButton = ContentDialogButton.Close
};
if (!signedIn && !useKugouWebLogin) StartQr();
if (!signedIn) StartQr();
await dialog.ShowAsync();
CancelQr(AppLocalizer.T("已关闭账户窗口", "Account dialog closed"));
if (providerLoginWeb?.CoreWebView2 is { } coreWebView)
{
try
{
await coreWebView.Profile.ClearBrowsingDataAsync();
}
catch
{
}
providerLoginWeb.Close();
}
if (!string.IsNullOrWhiteSpace(requestedProviderId))
{
@@ -1212,6 +1257,8 @@ public sealed class NetworkMusicPage : Page
}
RefreshAccountPicture();
_favoriteStateSongKey = string.Empty;
BeginFavoriteStateRefresh(_playback.CurrentSong);
if (sourceChanged || Provider.LoginState.LoggedIn)
{
await RunBusyAsync(LoadRecommendedCoreAsync);
@@ -1228,89 +1275,6 @@ public sealed class NetworkMusicPage : Page
: null;
}
private static async Task<string> CaptureKugouWebCookiesAsync(WebView2 web)
{
if (web.CoreWebView2 is null) return string.Empty;
var cookiePairs = new List<(string Name, string Value, int Priority)>();
foreach (var (uri, priority) in KugouMusicProvider.BrowserCookieSources)
{
var cookies = await web.CoreWebView2.CookieManager.GetCookiesAsync(uri);
cookiePairs.AddRange(cookies
.Where(cookie => cookie.Domain.TrimStart('.').EndsWith("kugou.com", StringComparison.OrdinalIgnoreCase))
.Select(cookie => (cookie.Name, cookie.Value, priority)));
}
return KugouMusicProvider.MergeBrowserCookies(cookiePairs);
}
private static async Task MonitorKugouWebLoginAsync(
WebView2 web,
IMusicProvider provider,
ContentDialog? dialog,
TextBlock status,
TextBlock account,
Task<Uri> officialCompletion,
CancellationToken cancellationToken)
{
var previousCookies = string.Empty;
var attempts = 0;
try
{
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
if (!officialCompletion.IsCompletedSuccessfully) continue;
var cookies = await CaptureKugouWebCookiesAsync(web);
if (!KugouMusicProvider.HasWebLoginCredential(cookies)) continue;
if (!string.Equals(cookies, previousCookies, StringComparison.Ordinal))
{
previousCookies = cookies;
attempts = 0;
}
if (attempts >= 6)
{
status.Text = AppLocalizer.T("手机已授权,账户同步较慢,软件仍在继续检测…", "Phone authorization succeeded, but account sync is taking longer. Detection is continuing...");
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
attempts = 5;
}
attempts++;
status.Text = AppLocalizer.T("已检测到手机授权,正在验证登录状态…", "Phone authorization detected. Verifying sign-in...");
MusicLoginState state;
try
{
state = provider is KugouMusicProvider kugou
? await kugou.CompleteOfficialWebLoginAsync(cookies, officialCompletion.Result, cancellationToken)
: await provider.LoginWithCookieAsync(cookies, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
status.Text = AppLocalizer.T(
$"登录状态暂时无法验证,将自动重试:{AppLocalizer.SanitizeSensitiveText(exception.Message, 100)}",
$"Sign-in could not be verified yet and will be retried: {AppLocalizer.SanitizeSensitiveText(exception.Message, 100)}");
continue;
}
if (!state.LoggedIn) continue;
account.Text = AppLocalizer.T($"已登录:{state.Nickname}", $"Signed in: {state.Nickname}");
status.Text = AppLocalizer.T("登录成功,凭据已使用 Windows DPAPI 加密保存。", "Signed in. Credentials are encrypted with Windows DPAPI.");
await Task.Delay(500, cancellationToken);
dialog?.Hide();
return;
}
}
catch (OperationCanceledException)
{
}
catch (Exception exception)
{
status.Text = AppLocalizer.SanitizeSensitiveText(exception.Message, 180);
}
}
private async Task ShowMvAsync()
{
if (_playback.CurrentSong is not { } song) return;
@@ -1368,7 +1332,22 @@ public sealed class NetworkMusicPage : Page
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
var state = await provider.CheckQrSessionAsync(session, cancellationToken);
MusicQrStatus state;
try
{
state = await provider.CheckQrSessionAsync(session, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
status.Text = AppLocalizer.T(
$"暂时无法检查登录状态,将自动重试:{AppLocalizer.SanitizeSensitiveText(exception.Message, 100)}",
$"Sign-in status is temporarily unavailable and will be retried: {AppLocalizer.SanitizeSensitiveText(exception.Message, 100)}");
continue;
}
status.Text = QrStatusText(state);
if (state.Code == 804)
{
@@ -19,6 +19,7 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
private readonly Action? _goBack;
private readonly IDevEnvironmentDetectionService _detection = AppServices.GetRequiredService<IDevEnvironmentDetectionService>();
private readonly IDevEnvironmentCatalogService _catalog = AppServices.GetRequiredService<IDevEnvironmentCatalogService>();
private readonly IDevTerminalSetupService _terminalSetup = AppServices.GetRequiredService<IDevTerminalSetupService>();
private readonly IDownloadManagerService _downloads = AppServices.GetRequiredService<IDownloadManagerService>();
private readonly StackPanel _environmentList = new() { Spacing = 12 };
private readonly TextBlock _statusText = ModernUi.Text(AppLocalizer.T("准备检测开发环境。", "Ready to detect development environments."), 14, foreground: ModernUi.TextSecondary);
@@ -26,6 +27,12 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
private readonly Dictionary<string, ComboBox> _sourceBoxes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Button> _quickButtons = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Button> _sourceButtons = new(StringComparer.OrdinalIgnoreCase);
private readonly TextBlock _terminalStatus = ModernUi.Text(AppLocalizer.T("正在检测终端环境...", "Checking terminal environment..."), 14, FontWeights.SemiBold);
private readonly TextBlock _terminalDetail = ModernUi.Text(string.Empty, 12, foreground: ModernUi.TextSecondary, maxLines: 3);
private readonly ProgressBar _terminalProgress = new() { Minimum = 0, Maximum = 100, Visibility = Visibility.Collapsed };
private Button? _terminalInstallButton;
private Button? _terminalOpenButton;
private DevTerminalSnapshot? _terminalSnapshot;
private IReadOnlyDictionary<string, DetectedDevEnvironment> _detected = new Dictionary<string, DetectedDevEnvironment>();
private IReadOnlyDictionary<string, IReadOnlyList<DevEnvironmentVersion>> _versions = new Dictionary<string, IReadOnlyList<DevEnvironmentVersion>>();
@@ -58,6 +65,7 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
back: _goBack,
backTooltip: AppLocalizer.T("返回上一级", "Back"),
meta: _statusText));
root.Children.Add(BuildTerminalSetupCard());
root.Children.Add(_environmentList);
return new ScrollViewer
@@ -68,6 +76,63 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
};
}
private Border BuildTerminalSetupCard()
{
_terminalInstallButton = ModernUi.PillButton(
AppLocalizer.T("安装 / 修复", "Install / repair"),
"\uE896",
async () => await InstallTerminalAsync(),
primary: true);
_terminalOpenButton = ModernUi.PillButton(
AppLocalizer.T("打开终端", "Open terminal"),
"\uE756",
async () => await OpenTerminalAsync());
var refresh = ModernUi.IconButton("\uE72C", AppLocalizer.T("重新检测终端", "Check terminal again"), async () => await RefreshTerminalAsync());
var actions = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
Children = { _terminalInstallButton, _terminalOpenButton, refresh }
};
var top = new Grid { ColumnSpacing = 14 };
top.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
top.ColumnDefinitions.Add(new ColumnDefinition());
top.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
top.Children.Add(ModernUi.IconTile("\uE756", 44, ModernUi.AccentSoft, ModernUi.Accent, 19));
var copy = new StackPanel
{
Spacing = 3,
Children =
{
ModernUi.Text(AppLocalizer.T("Windows 终端环境", "Windows terminal environment"), 18, FontWeights.SemiBold),
_terminalStatus,
_terminalDetail
}
};
Grid.SetColumn(copy, 1);
top.Children.Add(copy);
Grid.SetColumn(actions, 2);
top.Children.Add(actions);
return ModernUi.Card(new StackPanel
{
Spacing = 12,
Children =
{
top,
_terminalProgress,
ModernUi.Text(
AppLocalizer.T(
"安装 Windows Terminal 与 PowerShell 7;系统支持时自动设置默认终端和默认 PowerShell 配置。",
"Install Windows Terminal and PowerShell 7, then configure supported default terminal settings."),
12,
foreground: ModernUi.TextSecondary,
maxLines: 2)
}
}, new Thickness(16), radius: 8);
}
private async Task ReloadAsync()
{
_statusText.Text = AppLocalizer.T("正在检测本机环境并读取官方版本信息...", "Detecting local environments and reading official version metadata...");
@@ -76,7 +141,9 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
try
{
var detected = await _detection.DetectAsync();
var terminalTask = _terminalSetup.DetectAsync();
var detectedTask = _detection.DetectAsync();
var detected = await detectedTask;
_detected = detected.ToDictionary(item => item.Id, StringComparer.OrdinalIgnoreCase);
var versionPairs = await Task.WhenAll(_catalog.Definitions.Select(async definition =>
@@ -85,6 +152,7 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
return (definition.Id, Versions: versions);
}));
_versions = versionPairs.ToDictionary(item => item.Id, item => item.Versions, StringComparer.OrdinalIgnoreCase);
ApplyTerminalSnapshot(await terminalTask);
Render();
}
@@ -99,6 +167,146 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
}
}
private async Task RefreshTerminalAsync()
{
SetTerminalBusy(true, AppLocalizer.T("正在检测终端环境...", "Checking terminal environment..."));
try
{
ApplyTerminalSnapshot(await _terminalSetup.DetectAsync());
}
catch (Exception exception)
{
_terminalStatus.Text = AppLocalizer.T("终端检测失败", "Terminal check failed");
_terminalDetail.Text = AppLocalizer.SanitizeSensitiveText(exception.Message, 180);
}
finally
{
SetTerminalBusy(false);
}
}
private async Task InstallTerminalAsync()
{
if (XamlRoot is null)
{
return;
}
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = AppLocalizer.T("安装终端套件?", "Install terminal suite?"),
Content = ModernUi.Text(
AppLocalizer.T(
"将安装或升级 Windows Terminal 与 PowerShell 7,并在系统支持时更新当前用户的 PATH 和默认终端设置。安装包仅从微软官方来源获取。",
"Windows Terminal and PowerShell 7 will be installed or upgraded from official Microsoft sources. Supported PATH and default-terminal settings will be updated for the current user."),
13,
foreground: ModernUi.TextSecondary,
maxLines: 5),
PrimaryButtonText = AppLocalizer.T("继续", "Continue"),
CloseButtonText = AppLocalizer.T("取消", "Cancel"),
DefaultButton = ContentDialogButton.Primary
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return;
}
SetTerminalBusy(true, AppLocalizer.T("正在准备终端安装...", "Preparing terminal setup..."));
try
{
var progress = new Progress<DevTerminalInstallProgress>(item =>
{
_terminalProgress.Value = item.Percent;
_terminalStatus.Text = AppLocalizer.T(item.Message switch
{
"Checking the current terminal environment..." => "正在检查当前终端环境...",
"Installing or upgrading Windows Terminal..." => "正在安装或升级 Windows Terminal...",
"Installing or upgrading PowerShell 7..." => "正在安装或升级 PowerShell 7...",
"Refreshing PATH and verifying terminal commands..." => "正在刷新 PATH 并验证终端命令...",
"Configuring Windows Terminal defaults..." => "正在配置默认终端...",
"Terminal setup completed." => "终端配置已完成。",
_ => item.Message
}, item.Message);
});
var result = await _terminalSetup.InstallOrRepairAsync(progress);
ApplyTerminalSnapshot(result.Snapshot);
if (result.Succeeded)
{
ToastService.Show(AppLocalizer.T("终端套件已配置。", "Terminal suite configured."), ToastKind.Success);
}
else
{
_terminalDetail.Text = string.Join(" ", result.Messages.Append(AppLocalizer.T("请查看状态后重试失败的组件。", "Review the status and retry the failed component.")));
ToastService.Show(AppLocalizer.T("终端套件仅完成了部分配置。", "Terminal setup completed partially."), ToastKind.Warning);
}
}
catch (Exception exception)
{
_terminalStatus.Text = AppLocalizer.T("终端安装失败", "Terminal setup failed");
_terminalDetail.Text = AppLocalizer.SanitizeSensitiveText(exception.Message, 200);
ToastService.Show(AppLocalizer.T("终端安装失败,请查看详细状态。", "Terminal setup failed. Review the status details."), ToastKind.Warning);
}
finally
{
SetTerminalBusy(false);
}
}
private async Task OpenTerminalAsync()
{
if (!await _terminalSetup.OpenTerminalAsync())
{
ToastService.Show(AppLocalizer.T("无法启动终端。", "Could not start a terminal."), ToastKind.Warning);
}
}
private void ApplyTerminalSnapshot(DevTerminalSnapshot snapshot)
{
_terminalSnapshot = snapshot;
var terminal = snapshot.WindowsTerminalInstalled
? $"Windows Terminal {ValueOrDash(snapshot.WindowsTerminalVersion)}"
: AppLocalizer.T("Windows Terminal 未安装", "Windows Terminal not installed");
var powershell = snapshot.PowerShellInstalled
? $"PowerShell {ValueOrDash(snapshot.PowerShellVersion)}"
: AppLocalizer.T("PowerShell 7 未安装", "PowerShell 7 not installed");
_terminalStatus.Text = $"{terminal} · {powershell}";
var defaultState = !snapshot.SupportsDefaultTerminal
? AppLocalizer.T($"Windows {snapshot.WindowsBuild} 不支持系统默认终端切换", $"Windows {snapshot.WindowsBuild} does not support changing the system default terminal")
: snapshot.IsWindowsTerminalDefault
? AppLocalizer.T("Windows Terminal 已设为默认", "Windows Terminal is the default")
: AppLocalizer.T("尚未设为默认终端", "Not yet the default terminal");
var profileState = snapshot.IsPowerShellDefaultProfile
? AppLocalizer.T("PowerShell 7 默认配置已启用", "PowerShell 7 default profile enabled")
: AppLocalizer.T("PowerShell 7 默认配置待设置", "PowerShell 7 default profile pending");
_terminalDetail.Text = $"{defaultState} · {profileState} · {snapshot.Architecture}";
if (_terminalOpenButton is not null)
{
_terminalOpenButton.IsEnabled = snapshot.PowerShellInstalled || snapshot.WindowsTerminalInstalled;
}
}
private void SetTerminalBusy(bool busy, string? message = null)
{
if (_terminalInstallButton is not null)
{
_terminalInstallButton.IsEnabled = !busy;
}
if (_terminalOpenButton is not null)
{
_terminalOpenButton.IsEnabled = !busy && (_terminalSnapshot?.IsReady == true || _terminalSnapshot?.PowerShellInstalled == true);
}
_terminalProgress.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
_terminalProgress.IsIndeterminate = busy && _terminalProgress.Value <= 0;
if (!string.IsNullOrWhiteSpace(message))
{
_terminalStatus.Text = message;
}
}
private static string ValueOrDash(string value) => string.IsNullOrWhiteSpace(value) ? "--" : value;
private void Render()
{
_versionBoxes.Clear();
@@ -229,6 +437,14 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
return;
}
if (string.Equals(candidate.Source.SourceKind, "Manual", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Path.GetExtension(candidate.Source.FileName), ".url", StringComparison.OrdinalIgnoreCase))
{
await Launcher.LaunchUriAsync(uri);
ToastService.Show(AppLocalizer.T("已打开官方下载安装页面。", "Opened the official download page."), ToastKind.Success);
return;
}
var plan = _catalog.CreateInstallPlan(definition.Id, version, candidate);
var item = await _downloads.EnqueueAsync(
candidate.Source,
@@ -236,7 +452,8 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
InstallCommand: mode == DevEnvironmentInstallMode.QuickInstall ? "installer" : null,
InstallArguments: plan.InstallArguments,
IsInstaller: mode == DevEnvironmentInstallMode.QuickInstall,
DeleteAfterInstall: mode == DevEnvironmentInstallMode.QuickInstall));
DeleteAfterInstall: mode == DevEnvironmentInstallMode.QuickInstall,
OpenKind: mode == DevEnvironmentInstallMode.QuickInstall ? DownloadOpenKind.Installer : DownloadOpenKind.File));
ToastService.Show(AppLocalizer.T("已加入下载管理。", "Added to Download Manager."), ToastKind.Success);
if (mode == DevEnvironmentInstallMode.SourceBuild)
@@ -75,12 +75,14 @@ public sealed partial class ToolPageRegistry
registry.Register<ZhihuHotToolPage, ZhihuHotToolViewModel>("zhihu_hot", (module, goBack) => new ZhihuHotToolPage(module, goBack));
registry.Register<CctvNewsToolPage, CctvNewsToolViewModel>("cctv_news", (module, goBack) => new CctvNewsToolPage(module, goBack));
registry.Register<TechNewsToolPage, TechNewsToolViewModel>("tech_news", (module, goBack) => new TechNewsToolPage(module, goBack));
registry.Register<AiLatestNewsToolPage, AiLatestNewsToolViewModel>("ai_latest_news", (module, goBack) => new AiLatestNewsToolPage(module, goBack));
registry.Register<FootballNewsToolPage, FootballNewsToolViewModel>("football_news", (module, goBack) => new FootballNewsToolPage(module, goBack));
registry.Register<MovieBoxOfficeToolPage, MovieBoxOfficeToolViewModel>("movie_box_office", (module, goBack) => new MovieBoxOfficeToolPage(module, goBack));
registry.Register<EarthquakeInfoToolPage, EarthquakeInfoToolViewModel>("earthquake_info", (module, goBack) => new EarthquakeInfoToolPage(module, goBack));
registry.Register<GoldPriceToolPage, GoldPriceToolViewModel>("gold_price", (module, goBack) => new GoldPriceToolPage(module, goBack));
registry.Register<OilPriceToolPage, OilPriceToolViewModel>("oil_price", (module, goBack) => new OilPriceToolPage(module, goBack));
registry.Register<TrainQueryToolPage, TrainQueryToolViewModel>("train_query", (module, goBack) => new TrainQueryToolPage(module, goBack));
registry.Register<CityRouteQueryToolPage, CityRouteQueryToolViewModel>("city_route_query", (module, goBack) => new CityRouteQueryToolPage(module, goBack));
registry.Register<HistoryTodayToolPage, HistoryTodayToolViewModel>("history_today", (module, goBack) => new HistoryTodayToolPage(module, goBack));
registry.Register<CarInfoToolPage, CarInfoToolViewModel>("car_info", (module, goBack) => new CarInfoToolPage(module, goBack));
registry.Register<SanguoshaSkinToolPage, SanguoshaSkinToolViewModel>("sanguosha_skin", (module, goBack) => new SanguoshaSkinToolPage(module, goBack));
@@ -171,6 +173,7 @@ public sealed partial class ToolPageRegistry
registry.Register<SystemToolToolPage, SystemToolToolViewModel>("system_tool", (module, goBack) => new SystemToolToolPage(module, goBack));
registry.Register<PcBenchmarkToolPage, PcBenchmarkToolViewModel>("pc_benchmark", (module, goBack) => new PcBenchmarkToolPage(module, goBack));
registry.Register<DevEnvironmentConfigToolPage, DevEnvironmentConfigToolViewModel>("dev_environment_config", (module, goBack) => new DevEnvironmentConfigToolPage(module, new DevEnvironmentConfigToolViewModel(module), goBack));
registry.Register<SerialTerminalToolPage, SerialTerminalToolViewModel>("serial_terminal", (module, goBack) => new SerialTerminalToolPage(module, goBack));
}
}
@@ -375,6 +378,9 @@ public sealed class CctvNewsToolPage(IToolModule module, Action? goBack = null)
public sealed class TechNewsToolViewModel(IToolModule module) : AdaptiveToolViewModel(module);
public sealed class TechNewsToolPage(IToolModule module, Action? goBack = null) : AdaptiveToolPage(module, new TechNewsToolViewModel(module), goBack);
public sealed class AiLatestNewsToolViewModel(IToolModule module) : AdaptiveToolViewModel(module);
public sealed class AiLatestNewsToolPage(IToolModule module, Action? goBack = null) : AdaptiveToolPage(module, new AiLatestNewsToolViewModel(module), goBack);
public sealed class FootballNewsToolViewModel(IToolModule module) : AdaptiveToolViewModel(module);
public sealed class FootballNewsToolPage(IToolModule module, Action? goBack = null) : AdaptiveToolPage(module, new FootballNewsToolViewModel(module), goBack);
@@ -393,6 +399,9 @@ public sealed class OilPriceToolPage(IToolModule module, Action? goBack = null)
public sealed class TrainQueryToolViewModel(IToolModule module) : AdaptiveToolViewModel(module);
public sealed class TrainQueryToolPage(IToolModule module, Action? goBack = null) : AdaptiveToolPage(module, new TrainQueryToolViewModel(module), goBack);
public sealed class CityRouteQueryToolViewModel(IToolModule module) : AdaptiveToolViewModel(module);
public sealed class CityRouteQueryToolPage(IToolModule module, Action? goBack = null) : AdaptiveToolPage(module, new CityRouteQueryToolViewModel(module), goBack);
public sealed class HistoryTodayToolViewModel(IToolModule module) : AdaptiveToolViewModel(module);
public sealed class HistoryTodayToolPage(IToolModule module, Action? goBack = null) : AdaptiveToolPage(module, new HistoryTodayToolViewModel(module), goBack);
@@ -660,4 +669,3 @@ public sealed class SystemToolToolPage(IToolModule module, Action? goBack = null
public sealed class PcBenchmarkToolViewModel(IToolModule module) : AdaptiveToolViewModel(module);
public sealed class PcBenchmarkToolPage(IToolModule module, Action? goBack = null) : AdaptiveToolPage(module, new PcBenchmarkToolViewModel(module), goBack);
@@ -0,0 +1,369 @@
using System.Text;
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Storage;
using Windows.Storage.Pickers;
using WinRT.Interop;
using YMhut.Box.Core.Tools;
using YMhut.Box.WinUI.Services;
using YMhut.Box.WinUI.ViewModels.Tools;
namespace YMhut.Box.WinUI.Views.Tools;
public sealed class SerialTerminalToolViewModel(IToolModule module) : AdaptiveToolViewModel(module);
public sealed class SerialTerminalToolPage : ToolPageBase
{
private readonly ISerialPortTransport _transport;
private readonly Action? _goBack;
private readonly ComboBox _portBox = new() { MinWidth = 130, PlaceholderText = "COM" };
private readonly ComboBox _baudBox = new() { MinWidth = 120 };
private readonly ComboBox _parityBox = new() { MinWidth = 110 };
private readonly ComboBox _dataBitsBox = new() { MinWidth = 90 };
private readonly ComboBox _stopBitsBox = new() { MinWidth = 100 };
private readonly ToggleSwitch _hexSend = new();
private readonly ToggleSwitch _hexReceive = new();
private readonly ToggleSwitch _timedSend = new();
private readonly NumberBox _interval = new() { Minimum = 100, Maximum = 60000, Value = 1000, SmallChange = 100, Width = 120 };
private readonly TextBox _sendBox = new() { AcceptsReturn = true, MinHeight = 92, TextWrapping = TextWrapping.Wrap };
private readonly TextBox _receiveBox = new()
{
AcceptsReturn = true,
IsReadOnly = true,
MinHeight = 260,
TextWrapping = TextWrapping.NoWrap,
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Cascadia Mono")
};
private readonly TextBlock _status = ModernUi.Text(string.Empty, 13, FontWeights.SemiBold, ModernUi.TextSecondary);
private readonly DispatcherTimer _sendTimer = new();
private Button? _connectButton;
private Button? _sendButton;
private bool _sending;
private bool _disposed;
public SerialTerminalToolPage(
IToolModule module,
Action? goBack = null,
ISerialPortTransport? transport = null)
{
_goBack = goBack;
_transport = transport ?? new SerialPortTransport();
BindModule(module);
Background = ModernUi.AppBackground;
ConfigureControls();
Content = BuildContent(module);
_transport.DataReceived += Transport_DataReceived;
_sendTimer.Tick += SendTimer_Tick;
Loaded += (_, _) => RefreshPorts();
Unloaded += SerialTerminalToolPage_Unloaded;
}
private void ConfigureControls()
{
foreach (var baud in new[] { 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400 })
{
_baudBox.Items.Add(baud);
}
_baudBox.SelectedItem = 115200;
foreach (var parity in Enum.GetValues<SerialParityMode>())
{
_parityBox.Items.Add(parity);
}
_parityBox.SelectedItem = SerialParityMode.None;
foreach (var dataBits in new[] { 5, 6, 7, 8 })
{
_dataBitsBox.Items.Add(dataBits);
}
_dataBitsBox.SelectedItem = 8;
foreach (var stopBits in Enum.GetValues<SerialStopBitsMode>())
{
_stopBitsBox.Items.Add(stopBits);
}
_stopBitsBox.SelectedItem = SerialStopBitsMode.One;
_hexSend.Header = AppLocalizer.T("HEX 发送", "HEX send");
_hexReceive.Header = AppLocalizer.T("HEX 显示", "HEX display");
_timedSend.Header = AppLocalizer.T("定时发送", "Timed send");
_timedSend.Toggled += (_, _) => UpdateTimer();
_interval.ValueChanged += (_, _) => UpdateTimer();
_sendBox.PlaceholderText = AppLocalizer.T("输入要发送的文本或十六进制字节", "Enter text or hexadecimal bytes to send");
_status.Text = AppLocalizer.T("未连接", "Disconnected");
}
private UIElement BuildContent(IToolModule module)
{
var refresh = ModernUi.IconButton("\uE72C", AppLocalizer.T("刷新串口", "Refresh ports"), RefreshPorts);
_connectButton = ModernUi.PillButton(AppLocalizer.T("连接", "Connect"), "\uE703", async () => await ToggleConnectionAsync(), primary: true);
var connectionActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { _connectButton, refresh } };
var connectionGrid = new Grid { ColumnSpacing = 10, RowSpacing = 10 };
for (var index = 0; index < 5; index++)
{
connectionGrid.ColumnDefinitions.Add(new ColumnDefinition());
}
AddLabeledControl(connectionGrid, 0, AppLocalizer.T("端口", "Port"), _portBox);
AddLabeledControl(connectionGrid, 1, AppLocalizer.T("波特率", "Baud"), _baudBox);
AddLabeledControl(connectionGrid, 2, AppLocalizer.T("校验", "Parity"), _parityBox);
AddLabeledControl(connectionGrid, 3, AppLocalizer.T("数据位", "Data bits"), _dataBitsBox);
AddLabeledControl(connectionGrid, 4, AppLocalizer.T("停止位", "Stop bits"), _stopBitsBox);
_sendButton = ModernUi.PillButton(AppLocalizer.T("发送", "Send"), "\uE724", async () => await SendAsync(), primary: true);
_sendButton.IsEnabled = false;
var sendActions = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
Children =
{
_hexSend,
_timedSend,
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 6,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
ModernUi.Text(AppLocalizer.T("间隔 ms", "Interval ms"), 12, foreground: ModernUi.TextSecondary),
_interval
}
},
_sendButton
}
};
var clear = ModernUi.IconButton("\uE74D", AppLocalizer.T("清空接收日志", "Clear receive log"), () => _receiveBox.Text = string.Empty);
var export = ModernUi.IconButton("\uE159", AppLocalizer.T("导出接收日志", "Export receive log"), async () => await ExportLogAsync());
var receiveHeader = new Grid();
receiveHeader.ColumnDefinitions.Add(new ColumnDefinition());
receiveHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
receiveHeader.Children.Add(_hexReceive);
var receiveActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, Children = { clear, export } };
Grid.SetColumn(receiveActions, 1);
receiveHeader.Children.Add(receiveActions);
var root = new StackPanel { Padding = new Thickness(24, 20, 24, 28), Spacing = 16 };
root.Children.Add(ModernUi.PageHeader(
ToolText.Name(module),
ToolText.Description(module),
module.Metadata.IconGlyph,
actions: connectionActions,
back: _goBack,
backTooltip: AppLocalizer.T("返回上一级", "Back"),
meta: _status));
root.Children.Add(ModernUi.Card(connectionGrid, new Thickness(14), radius: 8));
root.Children.Add(ModernUi.Card(new StackPanel
{
Spacing = 10,
Children =
{
ModernUi.Text(AppLocalizer.T("发送", "Send"), 16, FontWeights.SemiBold),
_sendBox,
sendActions
}
}, new Thickness(14), radius: 8));
root.Children.Add(ModernUi.Card(new StackPanel
{
Spacing = 10,
Children =
{
ModernUi.Text(AppLocalizer.T("接收日志", "Receive log"), 16, FontWeights.SemiBold),
receiveHeader,
_receiveBox
}
}, new Thickness(14), radius: 8));
return new ScrollViewer
{
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Content = root
};
}
private static void AddLabeledControl(Grid grid, int column, string label, Control control)
{
var panel = new StackPanel
{
Spacing = 5,
Children = { ModernUi.Text(label, 12, FontWeights.SemiBold, ModernUi.TextSecondary), control }
};
Grid.SetColumn(panel, column);
grid.Children.Add(panel);
}
private void RefreshPorts()
{
var selected = _portBox.SelectedItem?.ToString();
_portBox.Items.Clear();
foreach (var name in _transport.GetPortNames())
{
_portBox.Items.Add(name);
}
_portBox.SelectedItem = !string.IsNullOrWhiteSpace(selected) && _portBox.Items.Contains(selected)
? selected
: _portBox.Items.FirstOrDefault();
if (_portBox.Items.Count == 0)
{
_status.Text = AppLocalizer.T("未发现串口设备", "No serial ports found");
}
}
private async Task ToggleConnectionAsync()
{
if (_transport.IsOpen)
{
await DisconnectAsync();
return;
}
if (_portBox.SelectedItem is not string portName)
{
ToastService.Show(AppLocalizer.T("请选择串口。", "Select a serial port."), ToastKind.Warning);
return;
}
try
{
var options = new SerialConnectionOptions(
portName,
_baudBox.SelectedItem is int baud ? baud : 115200,
_dataBitsBox.SelectedItem is int dataBits ? dataBits : 8,
_parityBox.SelectedItem is SerialParityMode parity ? parity : SerialParityMode.None,
_stopBitsBox.SelectedItem is SerialStopBitsMode stopBits ? stopBits : SerialStopBitsMode.One);
await _transport.OpenAsync(options);
_status.Text = AppLocalizer.T($"已连接 {portName} · {options.BaudRate}", $"Connected to {portName} · {options.BaudRate}");
_connectButton!.Content = AppLocalizer.T("断开", "Disconnect");
_sendButton!.IsEnabled = true;
SetConnectionControlsEnabled(false);
UpdateTimer();
}
catch (Exception exception)
{
_status.Text = AppLocalizer.T("连接失败", "Connection failed");
ToastService.Show(AppLocalizer.SanitizeSensitiveText(exception.Message, 160), ToastKind.Warning);
}
}
private async Task DisconnectAsync()
{
_sendTimer.Stop();
await _transport.CloseAsync();
_status.Text = AppLocalizer.T("未连接", "Disconnected");
if (_connectButton is not null)
{
_connectButton.Content = AppLocalizer.T("连接", "Connect");
}
if (_sendButton is not null)
{
_sendButton.IsEnabled = false;
}
SetConnectionControlsEnabled(true);
}
private async Task SendAsync()
{
if (_sending || !_transport.IsOpen)
{
return;
}
_sending = true;
try
{
var data = SerialPayloadCodec.Parse(_sendBox.Text, _hexSend.IsOn);
if (data.Length == 0)
{
return;
}
await _transport.WriteAsync(data);
AppendLog("TX", SerialPayloadCodec.Format(data, _hexSend.IsOn));
}
catch (Exception exception)
{
ToastService.Show(AppLocalizer.SanitizeSensitiveText(exception.Message, 160), ToastKind.Warning);
}
finally
{
_sending = false;
}
}
private async void SendTimer_Tick(object? sender, object e)
=> await SendAsync();
private void UpdateTimer()
{
_sendTimer.Stop();
if (_timedSend.IsOn && _transport.IsOpen)
{
_sendTimer.Interval = TimeSpan.FromMilliseconds(Math.Clamp(_interval.Value, 100, 60000));
_sendTimer.Start();
}
}
private void Transport_DataReceived(object? sender, YMhut.Box.Core.Tools.SerialDataReceivedEventArgs e)
{
var data = e.Data.ToArray();
DispatcherQueue.TryEnqueue(() => AppendLog("RX", SerialPayloadCodec.Format(data, _hexReceive.IsOn)));
}
private void AppendLog(string direction, string value)
{
var next = $"[{DateTime.Now:HH:mm:ss.fff}] {direction} {value}";
_receiveBox.Text = string.IsNullOrEmpty(_receiveBox.Text) ? next : _receiveBox.Text + Environment.NewLine + next;
if (_receiveBox.Text.Length > 250_000)
{
_receiveBox.Text = _receiveBox.Text[^200_000..];
}
_receiveBox.SelectionStart = _receiveBox.Text.Length;
}
private async Task ExportLogAsync()
{
if (App.CurrentWindow is null)
{
return;
}
var picker = new FileSavePicker { SuggestedFileName = $"serial-{DateTime.Now:yyyyMMdd-HHmmss}" };
picker.FileTypeChoices.Add(AppLocalizer.T("文本日志", "Text log"), [".txt"]);
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.CurrentWindow));
var file = await picker.PickSaveFileAsync();
if (file is not null)
{
await FileIO.WriteTextAsync(file, _receiveBox.Text);
ToastService.Show(AppLocalizer.T("串口日志已导出。", "Serial log exported."), ToastKind.Success);
}
}
private void SetConnectionControlsEnabled(bool enabled)
{
_portBox.IsEnabled = enabled;
_baudBox.IsEnabled = enabled;
_parityBox.IsEnabled = enabled;
_dataBitsBox.IsEnabled = enabled;
_stopBitsBox.IsEnabled = enabled;
}
private async void SerialTerminalToolPage_Unloaded(object sender, RoutedEventArgs e)
{
if (_disposed)
{
return;
}
_disposed = true;
_sendTimer.Stop();
_sendTimer.Tick -= SendTimer_Tick;
_transport.DataReceived -= Transport_DataReceived;
try
{
await _transport.CloseAsync();
}
catch
{
}
_transport.Dispose();
}
}
+1
View File
@@ -37,6 +37,7 @@
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260416003" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />
<PackageReference Include="System.Drawing.Common" Version="10.0.9" />
<PackageReference Include="System.IO.Ports" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YMhut.Box.Core\YMhut.Box.Core.csproj" />