完善音乐下载、下载管理和开发工具
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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" => "收藏歌单",
|
||||
_ => "酷狗请求"
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||