diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4a5109d..b59fb9e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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. | diff --git a/src/YMhut.Box.Core/DevEnvironments/DevTerminalSetupService.cs b/src/YMhut.Box.Core/DevEnvironments/DevTerminalSetupService.cs new file mode 100644 index 0000000..1e3033b --- /dev/null +++ b/src/YMhut.Box.Core/DevEnvironments/DevTerminalSetupService.cs @@ -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 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 ResolveCommandAsync(string command, CancellationToken cancellationToken = default); + + Task RunAsync(string fileName, string arguments, CancellationToken cancellationToken = default); + + Task InstallWindowsTerminalFallbackAsync(CancellationToken cancellationToken = default); + + Task InstallPowerShellFallbackAsync(CancellationToken cancellationToken = default); + + Task ConfigureDefaultsAsync(string powerShellPath, CancellationToken cancellationToken = default); + + bool IsWindowsTerminalDefault(); + + bool IsPowerShellDefaultProfile(); + + Task OpenTerminalAsync(CancellationToken cancellationToken = default); +} + +public interface IDevTerminalSetupService +{ + Task DetectAsync(CancellationToken cancellationToken = default); + + Task InstallOrRepairAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default); + + Task OpenTerminalAsync(CancellationToken cancellationToken = default); +} + +public sealed class DevTerminalSetupService( + IDevTerminalPlatform platform, + ILogService? logService = null) : IDevTerminalSetupService +{ + public async Task 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 InstallOrRepairAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var messages = new List(); + 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 OpenTerminalAsync(CancellationToken cancellationToken = default) + => platform.OpenTerminalAsync(cancellationToken); + + public static bool SupportsDefaultTerminal(int windowsBuild) + => windowsBuild >= 22000 || windowsBuild >= 19045; + + private async Task 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 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 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 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 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 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 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(), ManagedPowerShellProfileGuid, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + public async Task 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 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 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() + .FirstOrDefault(item => string.Equals(item["guid"]?.GetValue(), 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); +} diff --git a/src/YMhut.Box.Core/Downloads/DownloadModels.cs b/src/YMhut.Box.Core/Downloads/DownloadModels.cs index 7939955..181331e 100644 --- a/src/YMhut.Box.Core/Downloads/DownloadModels.cs +++ b/src/YMhut.Box.Core/Downloads/DownloadModels.cs @@ -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 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() } + Converters = + { + new JsonStringEnumConverter(), + new JsonStringEnumConverter() + } }; public static string Serialize(DownloadHostMessage message) diff --git a/src/YMhut.Box.Core/Downloads/DownloadQueueStore.cs b/src/YMhut.Box.Core/Downloads/DownloadQueueStore.cs index 1877e96..dfe1de4 100644 --- a/src/YMhut.Box.Core/Downloads/DownloadQueueStore.cs +++ b/src/YMhut.Box.Core/Downloads/DownloadQueueStore.cs @@ -20,7 +20,11 @@ public sealed class DownloadQueueStore(AppPaths paths) : IDownloadQueueStore private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true, - Converters = { new JsonStringEnumConverter() } + Converters = + { + new JsonStringEnumConverter(), + new JsonStringEnumConverter() + } }; private readonly string _path = Path.Combine(paths.Data, "downloads.json"); diff --git a/src/YMhut.Box.Core/Music/KugouApiClient.cs b/src/YMhut.Box.Core/Music/KugouApiClient.cs new file mode 100644 index 0000000..843421e --- /dev/null +++ b/src/YMhut.Box.Core/Music/KugouApiClient.cs @@ -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 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; +} + +/// +/// Minimal, HTTPS-only KuGou protocol adapter based on KuGouMusicApi commit +/// 06560e3e053bda1ab830750db6f645bab703f824. +/// +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? _playlistKeyFactory; + + public KugouApiClient(HttpMessageHandler? handler = null, Func? 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 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 + { + ["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 CreateQrKeyAsync(CancellationToken cancellationToken) + { + var result = await SendWebAsync( + "https://login-user.kugou.com", + "/v2/qrcode", + new Dictionary + { + ["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 CheckQrAsync(string key, CancellationToken cancellationToken) + => SendWebAsync( + "https://login-user.kugou.com", + "/v2/get_userinfo_qrcode", + new Dictionary + { + ["plat"] = "4", + ["appid"] = AppId.ToString(CultureInfo.InvariantCulture), + ["srcappid"] = SourceAppId.ToString(CultureInfo.InvariantCulture), + ["qrcode"] = key, + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) + }, + cancellationToken); + + public Task SendAndroidAsync( + HttpMethod method, + string path, + IReadOnlyDictionary? 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 + { + ["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 parameters) + => Md5Hex(WebSalt + string.Concat(parameters.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => $"{pair.Key}={pair.Value}")) + WebSalt); + + internal static string SignatureAndroid(IReadOnlyDictionary 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 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(out var result)) return result; + if (value is JsonValue number && number.TryGetValue(out var integer)) return integer.ToString(CultureInfo.InvariantCulture); + if (value is JsonValue boolean && boolean.TryGetValue(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 Array(JsonNode? node) + => node is JsonArray array ? array.Where(item => item is not null).Cast().ToArray() : []; + + private async Task SendWebAsync( + string baseUrl, + string path, + IReadOnlyDictionary 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 SendAndroidCoreAsync( + HttpMethod method, + string baseUrl, + string path, + IReadOnlyDictionary? 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 SendAndroidRawAsync( + HttpMethod method, + string baseUrl, + string path, + IReadOnlyDictionary? 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 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(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 DefaultParameters(KugouAccountCredential? account = null) + { + var result = new Dictionary(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 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 ParseResponseCookies(IEnumerable values) + { + var result = new Dictionary(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" => "收藏歌单", + _ => "酷狗请求" + }; +} diff --git a/src/YMhut.Box.Core/Music/KugouMusicProvider.cs b/src/YMhut.Box.Core/Music/KugouMusicProvider.cs index f8dc4f9..42675a0 100644 --- a/src/YMhut.Box.Core/Music/KugouMusicProvider.cs +++ b/src/YMhut.Box.Core/Music/KugouMusicProvider.cs @@ -1,43 +1,70 @@ +using System.Globalization; using System.Net.Http.Headers; 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; /// -/// Kugou's public web endpoints are intentionally kept separate from the NetEase client. -/// The adapter accepts only credentials returned by the provider's own sign-in flow and does -/// not synthesize entitlement tokens or media URLs. +/// Direct KuGou provider integration. Account requests use the provider's signed HTTPS +/// gateway and never synthesize entitlements or media addresses. /// public sealed class KugouMusicProvider : IMusicProvider, IDisposable { + private const string DeviceCredentialKey = "kugou-device"; + private const int MaximumAccountItems = 500; private const string WebOrigin = "https://www.kugou.com"; - private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36"; - internal static IReadOnlyList<(string Uri, int Priority)> BrowserCookieSources { get; } = + private const string WebUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36"; + private static readonly JsonSerializerOptions CredentialJsonOptions = new(JsonSerializerDefaults.Web) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = false + }; + private static readonly (MusicPlaybackQuality Quality, string Value, int Bitrate)[] QualityOrder = [ - ("https://loginservice.kugou.com/", 40), - ("https://staticssl.kugou.com/", 30), - ("https://login-user.kugou.com/", 20), - ("https://www.kugou.com/", 10), - ("https://kugou.com/", 0) + (MusicPlaybackQuality.Master, "super", 1000), + (MusicPlaybackQuality.HiRes, "high", 900), + (MusicPlaybackQuality.Lossless, "flac", 800), + (MusicPlaybackQuality.High, "320", 320), + (MusicPlaybackQuality.Standard, "128", 128) ]; + private readonly IMusicCredentialStore _credentials; - private readonly HttpClient _client; + private readonly HttpClient _publicClient; + private readonly KugouApiClient _api; private readonly SemaphoreSlim _initialization = new(1, 1); - private string _cookie = string.Empty; + private readonly SemaphoreSlim _accountCacheGate = new(1, 1); + private readonly Dictionary _playlistData = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _songData = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _favoriteFiles = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _subscriptionLists = new(StringComparer.OrdinalIgnoreCase); + private KugouAccountCredential? _account; + private long _likedPlaylistListId; + private bool _deviceInitialized; + private bool _accountCacheLoaded; + private bool _favoriteCacheLoaded; private bool _initialized; public KugouMusicProvider(IMusicCredentialStore credentials, HttpMessageHandler? handler = null) + : this(credentials, handler, null) + { + } + + internal KugouMusicProvider( + IMusicCredentialStore credentials, + HttpMessageHandler? handler, + Func? playlistKeyFactory) { _credentials = credentials; - _client = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false); - _client.Timeout = TimeSpan.FromSeconds(12); - _client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent); - _client.DefaultRequestHeaders.Referrer = new Uri(WebOrigin + "/"); - _client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", WebOrigin); - _client.DefaultRequestHeaders.TryAddWithoutValidation("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.7"); + _api = new KugouApiClient(handler, playlistKeyFactory); + _publicClient = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false); + _publicClient.Timeout = TimeSpan.FromSeconds(12); + _publicClient.DefaultRequestHeaders.UserAgent.ParseAdd(WebUserAgent); + _publicClient.DefaultRequestHeaders.Referrer = new Uri(WebOrigin + "/"); + _publicClient.DefaultRequestHeaders.TryAddWithoutValidation("Origin", WebOrigin); + _publicClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.7"); } public string Id => "kugou"; @@ -51,22 +78,26 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable try { if (_initialized) return; - var cookie = await _credentials.LoadAsync(Id, cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(cookie)) + await EnsureDeviceCoreAsync(cancellationToken).ConfigureAwait(false); + var secret = await _credentials.LoadAsync(Id, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(secret)) { - var validation = await ValidateCookieAsync(cookie, cancellationToken).ConfigureAwait(false); - if (validation.State.LoggedIn) + var account = ParseStoredAccount(secret, out var migrated); + if (account is null) { - _cookie = validation.Cookie; - LoginState = validation.State; - if (!string.Equals(cookie, validation.Cookie, StringComparison.Ordinal)) - { - await _credentials.SaveAsync(Id, validation.Cookie, cancellationToken).ConfigureAwait(false); - } + await _credentials.ClearAsync(Id, cancellationToken).ConfigureAwait(false); } else { - await _credentials.ClearAsync(Id, cancellationToken).ConfigureAwait(false); + try + { + await ValidateAccountAsync(account, cancellationToken).ConfigureAwait(false); + await CommitAccountAsync(account, migrated, cancellationToken).ConfigureAwait(false); + } + catch (KugouApiException exception) when (exception.AuthenticationFailure) + { + await ClearAccountAsync(cancellationToken).ConfigureAwait(false); + } } } _initialized = true; @@ -79,45 +110,88 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable public async Task LoginWithCookieAsync(string cookie, CancellationToken cancellationToken = default) { - var normalized = NormalizeCookie(cookie); - var validation = await ValidateCookieAsync(normalized, cancellationToken).ConfigureAwait(false); - if (!validation.State.LoggedIn) return validation.State; - _cookie = validation.Cookie; - LoginState = validation.State; - _initialized = true; - await _credentials.SaveAsync(Id, validation.Cookie, cancellationToken).ConfigureAwait(false); - return validation.State; - } - - public async Task CompleteOfficialWebLoginAsync( - string cookie, - Uri completionUri, - CancellationToken cancellationToken = default) - { - if (!IsOfficialWebLoginCompletionUri(completionUri)) return new MusicLoginState(false); - - var normalized = NormalizeCookie(cookie); - var account = ParseAccount(ParseCookie(normalized)); + await EnsureDeviceAsync(cancellationToken).ConfigureAwait(false); + var account = ParseStoredAccount(cookie, out _); if (account is null) return new MusicLoginState(false); - - _cookie = normalized; - LoginState = ToLoginState(account); + try + { + await ValidateAccountAsync(account, cancellationToken).ConfigureAwait(false); + } + catch (KugouApiException exception) when (exception.AuthenticationFailure) + { + return new MusicLoginState(false); + } + await CommitAccountAsync(account, save: true, cancellationToken).ConfigureAwait(false); _initialized = true; - await _credentials.SaveAsync(Id, normalized, cancellationToken).ConfigureAwait(false); return LoginState; } - public Task CreateQrSessionAsync(CancellationToken cancellationToken = default) - => Task.FromException(new InvalidOperationException("酷狗扫码登录通过内嵌的官方网页登录完成。")); + public async Task CreateQrSessionAsync(CancellationToken cancellationToken = default) + { + await EnsureDeviceAsync(cancellationToken).ConfigureAwait(false); + var key = await _api.CreateQrKeyAsync(cancellationToken).ConfigureAwait(false); + return new MusicQrSession( + key, + $"https://h5.kugou.com/apps/loginQRCode/html/index.html?qrcode={Uri.EscapeDataString(key)}", + DateTimeOffset.Now.AddMinutes(3)); + } - public Task CheckQrSessionAsync(MusicQrSession session, CancellationToken cancellationToken = default) - => Task.FromResult(new MusicQrStatus(805, "酷狗扫码登录由官方网页管理。", false, false, true)); + public async Task CheckQrSessionAsync(MusicQrSession session, CancellationToken cancellationToken = default) + { + if (DateTimeOffset.Now >= session.ExpiresAt) return new MusicQrStatus(800, "二维码已过期。", false, true, true); + var result = await _api.CheckQrAsync(session.Key, cancellationToken).ConfigureAwait(false); + var data = result.Json["data"] ?? result.Json; + var status = KugouApiClient.Integer(data, "status"); + switch (status) + { + case 0: + return new MusicQrStatus(800, "二维码已过期。", false, true, true); + case 1: + return new MusicQrStatus(801, "等待扫码。", false, false); + case 2: + return new MusicQrStatus(802, "已扫码,等待手机确认。", false, false); + case 4: + { + var token = KugouApiClient.Text(data, "token") ?? result.Cookies.GetValueOrDefault("token") ?? string.Empty; + var userId = KugouApiClient.Text(data, "userid") ?? result.Cookies.GetValueOrDefault("userid") ?? string.Empty; + if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(userId)) + { + return new MusicQrStatus(805, "手机已确认,但酷狗未返回完整登录令牌。请刷新二维码重试。", false, false, true); + } + var account = new KugouAccountCredential( + 2, + userId, + token, + KugouApiClient.Text(data, "nickname") ?? KugouApiClient.Text(data, "username") ?? "酷狗用户", + NormalizeImage(KugouApiClient.Text(data, "pic") ?? KugouApiClient.Text(data, "avatar") ?? string.Empty), + KugouApiClient.Text(data, "vip_type") ?? "none", + KugouApiClient.Text(data, "vip_token") ?? string.Empty, + KugouApiClient.Text(data, "t1") ?? string.Empty); + try + { + await ValidateAccountAsync(account, cancellationToken).ConfigureAwait(false); + await CommitAccountAsync(account, save: true, cancellationToken).ConfigureAwait(false); + _initialized = true; + return new MusicQrStatus(803, "登录成功。", true, false, true); + } + catch (KugouApiException exception) when (exception.AuthenticationFailure) + { + return new MusicQrStatus(805, "酷狗拒绝了登录令牌,请刷新二维码重试。", false, false, true); + } + } + default: + return new MusicQrStatus(805, + KugouApiClient.Text(data, "msg") ?? KugouApiClient.Text(result.Json, "msg") ?? "酷狗返回了未知扫码状态。", + false, + false, + true); + } + } public async Task LogoutAsync(CancellationToken cancellationToken = default) { - _cookie = string.Empty; - LoginState = new MusicLoginState(false); - await _credentials.ClearAsync(Id, cancellationToken).ConfigureAwait(false); + await ClearAccountAsync(cancellationToken).ConfigureAwait(false); + _initialized = true; } public async Task SearchAsync(string keywords, MusicSearchKind kind, int limit = 30, CancellationToken cancellationToken = default) @@ -126,90 +200,213 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable var uri = "https://songsearch.kugou.com/song_search_v2?keyword=" + Uri.EscapeDataString(keywords.Trim()) + $"&page=1&pagesize={Math.Clamp(limit, 1, 100)}&userid=-1&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0"; - var result = await GetJsonAsync(uri, _cookie, cancellationToken).ConfigureAwait(false); - var songs = Array(result["data"]?["lists"]) + var result = await GetPublicJsonAsync(uri, cancellationToken).ConfigureAwait(false); + var songs = KugouApiClient.Array(result["data"]?["lists"]) .Select(MapSong) .Where(song => !string.IsNullOrWhiteSpace(song.Id)) .ToArray(); return new MusicSearchResult(songs, [], []); } - public Task> GetUserPlaylistsAsync(CancellationToken cancellationToken = default) - => Task.FromException>(new InvalidOperationException("酷狗官方网页当前未提供可验证的用户歌单接口。")); + public async Task> GetUserPlaylistsAsync(CancellationToken cancellationToken = default) + { + var account = await RequireAccountAsync(cancellationToken).ConfigureAwait(false); + try + { + return await LoadAllUserPlaylistsAsync(account, refresh: true, cancellationToken).ConfigureAwait(false); + } + catch (KugouApiException exception) when (exception.AuthenticationFailure) + { + await ExpireAccountAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("酷狗登录已失效,请重新扫码登录。", exception); + } + } - public async Task> GetRecommendedPlaylistsAsync(CancellationToken cancellationToken = default) - => (await GetChartsAsync(cancellationToken).ConfigureAwait(false)).Take(12).ToArray(); + public Task> GetRecommendedPlaylistsAsync(CancellationToken cancellationToken = default) + => RunAccountOperationAsync(() => GetRecommendedPlaylistsCoreAsync(cancellationToken), cancellationToken); + + private async Task> GetRecommendedPlaylistsCoreAsync(CancellationToken cancellationToken) + { + await EnsureDeviceAsync(cancellationToken).ConfigureAwait(false); + if (_account is not null) await EnsureAccountCacheAsync(cancellationToken).ConfigureAwait(false); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + var body = new JsonObject + { + ["appid"] = KugouApiClient.AppId, + ["mid"] = _api.Device.Mid, + ["clientver"] = KugouApiClient.ClientVersion, + ["platform"] = "android", + ["clienttime"] = now, + ["userid"] = long.TryParse(_account?.UserId, out var userId) ? userId : 0, + ["module_id"] = 1, + ["page"] = 1, + ["pagesize"] = 30, + ["key"] = KugouApiClient.SignParamsKey(now), + ["special_recommend"] = new JsonObject + { + ["withtag"] = 1, + ["withsong"] = 1, + ["sort"] = 1, + ["ugc"] = 1, + ["is_selected"] = 0, + ["withrecommend"] = 1, + ["area_code"] = 1, + ["categoryid"] = 0 + }, + ["req_multi"] = 1, + ["retrun_min"] = 5, + ["return_special_falg"] = 1 + }; + var response = await _api.SendAndroidAsync( + HttpMethod.Post, + "/v2/special_recommend", + null, + body, + _account, + "specialrec.service.kugou.com", + cancellationToken).ConfigureAwait(false); + return PlaylistArray(response.Json) + .Select(node => MapPlaylist(node, accountPlaylist: false)) + .Where(item => !string.IsNullOrWhiteSpace(item.Id)) + .Take(30) + .ToArray(); + } public async Task> GetChartsAsync(CancellationToken cancellationToken = default) { - var result = await GetJsonAsync("https://m.kugou.com/rank/list&json=true", _cookie, cancellationToken).ConfigureAwait(false); - return Array(result["rank"]?["list"]) + var result = await GetPublicJsonAsync("https://m.kugou.com/rank/list&json=true", cancellationToken).ConfigureAwait(false); + return KugouApiClient.Array(result["rank"]?["list"]) .Select(node => new MusicPlaylist( Id, - "rank:" + (Text(node, "rankid") ?? string.Empty), - Text(node, "rankname") ?? "酷狗榜单", - NormalizeImage(Text(node, "imgurl") ?? Text(node, "img_9") ?? Text(node, "album_img_9") ?? string.Empty), - Integer(node, "songcount"), + "rank:" + (KugouApiClient.Text(node, "rankid") ?? string.Empty), + KugouApiClient.Text(node, "rankname") ?? "酷狗榜单", + NormalizeImage(KugouApiClient.Text(node, "imgurl") ?? KugouApiClient.Text(node, "img_9") ?? string.Empty), + KugouApiClient.Integer(node, "songcount"), "酷狗音乐")) .Where(playlist => !playlist.Id.EndsWith(':')) .ToArray(); } - public Task> GetDailySongsAsync(CancellationToken cancellationToken = default) - => Task.FromException>(new InvalidOperationException("酷狗官方网页当前未提供可验证的账户每日推荐接口。")); - - public async Task> GetPlaylistTracksAsync(string playlistId, int offset = 0, int count = 100, CancellationToken cancellationToken = default) + public async Task> GetDailySongsAsync(CancellationToken cancellationToken = default) { - if (!playlistId.StartsWith("rank:", StringComparison.OrdinalIgnoreCase)) + var account = await RequireAccountAsync(cancellationToken).ConfigureAwait(false); + try { - throw new InvalidOperationException("当前酷狗网页来源仅支持打开官方榜单。"); - } - - var rankId = playlistId["rank:".Length..]; - var skip = Math.Max(0, offset); - var take = Math.Clamp(count, 1, 200); - var collected = new List(take); - var page = skip / 20 + 1; - var withinPage = skip % 20; - while (collected.Count < take && page <= 10) - { - var result = await GetJsonAsync( - $"https://m.kugou.com/rank/info/?rankid={Uri.EscapeDataString(rankId)}&page={page}&json=true", - _cookie, + var response = await _api.SendAndroidAsync( + HttpMethod.Post, + "/everyday_song_recommend", + null, + new JsonObject + { + ["platform"] = "android", + ["userid"] = long.TryParse(account.UserId, out var userId) ? userId : 0 + }, + account, + "everydayrec.service.kugou.com", cancellationToken).ConfigureAwait(false); - var songs = Array(result["songs"]?["list"]); - if (songs.Count == 0) break; - collected.AddRange(songs.Skip(withinPage).Select(MapSong).Where(song => !string.IsNullOrWhiteSpace(song.Id))); - if (songs.Count < 20) break; - withinPage = 0; - page++; + return SongArray(response.Json).Select(MapSong).Where(song => !string.IsNullOrWhiteSpace(song.Id)).Take(100).ToArray(); } - return collected.Take(take).ToArray(); + catch (KugouApiException exception) when (exception.AuthenticationFailure) + { + await ExpireAccountAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("酷狗登录已失效,请重新扫码登录。", exception); + } + } + + public Task> GetPlaylistTracksAsync(string playlistId, int offset = 0, int count = 100, CancellationToken cancellationToken = default) + => RunAccountOperationAsync(() => GetPlaylistTracksCoreAsync(playlistId, offset, count, cancellationToken), cancellationToken); + + private async Task> GetPlaylistTracksCoreAsync(string playlistId, int offset, int count, CancellationToken cancellationToken) + { + var take = Math.Clamp(count, 1, MaximumAccountItems); + if (playlistId.StartsWith("rank:", StringComparison.OrdinalIgnoreCase)) + { + return await GetRankTracksAsync(playlistId["rank:".Length..], offset, take, cancellationToken).ConfigureAwait(false); + } + if (playlistId.StartsWith("list:", StringComparison.OrdinalIgnoreCase)) + { + var listId = ParseLong(playlistId["list:".Length..], "歌单标识无效。"); + return await LoadAccountPlaylistTracksAsync(listId, offset, take, cancellationToken).ConfigureAwait(false); + } + if (playlistId.StartsWith("collection:", StringComparison.OrdinalIgnoreCase)) + { + var collectionId = playlistId["collection:".Length..]; + return await LoadCollectionTracksAsync(collectionId, offset, take, cancellationToken).ConfigureAwait(false); + } + throw new InvalidOperationException("当前酷狗歌单标识不受支持,请刷新歌单列表后重试。"); } public Task> GetArtistSongsAsync(string artistId, int offset = 0, int count = 50, CancellationToken cancellationToken = default) => Task.FromResult>([]); - public async Task ResolveStreamAsync(string songId, MusicPlaybackQuality quality, CancellationToken cancellationToken = default) + public Task ResolveStreamAsync(string songId, MusicPlaybackQuality quality, CancellationToken cancellationToken = default) + => RunAccountOperationAsync(() => ResolveStreamCoreAsync(songId, quality, cancellationToken), cancellationToken); + + private async Task ResolveStreamCoreAsync(string songId, MusicPlaybackQuality quality, CancellationToken cancellationToken) { + await EnsureDeviceAsync(cancellationToken).ConfigureAwait(false); var identity = SplitSongId(songId); - var uri = "https://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash=" + Uri.EscapeDataString(identity.Hash); - if (!string.IsNullOrWhiteSpace(identity.AlbumId)) + var start = global::System.Array.FindIndex(QualityOrder, item => item.Quality == quality); + if (start < 0) start = QualityOrder.Length - 1; + string? lastReason = null; + for (var index = start; index < QualityOrder.Length; index++) { - uri += "&album_id=" + Uri.EscapeDataString(identity.AlbumId); + var candidate = QualityOrder[index]; + try + { + var response = await _api.SendAndroidAsync( + HttpMethod.Get, + "/v5/url", + new Dictionary + { + ["album_id"] = identity.AlbumId, + ["area_code"] = "1", + ["hash"] = identity.Hash.ToLowerInvariant(), + ["ssa_flag"] = "is_fromtrack", + ["version"] = "11430", + ["page_id"] = "151369488", + ["quality"] = candidate.Value, + ["album_audio_id"] = SongDataFor(songId)?.MixSongId.ToString(CultureInfo.InvariantCulture) ?? "0", + ["behavior"] = "play", + ["pid"] = "2", + ["cmd"] = "26", + ["pidversion"] = "3001", + ["IsFreePart"] = "0", + ["ppage_id"] = "463467626,350369493,788954147", + ["cdnBackup"] = "1", + ["module"] = "", + ["clientver"] = "11430" + }, + null, + _account, + "trackercdn.kugou.com", + cancellationToken, + addTrackKey: true).ConfigureAwait(false); + var data = response.Json["data"] ?? response.Json; + if (data is JsonArray array) data = array.FirstOrDefault(); + var address = KugouApiClient.Text(data, "url") ?? KugouApiClient.Text(data, "play_url") ?? FirstText(data, "backup_url"); + if (Uri.TryCreate(address, UriKind.Absolute, out var streamUri) && streamUri.Scheme == Uri.UriSchemeHttps) + { + var bitrate = KugouApiClient.Integer(data, "bitRate"); + if (bitrate <= 0) bitrate = candidate.Bitrate; + var trial = KugouApiClient.Integer(data, "is_free_part") == 1; + return new MusicStreamResult( + streamUri, + true, + trial, + candidate.Quality, + bitrate * 1000L, + trial ? "提供方仅允许试听片段。" : candidate.Quality == quality ? null : $"已回退到 {candidate.Quality} 音质。"); + } + lastReason = KugouApiClient.Text(data, "error") ?? KugouApiClient.Text(response.Json, "error") ?? "提供方未返回可播放地址。"; + } + catch (KugouApiException exception) when (!exception.AuthenticationFailure) + { + lastReason = exception.Message; + } } - var result = await GetJsonAsync(uri, _cookie, cancellationToken).ConfigureAwait(false); - var data = result["data"] ?? result; - var address = Text(data, "url") ?? Text(data, "play_url") ?? Text(data, "play_url_128") ?? FirstText(data, "backup_url"); - if (!Uri.TryCreate(address, UriKind.Absolute, out var streamUri)) - { - return new MusicStreamResult(null, false, false, MusicPlaybackQuality.Standard, 0, - Text(data, "error") ?? Text(result, "error") ?? "提供方未返回播放地址,歌曲可能需要登录、会员或受地区限制。"); - } - var bitrate = Integer(data, "bitRate"); - if (bitrate <= 0) bitrate = 128; - return new MusicStreamResult(streamUri, true, false, MusicPlaybackQuality.Standard, bitrate * 1000L, - quality == MusicPlaybackQuality.Standard ? null : "当前来源仅返回服务方允许的标准音质。"); + return new MusicStreamResult(null, false, false, quality, 0, + lastReason ?? "提供方未返回播放地址,歌曲可能需要登录、会员或受地区限制。"); } public async Task ProbeStreamAsync(Uri uri, CancellationToken cancellationToken = default) @@ -217,16 +414,22 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable using var request = new HttpRequestMessage(HttpMethod.Get, uri); request.Headers.Range = new RangeHeaderValue(0, 1); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("audio/*")); - if (!string.IsNullOrWhiteSpace(_cookie)) request.Headers.TryAddWithoutValidation("Cookie", _cookie); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream", 0.8)); try { - using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + using var response = await _publicClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; var reachable = response.IsSuccessStatusCode && - (contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(contentType)); + (contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) || + string.Equals(contentType, "application/octet-stream", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(contentType)); return new MusicStreamProbe(reachable, contentType, (int)response.StatusCode, reachable ? null : $"音乐文件服务返回 HTTP {(int)response.StatusCode} 或非音频内容。"); } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new MusicStreamProbe(false, string.Empty, 0, "音乐文件连接超时。"); + } catch (HttpRequestException exception) { return new MusicStreamProbe(false, string.Empty, (int?)exception.StatusCode ?? 0, "无法访问音乐文件。"); @@ -236,19 +439,18 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable public async Task GetLyricsAsync(string songId, CancellationToken cancellationToken = default) { var identity = SplitSongId(songId); - var searchUri = "https://lyrics.kugou.com/search?ver=1&man=yes&client=pc&hash=" + Uri.EscapeDataString(identity.Hash); - var search = await GetJsonAsync(searchUri, _cookie, cancellationToken).ConfigureAwait(false); - var candidate = Array(search["candidates"]).FirstOrDefault(); - var lyricId = Text(candidate, "id"); - var accessKey = Text(candidate, "accesskey"); - if (string.IsNullOrWhiteSpace(lyricId) || string.IsNullOrWhiteSpace(accessKey)) - { - return new TimedLyrics([], string.Empty, null, null); - } - var downloadUri = "https://lyrics.kugou.com/download?ver=1&client=pc&fmt=lrc&charset=utf8&id=" + - Uri.EscapeDataString(lyricId) + "&accesskey=" + Uri.EscapeDataString(accessKey); - var download = await GetJsonAsync(downloadUri, _cookie, cancellationToken).ConfigureAwait(false); - var encoded = Text(download, "content"); + var search = await GetPublicJsonAsync( + "https://lyrics.kugou.com/search?ver=1&man=yes&client=pc&hash=" + Uri.EscapeDataString(identity.Hash), + cancellationToken).ConfigureAwait(false); + var candidate = KugouApiClient.Array(search["candidates"]).FirstOrDefault(); + var lyricId = KugouApiClient.Text(candidate, "id"); + var accessKey = KugouApiClient.Text(candidate, "accesskey"); + if (string.IsNullOrWhiteSpace(lyricId) || string.IsNullOrWhiteSpace(accessKey)) return new TimedLyrics([], string.Empty, null, null); + var download = await GetPublicJsonAsync( + "https://lyrics.kugou.com/download?ver=1&client=pc&fmt=lrc&charset=utf8&id=" + + Uri.EscapeDataString(lyricId) + "&accesskey=" + Uri.EscapeDataString(accessKey), + cancellationToken).ConfigureAwait(false); + var encoded = KugouApiClient.Text(download, "content"); if (string.IsNullOrWhiteSpace(encoded)) return new TimedLyrics([], string.Empty, null, null); try { @@ -263,171 +465,663 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable public async Task GetMvAsync(string songId, string? mvId = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(mvId)) return null; - var result = await GetJsonAsync( + var result = await GetPublicJsonAsync( "https://m.kugou.com/app/i/mv.php?cmd=100&ismp3=1&ext=mp4&hash=" + Uri.EscapeDataString(mvId), - _cookie, cancellationToken).ConfigureAwait(false); var variants = (result["mvdata"] as JsonObject)? .Select(pair => pair.Value) .OfType() - .Select(node => new - { - Bitrate = Integer(node, "bitrate"), - Address = Text(node, "downurl") ?? FirstText(node, "backupdownurl") - }) + .Select(node => new { Bitrate = KugouApiClient.Integer(node, "bitrate"), Address = KugouApiClient.Text(node, "downurl") ?? FirstText(node, "backupdownurl") }) .OrderByDescending(item => item.Bitrate) .ToArray() ?? []; - var address = variants.Select(item => item.Address).FirstOrDefault(value => - Uri.TryCreate(value, UriKind.Absolute, out var candidate) && candidate.Scheme == Uri.UriSchemeHttps); + var address = variants.Select(item => item.Address).FirstOrDefault(value => Uri.TryCreate(value, UriKind.Absolute, out var candidate) && candidate.Scheme == Uri.UriSchemeHttps); var mediaUri = Uri.TryCreate(address, UriKind.Absolute, out var parsed) ? parsed : null; - var hasInsecureVariant = variants.Any(item => - Uri.TryCreate(item.Address, UriKind.Absolute, out var candidate) && candidate.Scheme == Uri.UriSchemeHttp); + var insecure = variants.Any(item => Uri.TryCreate(item.Address, UriKind.Absolute, out var candidate) && candidate.Scheme == Uri.UriSchemeHttp); return new MusicMv( Id, mvId, - Text(result, "songname") ?? "MV", - Text(result, "singer") ?? string.Empty, - NormalizeImage(Text(result, "mvicon") ?? string.Empty), - TimeSpan.FromMilliseconds(Integer(result, "timelength")), + KugouApiClient.Text(result, "songname") ?? "MV", + KugouApiClient.Text(result, "singer") ?? string.Empty, + NormalizeImage(KugouApiClient.Text(result, "mvicon") ?? string.Empty), + TimeSpan.FromMilliseconds(KugouApiClient.Integer(result, "timelength")), mediaUri, mediaUri is not null, - mediaUri is null - ? hasInsecureVariant - ? "服务方当前仅返回非加密 MV 地址,已拒绝加载。" - : Text(result, "error") ?? "提供方未返回可播放的 MV 地址。" - : null); + mediaUri is null ? insecure ? "服务方当前仅返回非加密 MV 地址,已拒绝加载。" : "提供方未返回可播放的 MV 地址。" : null); } - public Task SetFavoriteAsync(string songId, bool favorite, CancellationToken cancellationToken = default) - => Task.FromException(new InvalidOperationException("酷狗收藏功能需要服务方提供的已授权账户接口。")); + public Task GetFavoriteStateAsync(MusicSong song, CancellationToken cancellationToken = default) + => RunAccountOperationAsync(() => GetFavoriteStateCoreAsync(song, cancellationToken), cancellationToken); - public Task SetPlaylistSubscribedAsync(string playlistId, bool subscribed, CancellationToken cancellationToken = default) - => Task.FromException(new InvalidOperationException("酷狗歌单订阅功能需要服务方提供的已授权账户接口。")); + private async Task GetFavoriteStateCoreAsync(MusicSong song, CancellationToken cancellationToken) + { + await RequireAccountAsync(cancellationToken).ConfigureAwait(false); + await EnsureFavoriteCacheAsync(force: false, cancellationToken).ConfigureAwait(false); + return _favoriteFiles.ContainsKey(SplitSongId(song.Id).Hash); + } + + public Task SetFavoriteAsync(MusicSong song, bool favorite, CancellationToken cancellationToken = default) + => RunAccountOperationAsync(() => SetFavoriteCoreAsync(song, favorite, cancellationToken), cancellationToken); + + private async Task SetFavoriteCoreAsync(MusicSong song, bool favorite, CancellationToken cancellationToken) + { + var account = await RequireAccountAsync(cancellationToken).ConfigureAwait(false); + await EnsureFavoriteCacheAsync(force: !favorite, cancellationToken).ConfigureAwait(false); + if (_likedPlaylistListId <= 0) throw new InvalidOperationException("未找到酷狗账户的“我喜欢”歌单,已停止收藏操作。"); + var identity = SplitSongId(song.Id); + if (favorite) + { + if (_favoriteFiles.ContainsKey(identity.Hash)) return; + var metadata = SongDataFor(song.Id); + var body = new JsonObject + { + ["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"), + ["token"] = account.Token, + ["listid"] = _likedPlaylistListId, + ["list_ver"] = 0, + ["type"] = 0, + ["slow_upload"] = 1, + ["scene"] = "false;null", + ["data"] = new JsonArray + { + new JsonObject + { + ["number"] = 1, + ["name"] = song.Name, + ["hash"] = identity.Hash, + ["size"] = 0, + ["sort"] = 0, + ["timelen"] = (long)song.Duration.TotalMilliseconds, + ["bitrate"] = 0, + ["album_id"] = ParseLong(identity.AlbumId, defaultValue: 0), + ["mixsongid"] = metadata?.MixSongId ?? 0 + } + } + }; + await _api.SendAndroidAsync( + HttpMethod.Post, + "/cloudlist.service/v6/add_song", + new Dictionary + { + ["last_time"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), + ["last_area"] = "gztx" + }, + body, + account, + null, + cancellationToken).ConfigureAwait(false); + } + else + { + if (!_favoriteFiles.TryGetValue(identity.Hash, out var fileId)) return; + await _api.SendAndroidAsync( + HttpMethod.Post, + "/v4/delete_songs", + null, + new JsonObject + { + ["listid"] = _likedPlaylistListId, + ["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"), + ["data"] = new JsonArray { new JsonObject { ["fileid"] = fileId } }, + ["type"] = 0, + ["token"] = account.Token, + ["list_ver"] = 0 + }, + account, + "cloudlist.service.kugou.com", + cancellationToken).ConfigureAwait(false); + } + await EnsureFavoriteCacheAsync(force: true, cancellationToken).ConfigureAwait(false); + } + + public Task SetPlaylistSubscribedAsync(MusicPlaylist playlist, bool subscribed, CancellationToken cancellationToken = default) + => RunAccountOperationAsync(() => SetPlaylistSubscribedCoreAsync(playlist, subscribed, cancellationToken), cancellationToken); + + private async Task SetPlaylistSubscribedCoreAsync(MusicPlaylist playlist, bool subscribed, CancellationToken cancellationToken) + { + var account = await RequireAccountAsync(cancellationToken).ConfigureAwait(false); + if (!playlist.CanSubscribe || playlist.ProviderData is not KugouPlaylistData metadata || metadata.Owned) + { + throw new InvalidOperationException("该歌单不是可收藏的外部酷狗歌单。"); + } + await EnsureAccountCacheAsync(cancellationToken).ConfigureAwait(false); + var sourceKey = SubscriptionKey(metadata); + if (subscribed) + { + if (_subscriptionLists.ContainsKey(sourceKey)) return; + var response = await _api.SendAndroidAsync( + HttpMethod.Post, + "/cloudlist.service/v5/add_list", + null, + new JsonObject + { + ["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"), + ["token"] = account.Token, + ["total_ver"] = 0, + ["name"] = playlist.Name, + ["type"] = 1, + ["source"] = 1, + ["is_pri"] = 0, + ["list_create_userid"] = metadata.OwnerUserId, + ["list_create_listid"] = metadata.SourceListId, + ["list_create_gid"] = metadata.CollectionId, + ["from_shupinmv"] = 0 + }, + account, + null, + cancellationToken).ConfigureAwait(false); + var createdListId = KugouApiClient.Long(response.Json["data"], "listid"); + if (createdListId <= 0) createdListId = KugouApiClient.Long(response.Json, "listid"); + if (createdListId <= 0) throw new InvalidOperationException("酷狗已接受收藏请求,但未返回账户歌单标识。请刷新“我的歌单”确认结果。"); + try + { + var songs = await GetPlaylistTracksAsync(playlist.Id, 0, MaximumAccountItems, cancellationToken).ConfigureAwait(false); + foreach (var batch in songs.Chunk(50)) + { + await AddSongsToAccountPlaylistAsync(account, createdListId, batch, cancellationToken).ConfigureAwait(false); + } + } + catch + { + try { await _api.DeleteCollectedPlaylistAsync(account, createdListId, cancellationToken).ConfigureAwait(false); } catch { } + throw; + } + } + else + { + if (!_subscriptionLists.TryGetValue(sourceKey, out var localListId) || localListId <= 0) + { + throw new InvalidOperationException("找不到该歌单在当前账户中的收藏副本,请刷新“我的歌单”后重试。"); + } + if (localListId == _likedPlaylistListId) throw new InvalidOperationException("不能通过取消收藏操作删除“我喜欢”歌单。"); + await _api.DeleteCollectedPlaylistAsync(account, localListId, cancellationToken).ConfigureAwait(false); + } + InvalidateAccountCaches(); + await EnsureAccountCacheAsync(cancellationToken).ConfigureAwait(false); + } public void Dispose() { - _client.Dispose(); + _publicClient.Dispose(); + _api.Dispose(); _initialization.Dispose(); + _accountCacheGate.Dispose(); } - private async Task ValidateCookieAsync(string cookie, CancellationToken cancellationToken) + private async Task EnsureDeviceAsync(CancellationToken cancellationToken) { - var normalized = NormalizeCookie(cookie); - var parts = ParseCookie(normalized); - var account = ParseAccount(parts); - if (account is null) return new CookieValidationResult(new MusicLoginState(false), string.Empty); - - var uri = "https://login-user.kugou.com/v1/autologin?a_id=" + Uri.EscapeDataString(account.AppId) + - "&userid=" + Uri.EscapeDataString(account.UserId) + - "&t=" + Uri.EscapeDataString(account.Token) + - "&ct=" + Uri.EscapeDataString(account.ClientTime) + - "&domain=kugou.com" + - "&plat=4" + - "&dfid=" + Uri.EscapeDataString(parts.GetValueOrDefault("kg_dfid") ?? "-"); - using var request = CreateRequest(HttpMethod.Get, uri, normalized); - request.Headers.Accept.Clear(); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/javascript")); - using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) return new CookieValidationResult(new MusicLoginState(false), string.Empty); - var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - var codeMatch = Regex.Match(payload, "[\\\"']?error_code[\\\"']?\\s*[:=]\\s*[\\\"']?(?\\d+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - if (!codeMatch.Success || !int.TryParse(codeMatch.Groups["code"].Value, out var errorCode) || errorCode != 0) + if (_deviceInitialized) return; + await _initialization.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - return new CookieValidationResult(new MusicLoginState(false), string.Empty); + if (!_deviceInitialized) await EnsureDeviceCoreAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _initialization.Release(); } - - var responseCookies = response.Headers.TryGetValues("Set-Cookie", out var values) - ? NeteaseMusicProvider.ExtractResponseCookies(values) - : string.Empty; - var refreshedCookie = NormalizeCookie(MergeCookies(normalized, responseCookies)); - var refreshedAccount = ParseAccount(ParseCookie(refreshedCookie)) ?? account; - return new CookieValidationResult( - ToLoginState(refreshedAccount), - refreshedCookie); } - private async Task GetJsonAsync(string uri, string cookie, CancellationToken cancellationToken) - => (await GetJsonWithCookiesAsync(uri, cookie, cancellationToken).ConfigureAwait(false)).Json; - - private async Task GetJsonWithCookiesAsync(string uri, string cookie, CancellationToken cancellationToken) + private async Task EnsureDeviceCoreAsync(CancellationToken cancellationToken) { - using var request = CreateRequest(HttpMethod.Get, uri, cookie); - 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 HttpRequestException($"酷狗音乐服务返回 HTTP {(int)response.StatusCode}。", null, response.StatusCode); - var json = JsonNode.Parse(text) as JsonObject ?? throw new JsonException("酷狗音乐服务返回了无效 JSON。"); - var cookieHeaders = response.Headers.TryGetValues("Set-Cookie", out var values) ? values : []; - return new JsonResponse(json, NeteaseMusicProvider.ExtractResponseCookies(cookieHeaders)); + if (_deviceInitialized) return; + var stored = await _credentials.LoadAsync(DeviceCredentialKey, cancellationToken).ConfigureAwait(false); + KugouDeviceCredential? device = null; + if (!string.IsNullOrWhiteSpace(stored)) + { + try { device = JsonSerializer.Deserialize(stored, CredentialJsonOptions); } catch (JsonException) { } + } + device ??= KugouApiClient.CreateDeviceCredential(); + _api.Device = device; + await _credentials.SaveAsync(DeviceCredentialKey, JsonSerializer.Serialize(device, CredentialJsonOptions), cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(device.Dfid) || device.Dfid == "-") + { + try + { + device = await _api.RegisterDeviceAsync(cancellationToken).ConfigureAwait(false); + await _credentials.SaveAsync(DeviceCredentialKey, JsonSerializer.Serialize(device, CredentialJsonOptions), cancellationToken).ConfigureAwait(false); + } + catch (KugouApiException) + { + _api.Device = device; + } + } + _deviceInitialized = true; } - private HttpRequestMessage CreateRequest(HttpMethod method, string uri, string cookie) + private async Task RequireAccountAsync(CancellationToken cancellationToken) { - var request = new HttpRequestMessage(method, uri); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - if (!string.IsNullOrWhiteSpace(cookie)) request.Headers.TryAddWithoutValidation("Cookie", cookie); - return request; + await InitializeAsync(cancellationToken).ConfigureAwait(false); + return _account ?? throw new InvalidOperationException("请先登录酷狗音乐账户。"); } - private static MusicSong MapSong(JsonNode node) + private async Task RunAccountOperationAsync(Func> operation, CancellationToken cancellationToken) { - var hash = Text(node, "FileHash") ?? Text(node, "hash") ?? string.Empty; - var albumId = Text(node, "AlbumID") ?? Text(node, "album_id") ?? Text(node, "albumid"); - var artist = Text(node, "SingerName") ?? Text(node, "singername") ?? Text(node, "h5_author_name") ?? Text(node, "author") ?? string.Empty; - var artistId = Text(node, "SingerId") ?? Text(node, "singerid") ?? Text(Array(node?["authors"]).FirstOrDefault(), "author_id") ?? artist; - var image = Text(node, "Image") ?? Text(node?["trans_param"], "union_cover") ?? Text(node, "imgurl") ?? string.Empty; - image = NormalizeImage(image); - var duration = Integer(node, "Duration"); - if (duration <= 0) duration = Integer(node, "duration"); - return new MusicSong( - "kugou", + try + { + return await operation().ConfigureAwait(false); + } + catch (KugouApiException exception) when (exception.AuthenticationFailure) + { + await ExpireAccountAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("酷狗登录已失效,请重新扫码登录。", exception); + } + } + + private async Task RunAccountOperationAsync(Func operation, CancellationToken cancellationToken) + { + try + { + await operation().ConfigureAwait(false); + } + catch (KugouApiException exception) when (exception.AuthenticationFailure) + { + await ExpireAccountAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("酷狗登录已失效,请重新扫码登录。", exception); + } + } + + private async Task ValidateAccountAsync(KugouAccountCredential account, CancellationToken cancellationToken) + => _ = await LoadUserPlaylistPageAsync(account, 1, 1, cancellationToken).ConfigureAwait(false); + + private async Task CommitAccountAsync(KugouAccountCredential account, bool save, CancellationToken cancellationToken) + { + _account = account with { Version = 2 }; + LoginState = ToLoginState(_account); + InvalidateAccountCaches(); + if (save) + { + await _credentials.SaveAsync(Id, JsonSerializer.Serialize(_account, CredentialJsonOptions), cancellationToken).ConfigureAwait(false); + } + } + + private async Task ClearAccountAsync(CancellationToken cancellationToken) + { + _account = null; + LoginState = new MusicLoginState(false); + InvalidateAccountCaches(); + await _credentials.ClearAsync(Id, cancellationToken).ConfigureAwait(false); + } + + private async Task ExpireAccountAsync(CancellationToken cancellationToken) + { + await ClearAccountAsync(cancellationToken).ConfigureAwait(false); + _initialized = true; + } + + private void InvalidateAccountCaches() + { + _accountCacheLoaded = false; + _favoriteCacheLoaded = false; + _likedPlaylistListId = 0; + _favoriteFiles.Clear(); + _subscriptionLists.Clear(); + } + + private async Task EnsureAccountCacheAsync(CancellationToken cancellationToken) + { + if (_accountCacheLoaded || _account is null) return; + await _accountCacheGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!_accountCacheLoaded) + { + _ = await LoadAllUserPlaylistsAsync(_account, refresh: true, cancellationToken).ConfigureAwait(false); + } + } + finally + { + _accountCacheGate.Release(); + } + } + + private async Task EnsureFavoriteCacheAsync(bool force, CancellationToken cancellationToken) + { + if (_favoriteCacheLoaded && !force) return; + await EnsureAccountCacheAsync(cancellationToken).ConfigureAwait(false); + if (_likedPlaylistListId <= 0) + { + _favoriteCacheLoaded = true; + return; + } + _favoriteFiles.Clear(); + var songs = await LoadAccountPlaylistTracksAsync(_likedPlaylistListId, 0, MaximumAccountItems, cancellationToken).ConfigureAwait(false); + foreach (var song in songs) + { + if (song.ProviderData is KugouSongData { FileId: > 0 } metadata) + { + _favoriteFiles[metadata.Hash] = metadata.FileId; + } + } + _favoriteCacheLoaded = true; + } + + private async Task> LoadAllUserPlaylistsAsync( + KugouAccountCredential account, + bool refresh, + CancellationToken cancellationToken) + { + if (refresh) + { + _likedPlaylistListId = 0; + _subscriptionLists.Clear(); + } + var collected = new List(); + const int pageSize = 100; + for (var page = 1; collected.Count < MaximumAccountItems; page++) + { + var nodes = await LoadUserPlaylistPageAsync(account, page, pageSize, cancellationToken).ConfigureAwait(false); + if (nodes.Count == 0) break; + collected.AddRange(nodes.Select(node => MapPlaylist(node, accountPlaylist: true))); + if (nodes.Count < pageSize) break; + } + _accountCacheLoaded = true; + _favoriteCacheLoaded = false; + return collected.Take(MaximumAccountItems).ToArray(); + } + + private async Task> LoadUserPlaylistPageAsync( + KugouAccountCredential account, + int page, + int pageSize, + CancellationToken cancellationToken) + { + var response = await _api.SendAndroidAsync( + HttpMethod.Post, + "/v7/get_all_list", + new Dictionary { ["plat"] = "1" }, + new JsonObject + { + ["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"), + ["token"] = account.Token, + ["total_ver"] = 979, + ["type"] = 2, + ["page"] = page, + ["pagesize"] = pageSize + }, + account, + "cloudlist.service.kugou.com", + cancellationToken).ConfigureAwait(false); + return PlaylistArray(response.Json); + } + + private async Task> LoadAccountPlaylistTracksAsync( + long listId, + int offset, + int count, + CancellationToken cancellationToken) + { + var account = await RequireAccountAsync(cancellationToken).ConfigureAwait(false); + var pageSize = Math.Min(100, Math.Max(20, count)); + var skip = Math.Max(0, offset); + var page = skip / pageSize + 1; + var withinPage = skip % pageSize; + var collected = new List(count); + while (collected.Count < count && collected.Count + skip < MaximumAccountItems) + { + var response = await _api.SendAndroidAsync( + HttpMethod.Post, + "/v4/get_list_all_file", + null, + new JsonObject + { + ["listid"] = listId, + ["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"), + ["area_code"] = 1, + ["show_relate_goods"] = 0, + ["pagesize"] = pageSize, + ["allplatform"] = 1, + ["show_cover"] = 1, + ["type"] = 0, + ["token"] = account.Token, + ["page"] = page + }, + account, + "cloudlist.service.kugou.com", + cancellationToken).ConfigureAwait(false); + var nodes = SongArray(response.Json); + if (nodes.Count == 0) break; + collected.AddRange(nodes.Skip(withinPage).Select(MapSong)); + if (nodes.Count < pageSize) break; + withinPage = 0; + page++; + } + return collected.Take(count).ToArray(); + } + + private async Task> LoadCollectionTracksAsync( + string collectionId, + int offset, + int count, + CancellationToken cancellationToken) + { + await EnsureDeviceAsync(cancellationToken).ConfigureAwait(false); + var collected = new List(count); + var begin = Math.Max(0, offset); + while (collected.Count < count && begin < MaximumAccountItems) + { + var pageSize = Math.Min(100, count - collected.Count); + var response = await _api.SendAndroidAsync( + HttpMethod.Get, + "/pubsongs/v2/get_other_list_file_nofilt", + new Dictionary + { + ["area_code"] = "1", + ["begin_idx"] = begin.ToString(CultureInfo.InvariantCulture), + ["plat"] = "1", + ["type"] = "1", + ["mode"] = "1", + ["personal_switch"] = "1", + ["extend_fields"] = "abtags,hot_cmt,popularization", + ["pagesize"] = pageSize.ToString(CultureInfo.InvariantCulture), + ["global_collection_id"] = collectionId + }, + null, + _account, + null, + cancellationToken).ConfigureAwait(false); + var nodes = SongArray(response.Json); + if (nodes.Count == 0) break; + collected.AddRange(nodes.Select(MapSong)); + if (nodes.Count < pageSize) break; + begin += nodes.Count; + } + return collected.Take(count).ToArray(); + } + + private async Task> GetRankTracksAsync( + string rankId, + int offset, + int count, + CancellationToken cancellationToken) + { + var collected = new List(count); + var page = Math.Max(0, offset) / 20 + 1; + var withinPage = Math.Max(0, offset) % 20; + while (collected.Count < count && page <= 25) + { + var result = await GetPublicJsonAsync( + $"https://m.kugou.com/rank/info/?rankid={Uri.EscapeDataString(rankId)}&page={page}&json=true", + cancellationToken).ConfigureAwait(false); + var songs = KugouApiClient.Array(result["songs"]?["list"]); + if (songs.Count == 0) break; + collected.AddRange(songs.Skip(withinPage).Select(MapSong)); + if (songs.Count < 20) break; + withinPage = 0; + page++; + } + return collected.Take(count).ToArray(); + } + + private async Task AddSongsToAccountPlaylistAsync( + KugouAccountCredential account, + long listId, + IReadOnlyCollection songs, + CancellationToken cancellationToken) + { + var resources = new JsonArray(); + foreach (var song in songs) + { + var identity = SplitSongId(song.Id); + var metadata = SongDataFor(song.Id); + resources.Add(new JsonObject + { + ["number"] = 1, + ["name"] = song.Name, + ["hash"] = identity.Hash, + ["size"] = 0, + ["sort"] = 0, + ["timelen"] = (long)song.Duration.TotalMilliseconds, + ["bitrate"] = 0, + ["album_id"] = ParseLong(identity.AlbumId, defaultValue: 0), + ["mixsongid"] = metadata?.MixSongId ?? 0 + }); + } + await _api.SendAndroidAsync( + HttpMethod.Post, + "/cloudlist.service/v6/add_song", + new Dictionary + { + ["last_time"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), + ["last_area"] = "gztx" + }, + new JsonObject + { + ["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"), + ["token"] = account.Token, + ["listid"] = listId, + ["list_ver"] = 0, + ["type"] = 0, + ["slow_upload"] = 1, + ["scene"] = "false;null", + ["data"] = resources + }, + account, + null, + cancellationToken).ConfigureAwait(false); + } + + private MusicPlaylist MapPlaylist(JsonNode node, bool accountPlaylist) + { + var name = KugouApiClient.Text(node, "name") ?? KugouApiClient.Text(node, "specialname") ?? "酷狗歌单"; + var localListId = FirstLong(node, "listid", "list_id"); + var sourceListId = FirstLong(node, "list_create_listid", "specialid", "source_listid"); + var ownerId = FirstLong(node, "list_create_userid", "userid", "author_id"); + var collectionId = KugouApiClient.Text(node, "list_create_gid") ?? KugouApiClient.Text(node, "global_collection_id") ?? string.Empty; + var confirmedExternalCopy = accountPlaylist && + _account is not null && + ownerId > 0 && + ownerId.ToString(CultureInfo.InvariantCulture) != _account.UserId && + (sourceListId > 0 || !string.IsNullOrWhiteSpace(collectionId)); + var owned = accountPlaylist && !confirmedExternalCopy; + var subscribed = confirmedExternalCopy; + var id = !string.IsNullOrWhiteSpace(collectionId) + ? "collection:" + collectionId + : localListId > 0 + ? "list:" + localListId.ToString(CultureInfo.InvariantCulture) + : string.Empty; + var metadata = new KugouPlaylistData(collectionId, localListId, sourceListId, ownerId, owned); + if (accountPlaylist) + { + if (IsLikedPlaylist(node, name, owned)) _likedPlaylistListId = localListId; + if (subscribed && localListId > 0) _subscriptionLists[SubscriptionKey(metadata)] = localListId; + } + else if (_subscriptionLists.TryGetValue(SubscriptionKey(metadata), out _)) + { + subscribed = true; + } + var playlist = new MusicPlaylist( + Id, + id, + name, + NormalizeImage(KugouApiClient.Text(node, "pic") ?? KugouApiClient.Text(node, "imgurl") ?? KugouApiClient.Text(node, "image") ?? string.Empty), + Math.Max(KugouApiClient.Integer(node, "count"), KugouApiClient.Integer(node, "song_count")), + KugouApiClient.Text(node, "list_create_username") ?? KugouApiClient.Text(node, "author_name") ?? (owned ? LoginState.Nickname : "酷狗音乐"), + subscribed) + { + CanSubscribe = !owned && (localListId > 0 || sourceListId > 0 || !string.IsNullOrWhiteSpace(collectionId)), + ProviderData = metadata + }; + if (!string.IsNullOrWhiteSpace(id)) _playlistData[id] = metadata; + return playlist; + } + + private MusicSong MapSong(JsonNode node) + { + var hash = KugouApiClient.Text(node, "FileHash") ?? KugouApiClient.Text(node, "hash") ?? KugouApiClient.Text(node, "file_hash") ?? string.Empty; + var albumId = KugouApiClient.Text(node, "AlbumID") ?? KugouApiClient.Text(node, "album_id") ?? KugouApiClient.Text(node, "albumid") ?? "0"; + var artist = KugouApiClient.Text(node, "SingerName") ?? KugouApiClient.Text(node, "singername") ?? KugouApiClient.Text(node, "h5_author_name") ?? KugouApiClient.Text(node, "author_name") ?? KugouApiClient.Text(node, "author") ?? string.Empty; + var artistId = KugouApiClient.Text(node, "SingerId") ?? KugouApiClient.Text(node, "singerid") ?? KugouApiClient.Text(KugouApiClient.Array(node["authors"]).FirstOrDefault(), "author_id") ?? artist; + var duration = FirstLong(node, "Duration", "duration", "timelen", "time_length"); + var durationValue = duration > 10_000 ? TimeSpan.FromMilliseconds(duration) : TimeSpan.FromSeconds(Math.Max(0, duration)); + var metadata = new KugouSongData( + hash, + ParseLong(albumId, defaultValue: 0), + FirstLong(node, "mixsongid", "album_audio_id", "audio_id"), + FirstLong(node, "fileid", "file_id")); + var song = new MusicSong( + Id, ComposeSongId(hash, albumId), - Text(node, "SongName") ?? Text(node, "songname") ?? Text(node, "FileName") ?? Text(node, "filename") ?? string.Empty, + KugouApiClient.Text(node, "SongName") ?? KugouApiClient.Text(node, "songname") ?? KugouApiClient.Text(node, "FileName") ?? KugouApiClient.Text(node, "filename") ?? KugouApiClient.Text(node, "name") ?? string.Empty, artist, string.IsNullOrWhiteSpace(artist) ? [] : [new MusicArtist(artistId, artist)], - Text(node, "AlbumName") ?? Text(node, "album_name") ?? Text(node, "remark") ?? string.Empty, - image.Replace("{size}", "480", StringComparison.Ordinal), - TimeSpan.FromSeconds(Math.Max(0, duration)), - Math.Max(Integer(node, "PayType"), Integer(node, "pay_type"))) + KugouApiClient.Text(node, "AlbumName") ?? KugouApiClient.Text(node, "album_name") ?? KugouApiClient.Text(node, "remark") ?? string.Empty, + NormalizeImage(KugouApiClient.Text(node, "Image") ?? KugouApiClient.Text(node?["trans_param"], "union_cover") ?? KugouApiClient.Text(node, "imgurl") ?? KugouApiClient.Text(node, "sizable_cover") ?? string.Empty), + durationValue, + Math.Max(KugouApiClient.Integer(node, "PayType"), KugouApiClient.Integer(node, "pay_type"))) { - MvId = Text(node, "MvHash") ?? Text(node, "mvhash") ?? Text(Array(node?["mvdata"]).FirstOrDefault(), "hash") + MvId = KugouApiClient.Text(node, "MvHash") ?? KugouApiClient.Text(node, "mvhash") ?? KugouApiClient.Text(KugouApiClient.Array(node?["mvdata"]).FirstOrDefault(), "hash"), + ProviderData = metadata }; + if (!string.IsNullOrWhiteSpace(song.Id)) _songData[song.Id] = metadata; + return song; } - private static IReadOnlyList Array(JsonNode? node) - => node is JsonArray array ? array.Where(item => item is not null).Cast().ToArray() : []; + private KugouSongData? SongDataFor(string songId) + => _songData.TryGetValue(songId, out var value) + ? value + : new KugouSongData(SplitSongId(songId).Hash, ParseLong(SplitSongId(songId).AlbumId, defaultValue: 0), 0, 0); - private static string? FirstText(JsonNode? node, string property) - => Array(node?[property]).Select(item => item.GetValue()).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); - - private static string ComposeSongId(string hash, string? albumId) - => string.IsNullOrWhiteSpace(albumId) ? hash : $"{hash}|{albumId}"; - - private static string NormalizeImage(string value) - => value - .Replace("http://imge.kugou.com", "https://imge.kugou.com", StringComparison.OrdinalIgnoreCase) - .Replace("http://imgessl.kugou.com", "https://imgessl.kugou.com", StringComparison.OrdinalIgnoreCase) - .Replace("{size}", "480", StringComparison.Ordinal); - - private static (string Hash, string AlbumId) SplitSongId(string songId) + private async Task GetPublicJsonAsync(string uri, CancellationToken cancellationToken) { - var separator = songId.IndexOf('|'); - return separator < 0 - ? (songId, string.Empty) - : (songId[..separator], songId[(separator + 1)..]); + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + using var response = await _publicClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); + var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) throw new HttpRequestException($"酷狗音乐服务返回 HTTP {(int)response.StatusCode}。", null, response.StatusCode); + return JsonNode.Parse(text) as JsonObject ?? throw new JsonException("酷狗音乐服务返回了无效 JSON。"); } - private static string? Text(JsonNode? node, string property) + private static KugouAccountCredential? ParseStoredAccount(string secret, out bool migrated) { - var value = node?[property]; - if (value is null) return null; - if (value is JsonValue text && text.TryGetValue(out var result)) return result; - if (value is JsonValue number && number.TryGetValue(out var numeric)) return numeric.ToString(); - return value.ToJsonString().Trim('"'); + migrated = false; + try + { + var account = JsonSerializer.Deserialize(secret, CredentialJsonOptions); + if (account is not null && !string.IsNullOrWhiteSpace(account.UserId) && !string.IsNullOrWhiteSpace(account.Token)) return account; + } + catch (JsonException) + { + } + var cookies = ParseCookie(secret); + var composite = cookies.GetValueOrDefault("KuGoo"); + var fields = string.IsNullOrWhiteSpace(composite) + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : ParseAmpersandFields(global::System.Net.WebUtility.UrlDecode(composite)); + var userId = fields.GetValueOrDefault("KugooID") ?? cookies.GetValueOrDefault("KugooID") ?? cookies.GetValueOrDefault("userid") ?? string.Empty; + var token = fields.GetValueOrDefault("t") ?? cookies.GetValueOrDefault("t") ?? cookies.GetValueOrDefault("token") ?? string.Empty; + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(token)) return null; + migrated = true; + return new KugouAccountCredential( + 2, + userId, + token, + fields.GetValueOrDefault("NickName") ?? fields.GetValueOrDefault("UserName") ?? "酷狗用户", + NormalizeImage(fields.GetValueOrDefault("Pic") ?? string.Empty), + fields.GetValueOrDefault("VipType") ?? fields.GetValueOrDefault("vip_type") ?? "none", + cookies.GetValueOrDefault("vip_token") ?? string.Empty, + cookies.GetValueOrDefault("t1") ?? string.Empty); } - private static int Integer(JsonNode? node, string property) - => int.TryParse(Text(node, property), out var value) ? value : 0; - private static Dictionary ParseCookie(string cookie) => cookie.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(part => part.Split('=', 2, StringSplitOptions.TrimEntries)) @@ -435,88 +1129,65 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable .GroupBy(pair => pair[0], StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.Last()[1], StringComparer.OrdinalIgnoreCase); - private static KugouAccount? ParseAccount(IReadOnlyDictionary cookies) - { - var composite = cookies.GetValueOrDefault("KuGoo"); - if (string.IsNullOrWhiteSpace(composite)) return null; - var decoded = global::System.Net.WebUtility.UrlDecode(composite); - var fields = decoded - .Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + private static Dictionary ParseAmpersandFields(string value) + => value.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(part => part.Split('=', 2, StringSplitOptions.TrimEntries)) .Where(pair => pair.Length == 2 && !string.IsNullOrWhiteSpace(pair[0])) .GroupBy(pair => pair[0], StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.Last()[1], StringComparer.OrdinalIgnoreCase); - var appId = fields.GetValueOrDefault("a_id") ?? cookies.GetValueOrDefault("a_id") ?? "1014"; - var userId = fields.GetValueOrDefault("KugooID") ?? cookies.GetValueOrDefault("KugooID") ?? string.Empty; - var token = fields.GetValueOrDefault("t") ?? cookies.GetValueOrDefault("t") ?? string.Empty; - var clientTime = fields.GetValueOrDefault("ct") ?? cookies.GetValueOrDefault("ct") ?? string.Empty; - if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(userId) || - string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(clientTime)) return null; - var nickname = fields.GetValueOrDefault("NickName") ?? fields.GetValueOrDefault("UserName") ?? "酷狗用户"; - var avatar = fields.GetValueOrDefault("Pic") ?? string.Empty; - var vip = fields.GetValueOrDefault("VipType") ?? fields.GetValueOrDefault("vip_type") ?? "none"; - return new KugouAccount(appId, userId, token, clientTime, nickname, avatar, vip); - } - private static string NormalizeCookie(string cookie) - => string.Join("; ", ParseCookie(cookie).OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase).Select(pair => $"{pair.Key}={pair.Value}")); + private static IReadOnlyList PlaylistArray(JsonObject root) + => FirstArray(root["data"]?["info"], root["data"]?["special_list"], root["data"]?["list"], root["info"], root["list"]); - internal static string MergeBrowserCookies(IEnumerable<(string Name, string Value)> cookies) - => MergeBrowserCookies(cookies.Select(cookie => (cookie.Name, cookie.Value, Priority: 0))); + private static IReadOnlyList SongArray(JsonObject root) + => FirstArray(root["data"]?["song_list"], root["data"]?["songs"], root["data"]?["info"], root["data"]?["list"], root["songs"]?["list"], root["info"], root["list"]); - internal static string MergeBrowserCookies(IEnumerable<(string Name, string Value, int Priority)> cookies) + private static IReadOnlyList FirstArray(params JsonNode?[] candidates) + => candidates.Select(KugouApiClient.Array).FirstOrDefault(items => items.Count > 0) ?? []; + + private static string? FirstText(JsonNode? node, string property) + => KugouApiClient.Array(node?[property]).Select(item => item.GetValue()).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + + private static long FirstLong(JsonNode node, params string[] names) + => names.Select(name => KugouApiClient.Long(node, name)).FirstOrDefault(value => value != 0); + + private static string NormalizeImage(string value) + => value + .Replace("http://imge.kugou.com", "https://imge.kugou.com", StringComparison.OrdinalIgnoreCase) + .Replace("http://imgessl.kugou.com", "https://imgessl.kugou.com", StringComparison.OrdinalIgnoreCase) + .Replace("{size}", "480", StringComparison.Ordinal); + + private static string ComposeSongId(string hash, string? albumId) + => string.IsNullOrWhiteSpace(albumId) || albumId == "0" ? hash : $"{hash}|{albumId}"; + + private static (string Hash, string AlbumId) SplitSongId(string songId) { - var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (name, value, priority) in cookies) - { - if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) continue; - var complete = string.Equals(name, "KuGoo", StringComparison.OrdinalIgnoreCase) && - ParseAccount(new Dictionary(StringComparer.OrdinalIgnoreCase) { [name] = value }) is not null; - if (merged.TryGetValue(name, out var existing) && - (existing.Priority > priority || - existing.Priority == priority && existing.Complete && !complete)) - { - continue; - } - merged[name] = (value, priority, complete); - } - return string.Join("; ", merged - .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) - .Select(pair => $"{pair.Key}={pair.Value.Value}")); + var separator = songId.IndexOf('|'); + return separator < 0 ? (songId, "0") : (songId[..separator], songId[(separator + 1)..]); } - internal static bool HasWebLoginCredential(string cookie) - => ParseAccount(ParseCookie(cookie)) is not null; + private static long ParseLong(string value, string error) + => long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) && result > 0 + ? result + : throw new InvalidOperationException(error); - internal static bool IsOfficialWebLoginCompletionUri(Uri? uri) - => uri is not null && - uri.Scheme == Uri.UriSchemeHttps && - string.Equals(uri.Host, "staticssl.kugou.com", StringComparison.OrdinalIgnoreCase) && - string.Equals(uri.AbsolutePath.TrimEnd('/'), "/common/html/login/regok.html", StringComparison.OrdinalIgnoreCase); + private static long ParseLong(string value, long defaultValue) + => long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : defaultValue; - private static MusicLoginState ToLoginState(KugouAccount account) + private static bool IsLikedPlaylist(JsonNode node, string name, bool owned) + => owned && (KugouApiClient.Integer(node, "is_like") == 1 || + KugouApiClient.Integer(node, "is_default") == 1 || + string.Equals(name.Trim(), "我喜欢", StringComparison.Ordinal)); + + private static string SubscriptionKey(KugouPlaylistData data) + => !string.IsNullOrWhiteSpace(data.CollectionId) + ? "gid:" + data.CollectionId + : $"source:{data.OwnerUserId}:{data.SourceListId}"; + + private static MusicLoginState ToLoginState(KugouAccountCredential account) => new(true, account.UserId, account.Nickname, account.AvatarUrl, account.VipLevel); - private static string MergeCookies(params string?[] values) - { - var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var value in values.Where(value => !string.IsNullOrWhiteSpace(value))) - { - foreach (var pair in ParseCookie(value!)) merged[pair.Key] = pair.Value; - } - return string.Join("; ", merged.Select(pair => $"{pair.Key}={pair.Value}")); - } + private sealed record KugouSongData(string Hash, long AlbumId, long MixSongId, long FileId); - private sealed record JsonResponse(JsonObject Json, string Cookies); - - private sealed record CookieValidationResult(MusicLoginState State, string Cookie); - - private sealed record KugouAccount( - string AppId, - string UserId, - string Token, - string ClientTime, - string Nickname, - string AvatarUrl, - string VipLevel); + private sealed record KugouPlaylistData(string CollectionId, long LocalListId, long SourceListId, long OwnerUserId, bool Owned); } diff --git a/src/YMhut.Box.Core/Music/MusicModels.cs b/src/YMhut.Box.Core/Music/MusicModels.cs index 3364471..8b7b6e9 100644 --- a/src/YMhut.Box.Core/Music/MusicModels.cs +++ b/src/YMhut.Box.Core/Music/MusicModels.cs @@ -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 GetMvAsync(string songId, string? mvId = null, CancellationToken cancellationToken = default); - Task SetFavoriteAsync(string songId, bool favorite, CancellationToken cancellationToken = default); + Task 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 diff --git a/src/YMhut.Box.Core/Music/NeteaseMusicProvider.cs b/src/YMhut.Box.Core/Music/NeteaseMusicProvider.cs index d1ac04b..8dcbad3 100644 --- a/src/YMhut.Box.Core/Music/NeteaseMusicProvider.cs +++ b/src/YMhut.Box.Core/Music/NeteaseMusicProvider.cs @@ -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 GetFavoriteStateAsync(MusicSong song, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public async Task SetFavoriteAsync(MusicSong song, bool favorite, CancellationToken cancellationToken = default) { EnsureLoggedIn(); var result = await PostApiAsync("/api/song/like", new Dictionary { - ["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 { - ["id"] = playlistId, + ["id"] = playlist.Id, ["csrf_token"] = CsrfToken() }, cancellationToken).ConfigureAwait(false); EnsureSuccess(result, "收藏歌单"); diff --git a/src/YMhut.Box.Core/Tools/SerialTerminalModels.cs b/src/YMhut.Box.Core/Tools/SerialTerminalModels.cs new file mode 100644 index 0000000..f21cb1c --- /dev/null +++ b/src/YMhut.Box.Core/Tools/SerialTerminalModels.cs @@ -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 data) : EventArgs +{ + public ReadOnlyMemory Data { get; } = data; +} + +public interface ISerialPortTransport : IDisposable +{ + bool IsOpen { get; } + + event EventHandler? DataReceived; + + IReadOnlyList GetPortNames(); + + Task OpenAsync(SerialConnectionOptions options, CancellationToken cancellationToken = default); + + Task WriteAsync(ReadOnlyMemory 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 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); +} diff --git a/src/YMhut.Box.Tests/KugouMusicProviderTests.cs b/src/YMhut.Box.Tests/KugouMusicProviderTests.cs index f818942..a74920b 100644 --- a/src/YMhut.Box.Tests/KugouMusicProviderTests.cs +++ b/src/YMhut.Box.Tests/KugouMusicProviderTests.cs @@ -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 { - 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 + { + ["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(() => 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(); - 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(() => 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(() => 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(); - 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(); + 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(() => 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(() => 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(() => provider.SetPlaylistSubscribedAsync(playlists[0], false)); + await Assert.ThrowsExactlyAsync(() => provider.SetPlaylistSubscribedAsync(playlists[1], false)); + await Assert.ThrowsExactlyAsync(() => 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(() => 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> 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 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(expectedJson, options); + var actual = JsonSerializer.Deserialize(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 FromAsync(HttpRequestMessage request) + => new(request.Method, request.RequestUri!, request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync()); + } + + private sealed class StubHttpHandler(Func> responseFactory) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - => responseFactory(request); + => responseFactory(request, cancellationToken); } private sealed class MemoryCredentialStore : IMusicCredentialStore { private readonly Dictionary _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; diff --git a/src/box-winUI/App.xaml.cs b/src/box-winUI/App.xaml.cs index a0c7c54..cf3568c 100644 --- a/src/box-winUI/App.xaml.cs +++ b/src/box-winUI/App.xaml.cs @@ -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); diff --git a/src/box-winUI/Assets/LockScreenLogo.png b/src/box-winUI/Assets/LockScreenLogo.png index c0e57c6..f56993c 100644 Binary files a/src/box-winUI/Assets/LockScreenLogo.png and b/src/box-winUI/Assets/LockScreenLogo.png differ diff --git a/src/box-winUI/Assets/Square150x150Logo.png b/src/box-winUI/Assets/Square150x150Logo.png index f482e4e..1432f18 100644 Binary files a/src/box-winUI/Assets/Square150x150Logo.png and b/src/box-winUI/Assets/Square150x150Logo.png differ diff --git a/src/box-winUI/Assets/Square44x44Logo.png b/src/box-winUI/Assets/Square44x44Logo.png index 3a37741..a9c7f8f 100644 Binary files a/src/box-winUI/Assets/Square44x44Logo.png and b/src/box-winUI/Assets/Square44x44Logo.png differ diff --git a/src/box-winUI/Assets/StoreLogo.png b/src/box-winUI/Assets/StoreLogo.png index f1cdf82..664681a 100644 Binary files a/src/box-winUI/Assets/StoreLogo.png and b/src/box-winUI/Assets/StoreLogo.png differ diff --git a/src/box-winUI/Assets/Wide310x150Logo.png b/src/box-winUI/Assets/Wide310x150Logo.png index 6530c38..6897305 100644 Binary files a/src/box-winUI/Assets/Wide310x150Logo.png and b/src/box-winUI/Assets/Wide310x150Logo.png differ diff --git a/src/box-winUI/MainWindow.xaml.cs b/src/box-winUI/MainWindow.xaml.cs index b3d2bac..1078378 100644 --- a/src/box-winUI/MainWindow.xaml.cs +++ b/src/box-winUI/MainWindow.xaml.cs @@ -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 diff --git a/src/box-winUI/Services/AppServices.cs b/src/box-winUI/Services/AppServices.cs index a4906ab..a9cd8ac 100644 --- a/src/box-winUI/Services/AppServices.cs +++ b/src/box-winUI/Services/AppServices.cs @@ -73,6 +73,10 @@ public static class AppServices provider.GetRequiredService(), provider.GetService())); services.AddSingleton(provider => new DevEnvironmentDetectionService(provider.GetService())); + services.AddSingleton(provider => new WindowsDevTerminalPlatform(provider.GetRequiredService())); + services.AddSingleton(provider => new DevTerminalSetupService( + provider.GetRequiredService(), + provider.GetService())); services.AddSingleton(provider => new DevEnvironmentCatalogService( provider.GetRequiredService(), provider.GetRequiredService(), diff --git a/src/box-winUI/Services/DownloadItemLauncher.cs b/src/box-winUI/Services/DownloadItemLauncher.cs new file mode 100644 index 0000000..f193db8 --- /dev/null +++ b/src/box-winUI/Services/DownloadItemLauncher.cs @@ -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 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)); + } + } +} diff --git a/src/box-winUI/Services/DownloadManagerService.cs b/src/box-winUI/Services/DownloadManagerService.cs index 0068a37..4c2d09f 100644 --- a/src/box-winUI/Services/DownloadManagerService.cs +++ b/src/box-winUI/Services/DownloadManagerService.cs @@ -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) diff --git a/src/box-winUI/Services/SerialPortTransport.cs b/src/box-winUI/Services/SerialPortTransport.cs new file mode 100644 index 0000000..5fd1f8a --- /dev/null +++ b/src/box-winUI/Services/SerialPortTransport.cs @@ -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? DataReceived; + + public IReadOnlyList 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 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 + }; +} diff --git a/src/box-winUI/Views/DownloadManagerPage.cs b/src/box-winUI/Views/DownloadManagerPage.cs index 14bee2c..9053d07 100644 --- a/src/box-winUI/Views/DownloadManagerPage.cs +++ b/src/box-winUI/Views/DownloadManagerPage.cs @@ -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(); private readonly IDirectDownloadValidator _validator = AppServices.GetRequiredService(); + private readonly ILogService _logService = AppServices.GetRequiredService(); 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 diff --git a/src/box-winUI/Views/NetworkMusicPage.cs b/src/box-winUI/Views/NetworkMusicPage.cs index 0d7378c..351e9b9 100644 --- a/src/box-winUI/Views/NetworkMusicPage.cs +++ b/src/box-winUI/Views/NetworkMusicPage.cs @@ -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(); private readonly IAgreementAcceptanceStore _agreementStore = AppServices.GetRequiredService(); private readonly IAppVersionService _appVersionService = AppServices.GetRequiredService(); - private readonly WebView2EnvironmentFactory _webViewFactory = AppServices.GetRequiredService(); private readonly IMusicPlaybackService _playback = AppServices.GetRequiredService(); private readonly IDesktopOverlayService _overlayService = AppServices.GetRequiredService(); 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(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 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 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) { diff --git a/src/box-winUI/Views/Tools/DevEnvironmentConfigToolPage.cs b/src/box-winUI/Views/Tools/DevEnvironmentConfigToolPage.cs index 902a39a..28bf41a 100644 --- a/src/box-winUI/Views/Tools/DevEnvironmentConfigToolPage.cs +++ b/src/box-winUI/Views/Tools/DevEnvironmentConfigToolPage.cs @@ -19,6 +19,7 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase private readonly Action? _goBack; private readonly IDevEnvironmentDetectionService _detection = AppServices.GetRequiredService(); private readonly IDevEnvironmentCatalogService _catalog = AppServices.GetRequiredService(); + private readonly IDevTerminalSetupService _terminalSetup = AppServices.GetRequiredService(); private readonly IDownloadManagerService _downloads = AppServices.GetRequiredService(); 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 _sourceBoxes = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _quickButtons = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _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 _detected = new Dictionary(); private IReadOnlyDictionary> _versions = new Dictionary>(); @@ -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(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) diff --git a/src/box-winUI/Views/Tools/GeneratedToolPages.cs b/src/box-winUI/Views/Tools/GeneratedToolPages.cs index ff29da1..a35d31c 100644 --- a/src/box-winUI/Views/Tools/GeneratedToolPages.cs +++ b/src/box-winUI/Views/Tools/GeneratedToolPages.cs @@ -75,12 +75,14 @@ public sealed partial class ToolPageRegistry registry.Register("zhihu_hot", (module, goBack) => new ZhihuHotToolPage(module, goBack)); registry.Register("cctv_news", (module, goBack) => new CctvNewsToolPage(module, goBack)); registry.Register("tech_news", (module, goBack) => new TechNewsToolPage(module, goBack)); + registry.Register("ai_latest_news", (module, goBack) => new AiLatestNewsToolPage(module, goBack)); registry.Register("football_news", (module, goBack) => new FootballNewsToolPage(module, goBack)); registry.Register("movie_box_office", (module, goBack) => new MovieBoxOfficeToolPage(module, goBack)); registry.Register("earthquake_info", (module, goBack) => new EarthquakeInfoToolPage(module, goBack)); registry.Register("gold_price", (module, goBack) => new GoldPriceToolPage(module, goBack)); registry.Register("oil_price", (module, goBack) => new OilPriceToolPage(module, goBack)); registry.Register("train_query", (module, goBack) => new TrainQueryToolPage(module, goBack)); + registry.Register("city_route_query", (module, goBack) => new CityRouteQueryToolPage(module, goBack)); registry.Register("history_today", (module, goBack) => new HistoryTodayToolPage(module, goBack)); registry.Register("car_info", (module, goBack) => new CarInfoToolPage(module, goBack)); registry.Register("sanguosha_skin", (module, goBack) => new SanguoshaSkinToolPage(module, goBack)); @@ -171,6 +173,7 @@ public sealed partial class ToolPageRegistry registry.Register("system_tool", (module, goBack) => new SystemToolToolPage(module, goBack)); registry.Register("pc_benchmark", (module, goBack) => new PcBenchmarkToolPage(module, goBack)); registry.Register("dev_environment_config", (module, goBack) => new DevEnvironmentConfigToolPage(module, new DevEnvironmentConfigToolViewModel(module), goBack)); + registry.Register("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); - diff --git a/src/box-winUI/Views/Tools/SerialTerminalToolPage.cs b/src/box-winUI/Views/Tools/SerialTerminalToolPage.cs new file mode 100644 index 0000000..74fdf13 --- /dev/null +++ b/src/box-winUI/Views/Tools/SerialTerminalToolPage.cs @@ -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()) + { + _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()) + { + _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(); + } +} diff --git a/src/box-winUI/YMhut.Box.WinUI.csproj b/src/box-winUI/YMhut.Box.WinUI.csproj index eea49a4..96a9e55 100644 --- a/src/box-winUI/YMhut.Box.WinUI.csproj +++ b/src/box-winUI/YMhut.Box.WinUI.csproj @@ -37,6 +37,7 @@ +