From 92e5c330f25ce80debb2ed4c94bc6dd5ad038add Mon Sep 17 00:00:00 2001 From: admin_gitea Date: Mon, 17 Aug 2026 03:09:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=BC=80=E5=8F=91=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=B7=A5=E5=85=B7=E3=80=81=E9=9F=B3=E4=B9=90=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=92=8C=E7=95=8C=E9=9D=A2=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- NuGet.Config | 1 - README | 4 +- README.zh-CN.md | 4 +- build.bat | 9 +- docs/release-process.md | 2 +- scripts/build-winui.ps1 | 22 +- .../DevEnvironmentBuildScript.cs | 114 +++++++ .../DevTerminalSetupService.cs | 208 +++++++++++-- .../Downloads/DownloadQueueStore.cs | 3 +- src/YMhut.Box.Core/Music/KugouApiClient.cs | 1 + .../Music/KugouMusicProvider.cs | 163 +++++++++- .../Tools/SerialTerminalModels.cs | 25 +- .../DevTerminalAndSerialTests.cs | 287 ++++++++++++++++++ .../DownloadAndDevEnvironmentTests.cs | 56 ++++ .../KugouMusicProviderTests.cs | 70 ++++- src/YMhut.Box.Tests/ToolCatalogTests.cs | 41 +++ src/YMhut.Box.Tests/YMhut.Box.Tests.csproj | 3 + .../Assets/home-globe/solar-system-home.html | 11 + .../Controls/WeatherCapsuleControl.cs | 198 ++++++++---- src/box-winUI/MainWindow.xaml.cs | 28 +- src/box-winUI/Views/HomePage.cs | 43 ++- .../Tools/DevEnvironmentConfigToolPage.cs | 48 ++- 22 files changed, 1185 insertions(+), 156 deletions(-) create mode 100644 src/YMhut.Box.Core/DevEnvironments/DevEnvironmentBuildScript.cs create mode 100644 src/YMhut.Box.Tests/DevTerminalAndSerialTests.cs diff --git a/NuGet.Config b/NuGet.Config index d48e637..765346e 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -2,7 +2,6 @@ - diff --git a/README b/README index 7f0be91..3e15d49 100644 --- a/README +++ b/README @@ -151,7 +151,7 @@ Release 构建时: 常用测试命令: ```powershell -dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config --ignore-failed-sources +dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore ``` @@ -483,7 +483,7 @@ Everything up-to-date ```powershell git pull -dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config --ignore-failed-sources +dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config dotnet build src\box-winUI\YMhut.Box.WinUI.csproj -c Debug -p:Platform=x64 --no-restore dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 7f0be91..3e15d49 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -151,7 +151,7 @@ Release 构建时: 常用测试命令: ```powershell -dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config --ignore-failed-sources +dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore ``` @@ -483,7 +483,7 @@ Everything up-to-date ```powershell git pull -dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config --ignore-failed-sources +dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config dotnet build src\box-winUI\YMhut.Box.WinUI.csproj -c Debug -p:Platform=x64 --no-restore dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore ``` diff --git a/build.bat b/build.bat index 686b7f2..cfdc6c8 100644 --- a/build.bat +++ b/build.bat @@ -2,6 +2,10 @@ setlocal chcp 65001 >nul 2>&1 set "ROOT=%~dp0" +set "PAUSE_ON_ERROR=1" +for %%A in (%*) do ( + if /I "%%~A"=="--no-pause" set "PAUSE_ON_ERROR=0" +) if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%" cd /d "%ROOT%" @@ -15,5 +19,8 @@ set "EXITCODE=%ERRORLEVEL%" if not "%EXITCODE%"=="0" ( echo. echo [ERROR] WinUI build failed with exit code %EXITCODE%. + echo [ERROR] Detailed logs: %ROOT%\build\winui\logs + if "%PAUSE_ON_ERROR%"=="1" pause + exit /b %EXITCODE% ) -exit /b %EXITCODE% +exit /b 0 diff --git a/docs/release-process.md b/docs/release-process.md index eda0c01..7ddc08f 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,7 +1,7 @@ # Release Process 1. Update `version.json`. -2. Run `dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config --ignore-failed-sources`. +2. Run `dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config`. 3. Run `dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Release --no-restore`. 4. Run `build.bat --target=both`. 5. Verify `installer_output/*.msix`, `installer_output/*.appinstaller`, and the Inno Setup EXE. diff --git a/scripts/build-winui.ps1 b/scripts/build-winui.ps1 index 5544594..fed5272 100644 --- a/scripts/build-winui.ps1 +++ b/scripts/build-winui.ps1 @@ -41,7 +41,6 @@ $ServerPublicRoot = Join-Path $Root 'server\update\public' $ServerDownloadRoot = Join-Path $ServerPublicRoot 'downloads' $ToolStateRoot = Join-Path $Root '.cache\tool_state' $NuGetRoot = Join-Path $Root '.cache\nuget' -$LocalNuGetFeedRoot = Join-Path $NuGetRoot 'feed' $AppDataRoot = Join-Path $ToolStateRoot 'appdata' $PackageIdentityName = 'YMhut.Box' @@ -668,7 +667,14 @@ function Write-LayoutManifests([string] $Directory) { } function Get-FileSha256([string] $Path) { - return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + $stream = [IO.File]::OpenRead($Path) + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() + } finally { + $sha.Dispose() + $stream.Dispose() + } } function Sync-UpdateServerPackages { @@ -1468,7 +1474,6 @@ pause New-Directory $AppDataRoot New-Directory $NuGetRoot -New-Directory $LocalNuGetFeedRoot New-Directory $OutputRoot $env:APPDATA = $AppDataRoot $env:LOCALAPPDATA = $AppDataRoot @@ -1490,22 +1495,23 @@ Invoke-DotNet @( 'restore', $Solution, '--configfile', $NuGetConfig, - '--ignore-failed-sources', '--disable-parallel' ) +if (-not $SkipTests) { + Invoke-DotNet @('test', $Solution, '-c', $Configuration, '--no-restore') +} + +# A non-RID restore can replace referenced host asset files. Perform the final +# runtime restore immediately before publish so every host has a win-x64 target. foreach ($ridProject in @($Project, $InstallerBootstrapProject)) { Invoke-DotNet @( 'restore', $ridProject, '-r', 'win-x64', '--configfile', $NuGetConfig, - '--ignore-failed-sources', '--disable-parallel' ) } -if (-not $SkipTests) { - Invoke-DotNet @('test', $Solution, '-c', $Configuration, '--no-restore') -} Publish-UnpackagedApp $versionInfo diff --git a/src/YMhut.Box.Core/DevEnvironments/DevEnvironmentBuildScript.cs b/src/YMhut.Box.Core/DevEnvironments/DevEnvironmentBuildScript.cs new file mode 100644 index 0000000..12555f2 --- /dev/null +++ b/src/YMhut.Box.Core/DevEnvironments/DevEnvironmentBuildScript.cs @@ -0,0 +1,114 @@ +namespace YMhut.Box.Core.DevEnvironments; + +public static class DevEnvironmentBuildScript +{ + public static IReadOnlyList Create(DevEnvironmentInstallPlan plan, string archivePath) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentException.ThrowIfNullOrWhiteSpace(archivePath); + + var recipeCommands = plan.BuildRecipe + .Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Skip(1) + .ToArray(); + var environmentId = SafePathPart(plan.EnvironmentId); + var version = SafePathPart(plan.Version.Version); + var lines = new List + { + "@echo off", + "setlocal EnableExtensions", + "chcp 65001 >nul 2>&1", + "set \"SCRIPT_PATH=%~f0\"", + $"set \"ARCHIVE={EscapeSetValue(archivePath)}\"", + $"set \"WORKDIR=%USERPROFILE%\\YMhutBuilds\\{environmentId}-{version}\"", + $"title YMhut Build - {EscapeEcho(environmentId)} {EscapeEcho(version)}", + $"echo Waiting for {EscapeEcho(plan.EnvironmentName)} {EscapeEcho(plan.Version.Version)} source archive...", + ":wait_download", + "if not exist \"%ARCHIVE%\" (", + " timeout /t 2 >nul", + " goto :wait_download", + ")", + "if not exist \"%WORKDIR%\" mkdir \"%WORKDIR%\"", + "if errorlevel 1 goto :failed", + "cd /d \"%WORKDIR%\"", + "if errorlevel 1 goto :failed", + "echo Extracting source archive...", + "tar -xf \"%ARCHIVE%\" --strip-components=1", + "if errorlevel 1 goto :failed" + }; + + foreach (var command in recipeCommands) + { + var executable = NormalizeRecipeCommand(command); + if (string.IsNullOrWhiteSpace(executable)) + { + continue; + } + + lines.Add($"echo ^> {EscapeEcho(executable)}"); + lines.Add(executable); + lines.Add("if errorlevel 1 goto :failed"); + } + + lines.AddRange([ + "echo.", + "echo Build completed successfully.", + "del \"%SCRIPT_PATH%\" >nul 2>&1", + "exit /b 0", + ":failed", + "set \"EXIT_CODE=%ERRORLEVEL%\"", + "if \"%EXIT_CODE%\"==\"0\" set \"EXIT_CODE=1\"", + "echo.", + "echo [ERROR] Source build failed with exit code %EXIT_CODE%.", + "echo [ERROR] Working directory: %WORKDIR%", + "echo [ERROR] This script was kept at: %SCRIPT_PATH%", + "pause", + "exit /b %EXIT_CODE%" + ]); + return lines; + } + + private static string NormalizeRecipeCommand(string command) + { + if (command.StartsWith("tar -xf ", StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; + } + + if (command.StartsWith("cd ", StringComparison.OrdinalIgnoreCase)) + { + var target = command[3..].Trim().Trim('"'); + if (target.Contains('*') || target.Contains('?')) + { + return string.Empty; + } + + if (target.Equals("go\\src", StringComparison.OrdinalIgnoreCase)) + { + return "cd /d \"%WORKDIR%\\src\""; + } + + return $"cd /d \"%WORKDIR%\\{target}\""; + } + + var firstToken = command.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries)[0].Trim('"'); + return firstToken.EndsWith(".bat", StringComparison.OrdinalIgnoreCase) || + firstToken.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) + ? "call " + command + : command; + } + + private static string SafePathPart(string value) + => string.Concat((value ?? string.Empty).Select(character => + Path.GetInvalidFileNameChars().Contains(character) ? '_' : character)); + + private static string EscapeSetValue(string value) + => value.Replace("%", "%%", StringComparison.Ordinal); + + private static string EscapeEcho(string value) + => value.Replace("^", "^^", StringComparison.Ordinal) + .Replace("&", "^&", StringComparison.Ordinal) + .Replace("|", "^|", StringComparison.Ordinal) + .Replace("<", "^<", StringComparison.Ordinal) + .Replace(">", "^>", StringComparison.Ordinal); +} diff --git a/src/YMhut.Box.Core/DevEnvironments/DevTerminalSetupService.cs b/src/YMhut.Box.Core/DevEnvironments/DevTerminalSetupService.cs index 1e3033b..37b87ea 100644 --- a/src/YMhut.Box.Core/DevEnvironments/DevTerminalSetupService.cs +++ b/src/YMhut.Box.Core/DevEnvironments/DevTerminalSetupService.cs @@ -2,9 +2,12 @@ using System.Diagnostics; using System.IO.Compression; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Security.Principal; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Microsoft.Win32; +using YMhut.Box.Core; using YMhut.Box.Core.App; using YMhut.Box.Core.Logging; @@ -23,7 +26,9 @@ public sealed record DevTerminalSnapshot( bool IsPowerShellDefaultProfile, int WindowsBuild, string Architecture, - string Error = "") + string Error = "", + bool IsAdministrator = false, + int WindowsRevision = 0) { public bool IsReady => WindowsTerminalInstalled && PowerShellInstalled; } @@ -49,8 +54,12 @@ public interface IDevTerminalPlatform { int WindowsBuild { get; } + int WindowsRevision { get; } + string Architecture { get; } + bool IsAdministrator { get; } + Task ResolveCommandAsync(string command, CancellationToken cancellationToken = default); Task RunAsync(string fileName, string arguments, CancellationToken cancellationToken = default); @@ -96,10 +105,10 @@ public sealed class DevTerminalSetupService( var powerShellPath = await powerShellPathTask.ConfigureAwait(false); var terminalVersion = string.IsNullOrWhiteSpace(terminalPath) ? string.Empty - : await ReadVersionAsync("wt.exe", "--version", cancellationToken).ConfigureAwait(false); + : await ReadVersionAsync(terminalPath, "--version", cancellationToken).ConfigureAwait(false); var powerShellVersion = string.IsNullOrWhiteSpace(powerShellPath) ? string.Empty - : await ReadVersionAsync("pwsh.exe", "-NoLogo -NoProfile -Command \"$PSVersionTable.PSVersion.ToString()\"", cancellationToken).ConfigureAwait(false); + : await ReadVersionAsync(powerShellPath, "-NoLogo -NoProfile -Command \"$PSVersionTable.PSVersion.ToString()\"", cancellationToken).ConfigureAwait(false); return new DevTerminalSnapshot( !string.IsNullOrWhiteSpace(await wingetPathTask.ConfigureAwait(false)), @@ -109,11 +118,13 @@ public sealed class DevTerminalSetupService( !string.IsNullOrWhiteSpace(powerShellPath), powerShellVersion, powerShellPath, - SupportsDefaultTerminal(platform.WindowsBuild), + SupportsDefaultTerminal(platform.WindowsBuild, platform.WindowsRevision), platform.IsWindowsTerminalDefault(), platform.IsPowerShellDefaultProfile(), platform.WindowsBuild, - platform.Architecture); + platform.Architecture, + IsAdministrator: platform.IsAdministrator, + WindowsRevision: platform.WindowsRevision); } catch (OperationCanceledException) { @@ -124,8 +135,8 @@ public sealed class DevTerminalSetupService( 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); + SupportsDefaultTerminal(platform.WindowsBuild, platform.WindowsRevision), false, false, + platform.WindowsBuild, platform.Architecture, exception.Message, platform.IsAdministrator, platform.WindowsRevision); } } @@ -142,7 +153,7 @@ public sealed class DevTerminalSetupService( if (before.WingetAvailable) { progress?.Report(new DevTerminalInstallProgress("terminal", "Installing or upgrading Windows Terminal...", 18)); - terminalSucceeded = await InstallWithWingetAsync("Microsoft.WindowsTerminal", cancellationToken).ConfigureAwait(false); + terminalSucceeded = await InstallWithWingetAsync("Microsoft.WindowsTerminal", "wt.exe", cancellationToken).ConfigureAwait(false); if (!terminalSucceeded) { messages.Add("Windows Terminal could not be installed with winget; using the official package fallback."); @@ -150,7 +161,7 @@ public sealed class DevTerminalSetupService( } progress?.Report(new DevTerminalInstallProgress("powershell", "Installing or upgrading PowerShell 7...", 48)); - powerShellSucceeded = await InstallWithWingetAsync("Microsoft.PowerShell", cancellationToken).ConfigureAwait(false); + powerShellSucceeded = await InstallWithWingetAsync("Microsoft.PowerShell", "pwsh.exe", cancellationToken).ConfigureAwait(false); if (!powerShellSucceeded) { messages.Add("PowerShell 7 could not be installed with winget; using the official user-level fallback."); @@ -201,16 +212,29 @@ public sealed class DevTerminalSetupService( public Task OpenTerminalAsync(CancellationToken cancellationToken = default) => platform.OpenTerminalAsync(cancellationToken); - public static bool SupportsDefaultTerminal(int windowsBuild) - => windowsBuild >= 22000 || windowsBuild >= 19045; + public static bool SupportsDefaultTerminal(int windowsBuild, int windowsRevision) + => windowsBuild >= 22000 || windowsBuild == 19045 && windowsRevision >= 3031; - private async Task InstallWithWingetAsync(string packageId, CancellationToken cancellationToken) + public static bool SupportsWindowsTerminalVersion(string value) + { + var match = Regex.Match(value ?? string.Empty, @"\d+(?:\.\d+)+", RegexOptions.CultureInvariant); + return match.Success && Version.TryParse(match.Value, out var version) && version >= new Version(1, 17); + } + + private async Task InstallWithWingetAsync( + string packageId, + string expectedCommand, + 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 installedCommand = await platform.ResolveCommandAsync(expectedCommand, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(installedCommand)) + { + return true; + } } var install = await platform.RunAsync("winget.exe", $"install {common}", cancellationToken).ConfigureAwait(false); @@ -239,7 +263,12 @@ public sealed class DevTerminalSetupService( } private Task WriteLogAsync(string level, string message, string detail, CancellationToken cancellationToken) - => logService?.WriteAsync(level, "dev-terminal", message, detail, cancellationToken) ?? Task.CompletedTask; + => logService?.WriteAsync( + level, + "dev-terminal", + message, + SensitiveText.Sanitize(detail, 500), + cancellationToken) ?? Task.CompletedTask; } [SupportedOSPlatform("windows")] @@ -264,14 +293,70 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab public int WindowsBuild => Environment.OSVersion.Version.Build; + public int WindowsRevision + { + get + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion", writable: false); + return Convert.ToInt32(key?.GetValue("UBR") ?? 0); + } + catch + { + return Math.Max(0, Environment.OSVersion.Version.Revision); + } + } + } + public string Architecture => RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(); + public bool IsAdministrator + { + get + { + try + { + using var identity = WindowsIdentity.GetCurrent(); + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + } + catch + { + return false; + } + } + } + public async Task ResolveCommandAsync(string command, CancellationToken cancellationToken = default) { var result = await RunAsync("where.exe", command, cancellationToken).ConfigureAwait(false); - return result.Succeeded + var resolved = result.Succeeded ? result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? string.Empty : string.Empty; + if (!string.IsNullOrWhiteSpace(resolved)) + { + return resolved; + } + + return KnownCommandPaths(command).FirstOrDefault(File.Exists) ?? string.Empty; + } + + private static IEnumerable KnownCommandPaths(string command) + { + var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (command.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase)) + { + yield return Path.Combine(programFiles, "PowerShell", "7", "pwsh.exe"); + yield return Path.Combine(local, "Programs", "PowerShell", "7", "pwsh.exe"); + yield break; + } + + if (command.Equals("wt.exe", StringComparison.OrdinalIgnoreCase) || + command.Equals("winget.exe", StringComparison.OrdinalIgnoreCase)) + { + yield return Path.Combine(local, "Microsoft", "WindowsApps", command); + } } public async Task RunAsync(string fileName, string arguments, CancellationToken cancellationToken = default) @@ -338,6 +423,34 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab public async Task InstallPowerShellFallbackAsync(CancellationToken cancellationToken = default) { var architecture = RuntimeInformation.OSArchitecture == global::System.Runtime.InteropServices.Architecture.Arm64 ? "arm64" : "x64"; + if (IsAdministrator) + { + var msiAsset = await FindReleaseAssetAsync( + "https://api.github.com/repos/PowerShell/PowerShell/releases/latest", + name => name.EndsWith($"win-{architecture}.msi", StringComparison.OrdinalIgnoreCase), + cancellationToken).ConfigureAwait(false); + if (msiAsset is not null) + { + var installer = await DownloadAssetAsync(msiAsset.Value.Url, msiAsset.Value.Name, cancellationToken).ConfigureAwait(false); + var msiResult = await RunAsync( + "msiexec.exe", + $"/i \"{installer}\" /qn /norestart ADD_PATH=1", + cancellationToken).ConfigureAwait(false); + if (msiResult.Succeeded) + { + var machineInstallRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "PowerShell", + "7"); + if (File.Exists(Path.Combine(machineInstallRoot, "pwsh.exe"))) + { + AddUserPath(machineInstallRoot); + return true; + } + } + } + } + var asset = await FindReleaseAssetAsync( "https://api.github.com/repos/PowerShell/PowerShell/releases/latest", name => name.EndsWith($"win-{architecture}.zip", StringComparison.OrdinalIgnoreCase) && @@ -363,8 +476,12 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab 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))) + var terminalPath = await ResolveCommandAsync("wt.exe", cancellationToken).ConfigureAwait(false); + var terminalVersion = string.IsNullOrWhiteSpace(terminalPath) + ? new DevTerminalCommandResult(-1, string.Empty, string.Empty) + : await RunAsync(terminalPath, "--version", cancellationToken).ConfigureAwait(false); + if (DevTerminalSetupService.SupportsDefaultTerminal(WindowsBuild, WindowsRevision) && + DevTerminalSetupService.SupportsWindowsTerminalVersion(terminalVersion.Output)) { BackupDefaultTerminalRegistry(); using var key = Registry.CurrentUser.CreateSubKey(ConsoleStartupKey, writable: true); @@ -499,7 +616,11 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab }; 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 })); + var path = Path.Combine(directory, "default-terminal.json"); + if (!File.Exists(path)) + { + File.WriteAllText(path, backup.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } } catch { @@ -519,7 +640,7 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab JsonObject root; if (File.Exists(path)) { - var backup = path + $".ymhut-{DateTime.Now:yyyyMMddHHmmss}.bak"; + var backup = path + $".ymhut-{DateTime.Now:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}.bak"; File.Copy(path, backup, overwrite: false); root = JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject(); } @@ -568,7 +689,7 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab }; var path = candidates.FirstOrDefault(File.Exists) ?? candidates.FirstOrDefault(candidate => Directory.Exists(Path.GetDirectoryName(candidate)!)) ?? - candidates[1]; + candidates[0]; if (createDirectory) { Directory.CreateDirectory(Path.GetDirectoryName(path)!); @@ -579,19 +700,17 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab 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)) + var updated = DevTerminalPathPolicy.Merge(current, directory); + if (!string.Equals(current, updated, StringComparison.Ordinal)) { - entries.Add(directory); - Environment.SetEnvironmentVariable("PATH", string.Join(';', entries), EnvironmentVariableTarget.User); + Environment.SetEnvironmentVariable("PATH", updated, EnvironmentVariableTarget.User); } var processPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - var processEntries = processPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); - if (!processEntries.Contains(directory, StringComparer.OrdinalIgnoreCase)) + var updatedProcessPath = DevTerminalPathPolicy.Merge(processPath, directory); + if (!string.Equals(processPath, updatedProcessPath, StringComparison.Ordinal)) { - processEntries.Add(directory); - Environment.SetEnvironmentVariable("PATH", string.Join(';', processEntries)); + Environment.SetEnvironmentVariable("PATH", updatedProcessPath); } _ = SendMessageTimeout( @@ -614,3 +733,36 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab uint timeout, out nuint result); } + +public static class DevTerminalPathPolicy +{ + public static string Merge(string? currentPath, string requiredDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(requiredDirectory); + + var entries = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in (currentPath ?? string.Empty).Split( + ';', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (seen.Add(Normalize(entry))) + { + entries.Add(entry); + } + } + + if (seen.Add(Normalize(requiredDirectory))) + { + entries.Add(requiredDirectory.Trim()); + } + + return string.Join(';', entries); + } + + private static string Normalize(string value) + { + var expanded = Environment.ExpandEnvironmentVariables(value.Trim().Trim('"')); + return expanded.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } +} diff --git a/src/YMhut.Box.Core/Downloads/DownloadQueueStore.cs b/src/YMhut.Box.Core/Downloads/DownloadQueueStore.cs index dfe1de4..03f40d1 100644 --- a/src/YMhut.Box.Core/Downloads/DownloadQueueStore.cs +++ b/src/YMhut.Box.Core/Downloads/DownloadQueueStore.cs @@ -42,9 +42,10 @@ public sealed class DownloadQueueStore(AppPaths paths) : IDownloadQueueStore } await using var stream = File.OpenRead(_path); - return await JsonSerializer.DeserializeAsync>(stream, JsonOptions, cancellationToken) + var items = await JsonSerializer.DeserializeAsync>(stream, JsonOptions, cancellationToken) .ConfigureAwait(false) ?? []; + return items.Select(DownloadOpenPolicy.Normalize).ToArray(); } catch { diff --git a/src/YMhut.Box.Core/Music/KugouApiClient.cs b/src/YMhut.Box.Core/Music/KugouApiClient.cs index 843421e..11434f5 100644 --- a/src/YMhut.Box.Core/Music/KugouApiClient.cs +++ b/src/YMhut.Box.Core/Music/KugouApiClient.cs @@ -584,6 +584,7 @@ internal sealed class KugouApiClient : IDisposable "/v2/special_recommend" => "获取酷狗推荐歌单", "/v4/get_list_all_file" => "获取酷狗歌单歌曲", "/pubsongs/v2/get_other_list_file_nofilt" => "获取酷狗歌单歌曲", + "/v2/get_res_privilege/lite" => "获取酷狗歌曲权益", "/v5/url" => "解析酷狗播放地址", "/cloudlist.service/v6/add_song" => "收藏歌曲", "/v4/delete_songs" => "取消收藏歌曲", diff --git a/src/YMhut.Box.Core/Music/KugouMusicProvider.cs b/src/YMhut.Box.Core/Music/KugouMusicProvider.cs index 42675a0..447e426 100644 --- a/src/YMhut.Box.Core/Music/KugouMusicProvider.cs +++ b/src/YMhut.Box.Core/Music/KugouMusicProvider.cs @@ -24,12 +24,17 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable }; private static readonly (MusicPlaybackQuality Quality, string Value, int Bitrate)[] QualityOrder = [ - (MusicPlaybackQuality.Master, "super", 1000), + (MusicPlaybackQuality.Master, "viper_tape", 1000), (MusicPlaybackQuality.HiRes, "high", 900), (MusicPlaybackQuality.Lossless, "flac", 800), (MusicPlaybackQuality.High, "320", 320), (MusicPlaybackQuality.Standard, "128", 128) ]; + private static readonly string[] KugouPlaybackQualities = + [ + "128", "320", "flac", "high", "multitrack", "viper_atmos", + "viper_tape", "viper_clear", "super" + ]; private readonly IMusicCredentialStore _credentials; private readonly HttpClient _publicClient; @@ -348,10 +353,11 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable var identity = SplitSongId(songId); var start = global::System.Array.FindIndex(QualityOrder, item => item.Quality == quality); if (start < 0) start = QualityOrder.Length - 1; + var metadata = SongDataFor(songId); + var candidates = await GetPlaybackCandidatesAsync(identity, metadata, start, cancellationToken).ConfigureAwait(false); string? lastReason = null; - for (var index = start; index < QualityOrder.Length; index++) + foreach (var candidate in candidates) { - var candidate = QualityOrder[index]; try { var response = await _api.SendAndroidAsync( @@ -361,12 +367,12 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable { ["album_id"] = identity.AlbumId, ["area_code"] = "1", - ["hash"] = identity.Hash.ToLowerInvariant(), + ["hash"] = candidate.Hash.ToLowerInvariant(), ["ssa_flag"] = "is_fromtrack", ["version"] = "11430", ["page_id"] = "151369488", ["quality"] = candidate.Value, - ["album_audio_id"] = SongDataFor(songId)?.MixSongId.ToString(CultureInfo.InvariantCulture) ?? "0", + ["album_audio_id"] = metadata?.MixSongId.ToString(CultureInfo.InvariantCulture) ?? "0", ["behavior"] = "play", ["pid"] = "2", ["cmd"] = "26", @@ -382,14 +388,20 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable "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) + + foreach (var data in PlaybackResponseNodes(response.Json)) { - var bitrate = KugouApiClient.Integer(data, "bitRate"); + var extension = KugouApiClient.Text(data, "extName") ?? KugouApiClient.Text(data, "ext_name"); + if (string.Equals(extension, "mp4", StringComparison.OrdinalIgnoreCase)) + { + lastReason = "当前音质返回了视频容器,已尝试较低音质。"; + continue; + } + if (!TryGetHttpsPlaybackUri(data, out var streamUri)) continue; + + var bitrate = FirstInteger(data, "bitRate", "bitrate", "bit_rate"); if (bitrate <= 0) bitrate = candidate.Bitrate; - var trial = KugouApiClient.Integer(data, "is_free_part") == 1; + var trial = FirstInteger(data, "is_free_part", "isFreePart") == 1; return new MusicStreamResult( streamUri, true, @@ -398,7 +410,10 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable bitrate * 1000L, trial ? "提供方仅允许试听片段。" : candidate.Quality == quality ? null : $"已回退到 {candidate.Quality} 音质。"); } - lastReason = KugouApiClient.Text(data, "error") ?? KugouApiClient.Text(response.Json, "error") ?? "提供方未返回可播放地址。"; + lastReason = KugouApiClient.Text(response.Json, "error") ?? + KugouApiClient.Text(response.Json, "msg") ?? + lastReason ?? + "提供方未返回可用的 HTTPS 播放地址。"; } catch (KugouApiException exception) when (!exception.AuthenticationFailure) { @@ -409,6 +424,128 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable lastReason ?? "提供方未返回播放地址,歌曲可能需要登录、会员或受地区限制。"); } + private async Task> GetPlaybackCandidatesAsync( + (string Hash, string AlbumId) identity, + KugouSongData? metadata, + int start, + CancellationToken cancellationToken) + { + var fallback = QualityOrder.Skip(start).ToArray(); + var variants = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + var response = await _api.SendAndroidAsync( + HttpMethod.Post, + "/v2/get_res_privilege/lite", + null, + new JsonObject + { + ["appid"] = KugouApiClient.AppId, + ["area_code"] = 1, + ["behavior"] = "play", + ["clientver"] = KugouApiClient.ClientVersion, + ["need_hash_offset"] = 1, + ["relate"] = 1, + ["support_verify"] = 1, + ["resource"] = new JsonArray + { + new JsonObject + { + ["type"] = "audio", + ["page_id"] = 0, + ["hash"] = identity.Hash, + ["album_id"] = metadata?.AlbumId ?? ParseLong(identity.AlbumId, defaultValue: 0) + } + }, + ["qualities"] = new JsonArray(KugouPlaybackQualities + .Select(value => (JsonNode?)JsonValue.Create(value)) + .ToArray()) + }, + _account, + "media.store.kugou.com", + cancellationToken).ConfigureAwait(false); + + foreach (var item in KugouApiClient.Array(response.Json["data"])) + { + AddPlaybackVariant(item, variants); + foreach (var related in KugouApiClient.Array(item?["relate_goods"])) + { + AddPlaybackVariant(related, variants); + } + } + } + catch (KugouApiException exception) when (!exception.AuthenticationFailure) + { + // The URL endpoint can still resolve free or standard variants when the + // privilege service is temporarily unavailable. + } + + var resolved = fallback + .Where(item => variants.ContainsKey(item.Value)) + .Select(item => new KugouPlaybackCandidate(item.Quality, item.Value, variants[item.Value], item.Bitrate)) + .ToArray(); + return resolved.Length > 0 + ? resolved + : fallback.Select(item => new KugouPlaybackCandidate(item.Quality, item.Value, identity.Hash, item.Bitrate)).ToArray(); + } + + private static void AddPlaybackVariant(JsonNode? node, IDictionary variants) + { + if (node is not JsonObject item) return; + if (item.ContainsKey("level") && KugouApiClient.Integer(item, "level") == 0) return; + var quality = KugouApiClient.Text(item, "quality"); + var hash = KugouApiClient.Text(item, "hash"); + if (string.IsNullOrWhiteSpace(quality) || string.IsNullOrWhiteSpace(hash)) return; + variants.TryAdd(quality, hash); + } + + private static IEnumerable PlaybackResponseNodes(JsonObject response) + { + yield return response; + if (response["data"] is JsonObject data) + { + yield return data; + } + else if (response["data"] is JsonArray array) + { + foreach (var item in array.Where(item => item is not null)) yield return item!; + } + } + + private static bool TryGetHttpsPlaybackUri(JsonNode? node, out Uri? result) + { + foreach (var property in new[] { "url", "play_url", "backup_url", "backupurl" }) + { + foreach (var address in TextValues(node?[property])) + { + if (Uri.TryCreate(address, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps) + { + result = uri; + return true; + } + } + } + result = null; + return false; + } + + private static IEnumerable TextValues(JsonNode? node) + { + if (node is JsonValue value && value.TryGetValue(out var valueText) && !string.IsNullOrWhiteSpace(valueText)) + { + yield return valueText; + yield break; + } + if (node is not JsonArray array) yield break; + foreach (var item in array) + { + foreach (var itemText in TextValues(item)) yield return itemText; + } + } + + private static int FirstInteger(JsonNode? node, params string[] properties) + => properties.Select(property => KugouApiClient.Integer(node, property)).FirstOrDefault(value => value != 0); + public async Task ProbeStreamAsync(Uri uri, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Get, uri); @@ -1189,5 +1326,7 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable private sealed record KugouSongData(string Hash, long AlbumId, long MixSongId, long FileId); + private sealed record KugouPlaybackCandidate(MusicPlaybackQuality Quality, string Value, string Hash, int Bitrate); + private sealed record KugouPlaylistData(string CollectionId, long LocalListId, long SourceListId, long OwnerUserId, bool Owned); } diff --git a/src/YMhut.Box.Core/Tools/SerialTerminalModels.cs b/src/YMhut.Box.Core/Tools/SerialTerminalModels.cs index f21cb1c..9d0d3d2 100644 --- a/src/YMhut.Box.Core/Tools/SerialTerminalModels.cs +++ b/src/YMhut.Box.Core/Tools/SerialTerminalModels.cs @@ -55,7 +55,18 @@ public static class SerialPayloadCodec return Encoding.UTF8.GetBytes(value ?? string.Empty); } - var compact = new string((value ?? string.Empty).Where(Uri.IsHexDigit).ToArray()); + var input = value ?? string.Empty; + foreach (var character in input) + { + if (!char.IsAsciiHexDigit(character) && + !char.IsWhiteSpace(character) && + character is not (',' or ';' or ':' or '-')) + { + throw new FormatException("Hexadecimal input contains an invalid character."); + } + } + + var compact = new string(input.Where(char.IsAsciiHexDigit).ToArray()); if (compact.Length == 0) { return []; @@ -77,7 +88,13 @@ public static class SerialPayloadCodec } 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); + { + if (!hexadecimal) + { + return Encoding.UTF8.GetString(data); + } + + var encoded = Convert.ToHexString(data); + return string.Join(' ', encoded.Chunk(2).Select(chars => new string(chars))); + } } diff --git a/src/YMhut.Box.Tests/DevTerminalAndSerialTests.cs b/src/YMhut.Box.Tests/DevTerminalAndSerialTests.cs new file mode 100644 index 0000000..60f60bd --- /dev/null +++ b/src/YMhut.Box.Tests/DevTerminalAndSerialTests.cs @@ -0,0 +1,287 @@ +using System.Collections.Concurrent; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YMhut.Box.Core.DevEnvironments; +using YMhut.Box.Core.Downloads; +using YMhut.Box.Core.Tools; +using YMhut.Box.WinUI.Services; + +namespace YMhut.Box.Tests; + +[TestClass] +public sealed class DevTerminalAndSerialTests +{ + [TestMethod] + public async Task TerminalDetectionReportsInstalledVersionsAndDefaults() + { + var platform = new FakeTerminalPlatform + { + WindowsBuild = 22631, + WindowsTerminalDefault = true, + PowerShellDefaultProfile = true + }; + platform.Commands["winget.exe"] = @"C:\Windows\winget.exe"; + platform.Commands["wt.exe"] = @"C:\WindowsApps\wt.exe"; + platform.Commands["pwsh.exe"] = @"C:\Program Files\PowerShell\7\pwsh.exe"; + + var snapshot = await new DevTerminalSetupService(platform).DetectAsync(); + + Assert.IsTrue(snapshot.WingetAvailable); + Assert.IsTrue(snapshot.WindowsTerminalInstalled); + Assert.AreEqual("Windows Terminal 1.23.0", snapshot.WindowsTerminalVersion); + Assert.IsTrue(snapshot.PowerShellInstalled); + Assert.AreEqual("7.5.2", snapshot.PowerShellVersion); + Assert.IsTrue(snapshot.IsWindowsTerminalDefault); + Assert.IsTrue(snapshot.IsPowerShellDefaultProfile); + Assert.IsTrue(snapshot.IsAdministrator); + } + + [TestMethod] + public async Task WingetNoUpgradeWithoutExecutableFallsThroughToInstall() + { + var platform = new FakeTerminalPlatform + { + UpgradeResult = new DevTerminalCommandResult(1, "No applicable upgrade found.", string.Empty) + }; + platform.Commands["winget.exe"] = @"C:\Windows\winget.exe"; + + var result = await new DevTerminalSetupService(platform).InstallOrRepairAsync(); + + Assert.IsTrue(result.Succeeded); + Assert.IsTrue(platform.RunCalls.Any(call => call.Contains("install --id Microsoft.WindowsTerminal", StringComparison.Ordinal))); + Assert.IsTrue(platform.RunCalls.Any(call => call.Contains("install --id Microsoft.PowerShell", StringComparison.Ordinal))); + Assert.AreEqual(0, platform.TerminalFallbackCalls); + Assert.AreEqual(0, platform.PowerShellFallbackCalls); + } + + [TestMethod] + public async Task MissingWingetUsesUserFallbackAndReportsPartialFailure() + { + var platform = new FakeTerminalPlatform + { + TerminalFallbackResult = false, + PowerShellFallbackResult = true + }; + var progress = new RecordingProgress(); + + var result = await new DevTerminalSetupService(platform).InstallOrRepairAsync(progress); + + Assert.IsFalse(result.Succeeded); + Assert.IsFalse(result.WindowsTerminalSucceeded); + Assert.IsTrue(result.PowerShellSucceeded); + Assert.AreEqual(1, platform.TerminalFallbackCalls); + Assert.AreEqual(1, platform.PowerShellFallbackCalls); + Assert.IsTrue(result.Messages.Any(message => message.Contains("winget is unavailable", StringComparison.Ordinal))); + Assert.AreEqual(100, progress.Items[^1].Percent); + } + + [TestMethod] + public async Task TerminalDetectionHonorsCancellation() + { + var platform = new FakeTerminalPlatform(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsExactlyAsync( + () => new DevTerminalSetupService(platform).DetectAsync(cancellation.Token)); + } + + [TestMethod] + public void DefaultTerminalSupportMatchesSupportedWindowsBuilds() + { + Assert.IsFalse(DevTerminalSetupService.SupportsDefaultTerminal(19044, 5000)); + Assert.IsFalse(DevTerminalSetupService.SupportsDefaultTerminal(19045, 3030)); + Assert.IsTrue(DevTerminalSetupService.SupportsDefaultTerminal(19045, 3031)); + Assert.IsTrue(DevTerminalSetupService.SupportsDefaultTerminal(22631, 1)); + Assert.IsFalse(DevTerminalSetupService.SupportsWindowsTerminalVersion("Windows Terminal 1.16.0")); + Assert.IsTrue(DevTerminalSetupService.SupportsWindowsTerminalVersion("Windows Terminal 1.17.1023.0")); + } + + [TestMethod] + public void TerminalPathMergeDeduplicatesCaseAndTrailingSeparators() + { + var merged = DevTerminalPathPolicy.Merge( + @"C:\Tools;C:\PowerShell\7;C:\POWERSHELL\7\;C:\Other", + @"c:\powershell\7"); + + CollectionAssert.AreEqual( + new[] { @"C:\Tools", @"C:\PowerShell\7", @"C:\Other" }, + merged.Split(';')); + } + + [TestMethod] + public void SourceBuildScriptClosesOnSuccessAndPausesOnlyOnFailure() + { + var source = new DownloadSource("https://example.test/go.tar.gz", "Go", "go.tar.gz"); + var version = new DevEnvironmentVersion("1.24.0", null, source); + var plan = new DevEnvironmentInstallPlan( + "go", + "Go", + version, + DevEnvironmentInstallMode.SourceBuild, + "Build Go from source.\r\ntar -xf \r\ncd go\\src\r\nmake.bat"); + + var lines = DevEnvironmentBuildScript.Create(plan, @"C:\Downloads\go.tar.gz"); + + CollectionAssert.Contains(lines.ToArray(), "tar -xf \"%ARCHIVE%\" --strip-components=1"); + CollectionAssert.Contains(lines.ToArray(), "cd /d \"%WORKDIR%\\src\""); + CollectionAssert.Contains(lines.ToArray(), "call make.bat"); + CollectionAssert.Contains(lines.ToArray(), "del \"%SCRIPT_PATH%\" >nul 2>&1"); + var pauseIndex = lines.ToList().IndexOf("pause"); + var failedIndex = lines.ToList().IndexOf(":failed"); + Assert.IsGreaterThan(failedIndex, pauseIndex); + Assert.AreEqual("exit /b 0", lines[failedIndex - 1]); + } + + [TestMethod] + public void SerialPayloadCodecRoundTripsTextAndHex() + { + CollectionAssert.AreEqual(new byte[] { 0xAA, 0x0B, 0xFF }, SerialPayloadCodec.Parse("AA:0b-ff", hexadecimal: true)); + Assert.AreEqual("AA 0B FF", SerialPayloadCodec.Format(new byte[] { 0xAA, 0x0B, 0xFF }, hexadecimal: true)); + CollectionAssert.AreEqual("终端"u8.ToArray(), SerialPayloadCodec.Parse("终端", hexadecimal: false)); + } + + [TestMethod] + public void SerialPayloadCodecRejectsInvalidOrIncompleteHex() + { + Assert.ThrowsExactly(() => SerialPayloadCodec.Parse("GG", hexadecimal: true)); + Assert.ThrowsExactly(() => SerialPayloadCodec.Parse("ABC", hexadecimal: true)); + } + + [TestMethod] + public void UiPerformanceCoordinatorKeepsLightModeUntilLastScopeCloses() + { + var coordinator = new UiPerformanceCoordinator(); + var states = new List(); + coordinator.LightModeChanged += (_, args) => states.Add(args.IsLightMode); + + var windowMove = coordinator.EnterLightMode("window-move"); + var nestedWork = coordinator.EnterLightMode("webview-work"); + Assert.IsTrue(coordinator.IsLightMode); + CollectionAssert.AreEqual(new[] { true }, states); + + windowMove.Dispose(); + windowMove.Dispose(); + Assert.IsTrue(coordinator.IsLightMode); + CollectionAssert.AreEqual(new[] { true }, states); + + nestedWork.Dispose(); + Assert.IsFalse(coordinator.IsLightMode); + CollectionAssert.AreEqual(new[] { true, false }, states); + } + + private sealed class RecordingProgress : IProgress + { + public List Items { get; } = []; + + public void Report(T value) => Items.Add(value); + } + + private sealed class FakeTerminalPlatform : IDevTerminalPlatform + { + public ConcurrentDictionary Commands { get; } = new(StringComparer.OrdinalIgnoreCase); + + public List RunCalls { get; } = []; + + public int WindowsBuild { get; set; } = 22631; + + public int WindowsRevision { get; set; } = 1; + + public string Architecture { get; set; } = "x64"; + + public bool IsAdministrator { get; set; } = true; + + public bool WindowsTerminalDefault { get; set; } + + public bool PowerShellDefaultProfile { get; set; } + + public bool TerminalFallbackResult { get; set; } = true; + + public bool PowerShellFallbackResult { get; set; } = true; + + public int TerminalFallbackCalls { get; private set; } + + public int PowerShellFallbackCalls { get; private set; } + + public DevTerminalCommandResult UpgradeResult { get; set; } = new(0, string.Empty, string.Empty); + + public Task ResolveCommandAsync(string command, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Commands.TryGetValue(command, out var path) ? path : string.Empty); + } + + public Task RunAsync( + string fileName, + string arguments, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + RunCalls.Add($"{fileName} {arguments}"); + if (fileName.Equals("winget.exe", StringComparison.OrdinalIgnoreCase) && arguments.StartsWith("upgrade ", StringComparison.Ordinal)) + { + return Task.FromResult(UpgradeResult); + } + if (fileName.Equals("winget.exe", StringComparison.OrdinalIgnoreCase) && arguments.StartsWith("install ", StringComparison.Ordinal)) + { + if (arguments.Contains("Microsoft.WindowsTerminal", StringComparison.Ordinal)) + { + Commands["wt.exe"] = @"C:\WindowsApps\wt.exe"; + } + if (arguments.Contains("Microsoft.PowerShell", StringComparison.Ordinal)) + { + Commands["pwsh.exe"] = @"C:\Program Files\PowerShell\7\pwsh.exe"; + } + return Task.FromResult(new DevTerminalCommandResult(0, string.Empty, string.Empty)); + } + if (fileName.EndsWith("wt.exe", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new DevTerminalCommandResult(0, "Windows Terminal 1.23.0", string.Empty)); + } + if (fileName.EndsWith("pwsh.exe", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new DevTerminalCommandResult(0, "7.5.2", string.Empty)); + } + return Task.FromResult(new DevTerminalCommandResult(0, string.Empty, string.Empty)); + } + + public Task InstallWindowsTerminalFallbackAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + TerminalFallbackCalls++; + if (TerminalFallbackResult) + { + Commands["wt.exe"] = @"C:\WindowsApps\wt.exe"; + } + return Task.FromResult(TerminalFallbackResult); + } + + public Task InstallPowerShellFallbackAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + PowerShellFallbackCalls++; + if (PowerShellFallbackResult) + { + Commands["pwsh.exe"] = @"C:\Users\test\PowerShell\7\pwsh.exe"; + } + return Task.FromResult(PowerShellFallbackResult); + } + + public Task ConfigureDefaultsAsync(string powerShellPath, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + WindowsTerminalDefault = Commands.ContainsKey("wt.exe"); + PowerShellDefaultProfile = !string.IsNullOrWhiteSpace(powerShellPath); + return Task.FromResult(WindowsTerminalDefault || PowerShellDefaultProfile); + } + + public bool IsWindowsTerminalDefault() => WindowsTerminalDefault; + + public bool IsPowerShellDefaultProfile() => PowerShellDefaultProfile; + + public Task OpenTerminalAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(true); + } + } +} diff --git a/src/YMhut.Box.Tests/DownloadAndDevEnvironmentTests.cs b/src/YMhut.Box.Tests/DownloadAndDevEnvironmentTests.cs index cf2f37b..4e77664 100644 --- a/src/YMhut.Box.Tests/DownloadAndDevEnvironmentTests.cs +++ b/src/YMhut.Box.Tests/DownloadAndDevEnvironmentTests.cs @@ -167,6 +167,62 @@ public sealed class DownloadAndDevEnvironmentTests Assert.IsTrue(loaded[0].ResumeSupported); } + [TestMethod] + public void DownloadOpenPolicySeparatesInstallersFilesAndExternalPages() + { + var installer = DownloadItem.Create( + new DownloadSource("https://example.com/tool.msi", "Tool", "tool.msi"), + @"C:\Downloads\tool.msi", + installCommand: "installer", + isInstaller: true); + var manual = DownloadItem.Create( + new DownloadSource("https://example.com/download", "Official page", "tool.url", "Manual"), + @"C:\Downloads\tool.url", + installCommand: "installer", + isInstaller: true); + var archive = DownloadItem.Create( + new DownloadSource("https://example.com/source.zip", "Source", "source.zip"), + @"C:\Downloads\source.zip"); + + Assert.AreEqual(DownloadOpenKind.Installer, installer.OpenKind); + Assert.AreEqual(DownloadOpenKind.ExternalUri, manual.OpenKind); + Assert.AreEqual(DownloadOpenKind.File, archive.OpenKind); + Assert.IsFalse(DownloadOpenPolicy.Normalize(manual).IsInstaller); + Assert.IsFalse(DownloadOpenPolicy.IsSupportedInstaller(@"C:\Downloads\script.url", "cmd.exe")); + } + + [TestMethod] + public async Task DownloadQueueStoreMigratesLegacyManualItemOnRead() + { + var workspace = TempWorkspace(); + var paths = AppPaths.ForCurrentUser(workspace); + Directory.CreateDirectory(paths.Data); + await File.WriteAllTextAsync(Path.Combine(paths.Data, "downloads.json"), """ + [ + { + "id": "legacy", + "source": { + "url": "https://cmake.org/download/", + "displayName": "CMake official downloads", + "fileName": "cmake-official-download.url", + "sourceKind": "Manual" + }, + "targetPath": "C:\\Downloads\\cmake-official-download.url", + "state": "Completed", + "isInstaller": true, + "deleteAfterInstall": true + } + ] + """); + + var loaded = await new DownloadQueueStore(paths).LoadAsync(); + + Assert.HasCount(1, loaded); + Assert.AreEqual(DownloadOpenKind.ExternalUri, loaded[0].OpenKind); + Assert.IsFalse(loaded[0].IsInstaller); + Assert.IsFalse(loaded[0].DeleteAfterInstall); + } + [TestMethod] public async Task DownloadQueueStorePersistsSettings() { diff --git a/src/YMhut.Box.Tests/KugouMusicProviderTests.cs b/src/YMhut.Box.Tests/KugouMusicProviderTests.cs index a74920b..372b292 100644 --- a/src/YMhut.Box.Tests/KugouMusicProviderTests.cs +++ b/src/YMhut.Box.Tests/KugouMusicProviderTests.cs @@ -330,16 +330,28 @@ public sealed class KugouMusicProviderTests public async Task AccountPlaybackFallsBackByQualityAndRejectsHttpAddress() { var qualities = new List(); + var hashes = new List(); var handler = new StubHttpHandler((request, _) => { if (request.RequestUri!.AbsolutePath == "/v7/get_all_list") return Task.FromResult(JsonResponse(UserLists())); + if (request.RequestUri.AbsolutePath == "/v2/get_res_privilege/lite") + { + return Task.FromResult(JsonResponse(""" + {"status":1,"data":[{"hash":"ABCDEF","quality":"128","level":1,"relate_goods":[ + {"hash":"MASTER-HASH","quality":"viper_tape","level":1}, + {"hash":"HIRES-HASH","quality":"high","level":1} + ]}]} + """)); + } if (request.RequestUri.AbsolutePath == "/v5/url") { - var quality = ParseQuery(request.RequestUri)["quality"]; + var query = ParseQuery(request.RequestUri); + var quality = query["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}}")); + hashes.Add(query["hash"]); + return Task.FromResult(JsonResponse(quality == "viper_tape" + ? "{\"status\":1,\"extName\":\"mp4\",\"url\":[\"https://media.test/video.mp4\"]}" + : "{\"status\":1,\"url\":[\"http://media.test/insecure.flac\",\"https://media.test/song.flac\"],\"bitRate\":900}")); } return Task.FromResult(JsonResponse("{\"status\":1}")); }); @@ -351,12 +363,54 @@ public sealed class KugouMusicProviderTests 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); + CollectionAssert.AreEqual(new[] { "viper_tape", "high" }, qualities); + CollectionAssert.AreEqual(new[] { "master-hash", "hires-hash" }, hashes); StringAssert.Contains(stream.ProviderReason, "回退"); } [TestMethod] - [DataRow(MusicPlaybackQuality.Master, "super")] + public async Task PlaybackUsesPrivilegeHashAndParsesNestedUrlArray() + { + CapturedRequest? privilegeRequest = null; + string? privilegeRouter = null; + string? playbackHash = null; + using var provider = new KugouMusicProvider(AccountStore(), new StubHttpHandler(async (request, _) => + { + if (request.RequestUri!.AbsolutePath == "/v7/get_all_list") return JsonResponse(UserLists()); + if (request.RequestUri.AbsolutePath == "/v2/get_res_privilege/lite") + { + privilegeRouter = request.Headers.TryGetValues("x-router", out var values) ? values.Single() : null; + privilegeRequest = await CapturedRequest.FromAsync(request); + return JsonResponse(""" + {"status":1,"data":[{"hash":"BASE","quality":"128","level":1,"relate_goods":[ + {"hash":"LOSSLESS-HASH","quality":"flac","level":1} + ]}]} + """); + } + if (request.RequestUri.AbsolutePath == "/v5/url") + { + playbackHash = ParseQuery(request.RequestUri)["hash"]; + return JsonResponse(""" + {"status":1,"data":[{"url":["http://media.test/song.flac","https://media.test/song.flac"],"bitrate":800}]} + """); + } + return JsonResponse("{\"status\":1}"); + })); + await provider.InitializeAsync(); + + var stream = await provider.ResolveStreamAsync("BASE|17", MusicPlaybackQuality.Lossless); + + Assert.IsNotNull(privilegeRequest); + Assert.AreEqual("media.store.kugou.com", privilegeRouter); + StringAssert.Contains(privilegeRequest.Body, "\"qualities\""); + StringAssert.Contains(privilegeRequest.Body, "\"album_id\":17"); + Assert.AreEqual("lossless-hash", playbackHash); + Assert.AreEqual(new Uri("https://media.test/song.flac"), stream.Uri); + Assert.AreEqual(800_000L, stream.Bitrate); + } + + [TestMethod] + [DataRow(MusicPlaybackQuality.Master, "viper_tape")] [DataRow(MusicPlaybackQuality.HiRes, "high")] [DataRow(MusicPlaybackQuality.Lossless, "flac")] [DataRow(MusicPlaybackQuality.High, "320")] @@ -366,6 +420,10 @@ public sealed class KugouMusicProviderTests string? actualParameter = null; using var provider = new KugouMusicProvider(DeviceStore(), new StubHttpHandler((request, _) => { + if (request.RequestUri!.AbsolutePath == "/v2/get_res_privilege/lite") + { + return Task.FromResult(JsonResponse("{\"status\":1}")); + } actualParameter = ParseQuery(request.RequestUri!)["quality"]; return Task.FromResult(JsonResponse("{\"status\":1,\"data\":{\"url\":\"https://media.test/song\",\"bitRate\":320}}")); })); diff --git a/src/YMhut.Box.Tests/ToolCatalogTests.cs b/src/YMhut.Box.Tests/ToolCatalogTests.cs index 4eb6885..7a9f2d0 100644 --- a/src/YMhut.Box.Tests/ToolCatalogTests.cs +++ b/src/YMhut.Box.Tests/ToolCatalogTests.cs @@ -1,4 +1,5 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Text.RegularExpressions; using YMhut.Box.Core.Tools; namespace YMhut.Box.Tests; @@ -43,6 +44,33 @@ public sealed class ToolCatalogTests Assert.IsGreaterThanOrEqualTo(160, catalog.Modules.Count); } + [TestMethod] + public void GeneratedWinUiRegistrationsCoverEveryDefaultToolPage() + { + var repositoryRoot = FindRepositoryRoot(); + var source = File.ReadAllText(Path.Combine( + repositoryRoot, + "src", + "box-winUI", + "Views", + "Tools", + "GeneratedToolPages.cs")); + var registered = Regex.Matches( + source, + "registry\\.Register<[^>]+>\\(\"(?[^\"]+)\"", + RegexOptions.CultureInvariant) + .Select(match => match.Groups["id"].Value) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var missing = new ToolCatalog().Modules + .Where(module => !ToolCatalog.IsToolboxNativeSurface(module.Id)) + .Where(module => !registered.Contains(module.Id)) + .Select(module => module.Id) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + Assert.IsEmpty(missing, $"Missing WinUI tool registrations: {string.Join(", ", missing)}"); + } + [TestMethod] public void SearchFiltersByQueryAndCategory() { @@ -54,6 +82,19 @@ public sealed class ToolCatalogTests Assert.AreEqual("safe_browser", results[0].Id); } + private static string FindRepositoryRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "YMhut.Box.Native.sln"))) + { + return directory.FullName; + } + } + + throw new DirectoryNotFoundException("Could not locate the repository root from the test output directory."); + } + [TestMethod] public void SearchFindsIntegratedToolboxSurfaces() { diff --git a/src/YMhut.Box.Tests/YMhut.Box.Tests.csproj b/src/YMhut.Box.Tests/YMhut.Box.Tests.csproj index c3f2ba4..2e4a393 100644 --- a/src/YMhut.Box.Tests/YMhut.Box.Tests.csproj +++ b/src/YMhut.Box.Tests/YMhut.Box.Tests.csproj @@ -13,6 +13,9 @@ + + + diff --git a/src/box-winUI/Assets/home-globe/solar-system-home.html b/src/box-winUI/Assets/home-globe/solar-system-home.html index 7d610d7..35d5af5 100644 --- a/src/box-winUI/Assets/home-globe/solar-system-home.html +++ b/src/box-winUI/Assets/home-globe/solar-system-home.html @@ -778,6 +778,7 @@ const defaultTarget = new THREE.Vector3(0, 0, 0); const responsive = { scale: 1, cameraDistance: 74, y: 7 }; const visualState = detectVisualState(); + let hostLightMode = false; const renderStats = { elapsed: 0, frames: 0, qualityReduced: false }; const asyncTasks = new Map([ ['three', { label: 'Three.js 模块', progress: 0 }], @@ -2341,6 +2342,12 @@ return; } + if (payload.type === 'performanceMode') { + hostLightMode = Boolean(payload.lightMode); + clock.getDelta(); + return; + } + if (payload.type === 'ephemeris:complete') { if (!acceptEphemerisRequest(payload)) { return; @@ -2484,6 +2491,10 @@ function animate() { requestAnimationFrame(animate); + if (hostLightMode) { + clock.getDelta(); + return; + } const delta = Math.min(clock.getDelta(), 0.05); const elapsed = clock.elapsedTime; const animationFactor = visualState.animationsEnabled ? 1 : 0; diff --git a/src/box-winUI/Controls/WeatherCapsuleControl.cs b/src/box-winUI/Controls/WeatherCapsuleControl.cs index 8b6335e..b9332aa 100644 --- a/src/box-winUI/Controls/WeatherCapsuleControl.cs +++ b/src/box-winUI/Controls/WeatherCapsuleControl.cs @@ -17,6 +17,7 @@ public sealed class WeatherCapsuleControl : UserControl private readonly ISettingsService _settingsService = AppServices.GetRequiredService(); private readonly AccessibilitySettings _accessibility = new(); private readonly UISettings _systemUiSettings = new(); + private readonly IUiPerformanceCoordinator _uiPerformanceCoordinator = AppServices.GetRequiredService(); private readonly Button _button; private readonly AnimatedWeatherIconControl _weatherIcon; private readonly ProgressRing _loadingRing; @@ -88,17 +89,27 @@ public sealed class WeatherCapsuleControl : UserControl Grid.SetColumn(_tempText, 2); visual.Children.Add(_tempText); - _flyout = new Flyout { Placement = FlyoutPlacementMode.BottomEdgeAlignedRight }; + var flyoutStyle = new Style(typeof(FlyoutPresenter)); + flyoutStyle.Setters.Add(new Setter(Control.PaddingProperty, new Thickness(12))); + flyoutStyle.Setters.Add(new Setter(Control.CornerRadiusProperty, new CornerRadius(8))); + flyoutStyle.Setters.Add(new Setter(Control.BackgroundProperty, ModernUi.Surface)); + flyoutStyle.Setters.Add(new Setter(Control.BorderBrushProperty, ModernUi.Stroke)); + flyoutStyle.Setters.Add(new Setter(Control.BorderThicknessProperty, new Thickness(1))); + _flyout = new Flyout + { + Placement = FlyoutPlacementMode.BottomEdgeAlignedRight, + FlyoutPresenterStyle = flyoutStyle + }; _flyout.Opening += (_, _) => _flyout.Content = BuildFlyoutContent(); _button = new Button { - Height = 38, - MinWidth = 168, - MaxWidth = 242, + Height = 40, + MinWidth = 172, + MaxWidth = 248, Margin = new Thickness(0, 4, 0, 4), Padding = new Thickness(12, 4, 12, 4), - CornerRadius = new CornerRadius(19), + CornerRadius = new CornerRadius(20), Background = ModernUi.Surface, BorderBrush = ModernUi.Stroke, BorderThickness = new Thickness(1), @@ -171,8 +182,8 @@ public sealed class WeatherCapsuleControl : UserControl _locationText.Text = displaySnapshot.Location; _conditionText.Text = displaySnapshot.Condition; _tempText.Text = displaySnapshot.TemperatureText; - _button.Background = displaySnapshot.IsAvailable ? ModernUi.Surface : ModernUi.SurfaceAlt; - _button.BorderBrush = displaySnapshot.IsAvailable ? ModernUi.Stroke : ModernUi.StrokeStrong; + _button.Background = displaySnapshot.IsAvailable ? ModernUi.AccentSoft : ModernUi.SurfaceAlt; + _button.BorderBrush = displaySnapshot.IsAvailable ? ModernUi.Accent : ModernUi.StrokeStrong; ToolTipService.SetToolTip(_button, loading ? AppLocalizer.T("天气正在刷新", "Weather is refreshing") : BuildTooltip(displaySnapshot)); AutomationProperties.SetName(_button, loading ? AppLocalizer.T("天气正在刷新", "Weather is refreshing") : BuildTooltip(displaySnapshot)); } @@ -187,59 +198,90 @@ public sealed class WeatherCapsuleControl : UserControl private UIElement BuildFlyoutContent() { var snapshot = _snapshot; - var refresh = ModernUi.PillButton(AppLocalizer.T("刷新", "Refresh"), "\uE72C", async () => await RefreshAsync(), primary: true); + var refresh = ModernUi.IconButton("\uE72C", AppLocalizer.T("刷新天气", "Refresh weather"), async () => await RefreshAsync()); refresh.HorizontalAlignment = HorizontalAlignment.Right; Grid.SetColumn(refresh, 2); - return new StackPanel + var heading = new Grid { - Width = 280, - Padding = new Thickness(4), + ColumnSpacing = 12, + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition(), + new ColumnDefinition { Width = GridLength.Auto } + }, + Children = + { + BuildFlyoutIcon(snapshot), + BuildTitleBlock(snapshot), + refresh + } + }; + + var metrics = new Grid { ColumnSpacing = 8, RowSpacing = 8 }; + metrics.ColumnDefinitions.Add(new ColumnDefinition()); + metrics.ColumnDefinitions.Add(new ColumnDefinition()); + metrics.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + metrics.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + AddMetric(metrics, 0, 0, AppLocalizer.T("体感", "Feels like"), snapshot.FeelsLikeText, "\uE706"); + AddMetric(metrics, 0, 1, AppLocalizer.T("湿度", "Humidity"), snapshot.HumidityText, "\uE81F"); + AddMetric(metrics, 1, 0, AppLocalizer.T("风速", "Wind"), snapshot.WindText, "\uE9CA"); + AddMetric(metrics, 1, 1, AppLocalizer.T("今日温度", "Today"), snapshot.RangeText, "\uE787"); + + var footer = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition(), + new ColumnDefinition { Width = GridLength.Auto } + } + }; + footer.Children.Add(ModernUi.Text( + $"{AppLocalizer.T("更新时间", "Updated")} · {snapshot.UpdatedText}", + 11.5, + foreground: ModernUi.TextSecondary, + maxLines: 1)); + var queryLevel = ModernUi.SmallBadge(snapshot.QueryLevel, ModernUi.Accent, ModernUi.AccentSoft); + Grid.SetColumn(queryLevel, 1); + footer.Children.Add(queryLevel); + + var panel = new StackPanel + { + Width = 320, Spacing = 12, Children = { - new Grid - { - ColumnSpacing = 12, - ColumnDefinitions = - { - new ColumnDefinition { Width = GridLength.Auto }, - new ColumnDefinition(), - new ColumnDefinition { Width = GridLength.Auto } - }, - Children = - { - BuildFlyoutIcon(snapshot), - BuildTitleBlock(snapshot), - refresh - } - }, - BuildDetailLine(AppLocalizer.T("体感", "Feels like"), snapshot.FeelsLikeText, "\uE706"), - BuildDetailLine(AppLocalizer.T("湿度", "Humidity"), snapshot.HumidityText, "\uE81F"), - BuildDetailLine(AppLocalizer.T("风速", "Wind"), snapshot.WindText, "\uE9CA"), - BuildDetailLine(AppLocalizer.T("今日温度", "Today"), snapshot.RangeText, "\uE787"), - BuildDetailLine(AppLocalizer.T("更新时间", "Updated"), snapshot.UpdatedText, "\uE823"), - snapshot.ErrorMessage is null - ? new Border { Height = 0 } - : ModernUi.Card( - ModernUi.Text(snapshot.ErrorMessage, 12, foreground: ModernUi.TextSecondary, maxLines: 3), - new Thickness(10), - radius: 8, - background: ModernUi.SurfaceAlt) + heading, + new Border { Height = 1, Background = ModernUi.Stroke }, + metrics, + footer } }; + if (!string.IsNullOrWhiteSpace(snapshot.ErrorMessage)) + { + panel.Children.Add(new Border + { + Padding = new Thickness(10, 8, 10, 8), + CornerRadius = new CornerRadius(6), + Background = ModernUi.DangerSoft, + Child = ModernUi.Text(snapshot.ErrorMessage, 12, foreground: ModernUi.Danger, maxLines: 3) + }); + } + return panel; } private static StackPanel BuildTitleBlock(TitleWeatherSnapshot snapshot) { var panel = new StackPanel { - Spacing = 1, + Spacing = 2, VerticalAlignment = VerticalAlignment.Center, Children = { - ModernUi.Text(snapshot.Location, 17, FontWeights.SemiBold, maxLines: 1), - ModernUi.Text($"{snapshot.Condition} · {snapshot.TemperatureText} · {snapshot.QueryLevel}", 13, foreground: ModernUi.TextSecondary, maxLines: 1) + ModernUi.Text(snapshot.TemperatureText, 30, FontWeights.SemiBold, maxLines: 1), + ModernUi.Text(snapshot.Condition, 14, FontWeights.SemiBold, maxLines: 1), + ModernUi.Text(snapshot.Location, 12, foreground: ModernUi.TextSecondary, maxLines: 1) } }; Grid.SetColumn(panel, 1); @@ -252,11 +294,11 @@ public sealed class WeatherCapsuleControl : UserControl icon.Update(snapshot, CanAnimate()); return new Border { - Width = 52, - Height = 52, + Width = 66, + Height = 66, CornerRadius = new CornerRadius(8), - Background = ModernUi.AccentSoft, - BorderBrush = ModernUi.Stroke, + Background = snapshot.IsAvailable ? ModernUi.AccentSoft : ModernUi.SurfaceAlt, + BorderBrush = snapshot.IsAvailable ? ModernUi.Accent : ModernUi.StrokeStrong, BorderThickness = new Thickness(1), Child = icon }; @@ -270,27 +312,55 @@ public sealed class WeatherCapsuleControl : UserControl text.VerticalAlignment = VerticalAlignment.Center; } - private static UIElement BuildDetailLine(string label, string value, string glyph) + private static void AddMetric(Grid grid, int row, int column, string label, string value, string glyph) { - var grid = new Grid { ColumnSpacing = 10 }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition()); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.Children.Add(ModernUi.IconTile(glyph, 30, ModernUi.SurfaceAlt, ModernUi.TextSecondary, 13)); + var tile = new Border + { + MinHeight = 62, + Padding = new Thickness(10, 8, 10, 8), + CornerRadius = new CornerRadius(6), + Background = ModernUi.SurfaceAlt, + BorderBrush = ModernUi.Stroke, + BorderThickness = new Thickness(1), + Child = new Grid + { + ColumnSpacing = 9, + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition() + }, + Children = + { + ModernUi.IconTile(glyph, 30, ModernUi.Surface, ModernUi.Accent, 13), + BuildMetricCopy(label, value) + } + } + }; + Grid.SetRow(tile, row); + Grid.SetColumn(tile, column); + grid.Children.Add(tile); + } - var title = ModernUi.Text(label, 13, FontWeights.SemiBold, ModernUi.TextSecondary, maxLines: 1); - Grid.SetColumn(title, 1); - grid.Children.Add(title); - - var text = ModernUi.Text(value, 13, FontWeights.SemiBold, ModernUi.TextPrimary, maxLines: 1); - Grid.SetColumn(text, 2); - grid.Children.Add(text); - return grid; + private static StackPanel BuildMetricCopy(string label, string value) + { + var copy = new StackPanel + { + Spacing = 1, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + ModernUi.Text(label, 11.5, foreground: ModernUi.TextSecondary, maxLines: 1), + ModernUi.Text(value, 13, FontWeights.SemiBold, ModernUi.TextPrimary, maxLines: 1) + } + }; + Grid.SetColumn(copy, 1); + return copy; } private bool CanAnimate() { - if (!_settingsService.Current.AnimationsEnabled || _accessibility.HighContrast) + if (!_settingsService.Current.AnimationsEnabled || _accessibility.HighContrast || _uiPerformanceCoordinator.IsLightMode) { return false; } @@ -309,6 +379,8 @@ public sealed class WeatherCapsuleControl : UserControl { _settingsService.PropertyChanged -= SettingsService_PropertyChanged; _settingsService.PropertyChanged += SettingsService_PropertyChanged; + _uiPerformanceCoordinator.LightModeChanged -= UiPerformanceCoordinator_LightModeChanged; + _uiPerformanceCoordinator.LightModeChanged += UiPerformanceCoordinator_LightModeChanged; TrySubscribeHighContrastEvents(); ApplyMotionPreference(); } @@ -316,6 +388,7 @@ public sealed class WeatherCapsuleControl : UserControl private void WeatherCapsuleControl_Unloaded(object sender, RoutedEventArgs e) { _settingsService.PropertyChanged -= SettingsService_PropertyChanged; + _uiPerformanceCoordinator.LightModeChanged -= UiPerformanceCoordinator_LightModeChanged; TryUnsubscribeHighContrastEvents(); _loadingRing.IsActive = false; } @@ -323,6 +396,9 @@ public sealed class WeatherCapsuleControl : UserControl private void SettingsService_PropertyChanged(object? sender, PropertyChangedEventArgs e) => DispatcherQueue.TryEnqueue(ApplyMotionPreference); + private void UiPerformanceCoordinator_LightModeChanged(object? sender, UiPerformanceModeChangedEventArgs e) + => DispatcherQueue.TryEnqueue(ApplyMotionPreference); + private void Accessibility_HighContrastChanged(AccessibilitySettings sender, object args) => DispatcherQueue.TryEnqueue(ApplyMotionPreference); diff --git a/src/box-winUI/MainWindow.xaml.cs b/src/box-winUI/MainWindow.xaml.cs index 1078378..0fa9534 100644 --- a/src/box-winUI/MainWindow.xaml.cs +++ b/src/box-winUI/MainWindow.xaml.cs @@ -81,6 +81,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost private bool _lastResponsivePhone; private double _lastResponsiveWidth; private bool _windowMoveLoopActive; + private bool _responsiveShellUpdateDeferred; private AppShell? _shell; private WeatherCapsuleControl? WeatherCapsule; private bool _syncingQuickSettings; @@ -135,11 +136,19 @@ public sealed partial class MainWindow : Window, IShellNavigationHost if (active) { _moveLightModeScope ??= _uiPerformanceCoordinator.EnterLightMode("main-window-move"); + _responsiveShellTimer?.Stop(); + _pageTransitionStoryboard?.Stop(); + _pageTransitionStoryboard = null; } else { _moveLightModeScope?.Dispose(); _moveLightModeScope = null; + if (_responsiveShellUpdateDeferred) + { + _responsiveShellUpdateDeferred = false; + ScheduleResponsiveShellUpdate(); + } } ApplyShellTheme(); @@ -278,6 +287,12 @@ public sealed partial class MainWindow : Window, IShellNavigationHost return; } + if (_windowMoveLoopActive) + { + _responsiveShellUpdateDeferred = true; + return; + } + _responsiveShellTimer ??= CreateOneShotTimer( TimeSpan.FromMilliseconds(72), ApplyResponsiveShell); @@ -1960,7 +1975,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost private bool CanAnimatePageTransition() { - if (!_settingsService.Current.AnimationsEnabled || _accessibility.HighContrast) + if (!_settingsService.Current.AnimationsEnabled || _accessibility.HighContrast || _uiPerformanceCoordinator.IsLightMode) { return false; } @@ -2341,6 +2356,17 @@ public sealed partial class MainWindow : Window, IShellNavigationHost { var settings = _settingsService.Current; var isDark = ThemeService.ShouldUseDarkPalette(settings.Theme); + if (_windowMoveLoopActive) + { + var solid = new SolidColorBrush(isDark + ? Color.FromArgb(255, 28, 30, 31) + : Color.FromArgb(255, 247, 248, 249)); + AppTitleBar.Background = solid; + RootNavigation.Background = solid; + QuickSettingsPanel.Background = solid; + RootLayout.Background = solid; + return; + } var stableMaterial = _windowMoveLoopActive || _accessibility.HighContrast || !settings.HardwareAccelerationEnabled; var transparentBackdrop = !stableMaterial && UsesTransparentBackdrop(settings.WindowBackdrop); var topBarMaterial = stableMaterial ? "solid" : settings.TopBarMaterial; diff --git a/src/box-winUI/Views/HomePage.cs b/src/box-winUI/Views/HomePage.cs index 597df64..1975e4a 100644 --- a/src/box-winUI/Views/HomePage.cs +++ b/src/box-winUI/Views/HomePage.cs @@ -32,6 +32,7 @@ public sealed class HomePage : Page private readonly ISettingsService _settingsService = AppServices.GetRequiredService(); private readonly IAppInstallerUpdateService _updateService = AppServices.GetRequiredService(); private readonly IAppVersionService _versionService = AppServices.GetRequiredService(); + private readonly IUiPerformanceCoordinator _uiPerformanceCoordinator = AppServices.GetRequiredService(); private readonly StackPanel _rootStack = new() { Padding = new Thickness(24, 20, 24, 28), @@ -92,6 +93,7 @@ public sealed class HomePage : Page Background = ModernUi.AppBackground; Content = BuildContent(); Loaded += HomePage_Loaded; + Unloaded += HomePage_Unloaded; SizeChanged += (_, _) => ArrangeHomeLayout(); _homeSearchBox.KeyDown += HomeSearchBox_KeyDown; } @@ -823,6 +825,8 @@ public sealed class HomePage : Page private async void HomePage_Loaded(object sender, RoutedEventArgs e) { + _uiPerformanceCoordinator.LightModeChanged -= UiPerformanceCoordinator_LightModeChanged; + _uiPerformanceCoordinator.LightModeChanged += UiPerformanceCoordinator_LightModeChanged; ArrangeHomeLayout(); if (IsDashboardHome()) @@ -840,6 +844,23 @@ public sealed class HomePage : Page await InitializeGlobeAsync(); } + private void HomePage_Unloaded(object sender, RoutedEventArgs e) + { + _uiPerformanceCoordinator.LightModeChanged -= UiPerformanceCoordinator_LightModeChanged; + } + + private void UiPerformanceCoordinator_LightModeChanged(object? sender, UiPerformanceModeChangedEventArgs e) + { + if (DispatcherQueue.HasThreadAccess) + { + SendPerformanceMode(); + } + else + { + DispatcherQueue.TryEnqueue(() => SendPerformanceMode()); + } + } + private async Task InitializeGlobeAsync() { try @@ -882,6 +903,7 @@ public sealed class HomePage : Page _solarSystemReady = true; SendSolarSystemSettings(sender); + SendPerformanceMode(sender); await SendEphemerisAsync(sender); } @@ -1023,6 +1045,25 @@ public sealed class HomePage : Page } } + private void SendPerformanceMode(CoreWebView2? webView = null) + { + try + { + var target = webView ?? _globeView.CoreWebView2; + if (target is null) + { + return; + } + var json = JsonSerializer.Serialize( + new { type = "performanceMode", lightMode = _uiPerformanceCoordinator.IsLightMode }, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + target.PostWebMessageAsJson(json); + } + catch + { + } + } + private void ShowGlobeError(Exception exception) { _globeHost.Children.Clear(); @@ -1192,5 +1233,3 @@ public sealed class HomePage : Page }; } } - - diff --git a/src/box-winUI/Views/Tools/DevEnvironmentConfigToolPage.cs b/src/box-winUI/Views/Tools/DevEnvironmentConfigToolPage.cs index 28bf41a..f955759 100644 --- a/src/box-winUI/Views/Tools/DevEnvironmentConfigToolPage.cs +++ b/src/box-winUI/Views/Tools/DevEnvironmentConfigToolPage.cs @@ -212,6 +212,7 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase return; } + _terminalProgress.Value = 0; SetTerminalBusy(true, AppLocalizer.T("正在准备终端安装...", "Preparing terminal setup...")); try { @@ -264,6 +265,17 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase private void ApplyTerminalSnapshot(DevTerminalSnapshot snapshot) { _terminalSnapshot = snapshot; + if (!string.IsNullOrWhiteSpace(snapshot.Error)) + { + _terminalStatus.Text = AppLocalizer.T("终端环境检测失败", "Terminal environment detection failed"); + _terminalDetail.Text = AppLocalizer.SanitizeSensitiveText(snapshot.Error, 200); + if (_terminalOpenButton is not null) + { + _terminalOpenButton.IsEnabled = false; + } + return; + } + var terminal = snapshot.WindowsTerminalInstalled ? $"Windows Terminal {ValueOrDash(snapshot.WindowsTerminalVersion)}" : AppLocalizer.T("Windows Terminal 未安装", "Windows Terminal not installed"); @@ -273,14 +285,18 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase _terminalStatus.Text = $"{terminal} · {powershell}"; var defaultState = !snapshot.SupportsDefaultTerminal - ? AppLocalizer.T($"Windows {snapshot.WindowsBuild} 不支持系统默认终端切换", $"Windows {snapshot.WindowsBuild} does not support changing the system default terminal") + ? AppLocalizer.T($"Windows {snapshot.WindowsBuild}.{snapshot.WindowsRevision} 不支持系统默认终端切换", $"Windows {snapshot.WindowsBuild}.{snapshot.WindowsRevision} 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}"; + var privilegeState = snapshot.IsAdministrator + ? AppLocalizer.T("管理员权限可用", "Administrator access available") + : AppLocalizer.T("无管理员权限时使用当前用户安装", "Current-user fallback will be used without administrator access"); + var paths = $"wt: {ValueOrDash(snapshot.WindowsTerminalPath)} · pwsh: {ValueOrDash(snapshot.PowerShellPath)}"; + _terminalDetail.Text = $"{defaultState} · {profileState}\n{privilegeState} · {snapshot.Architecture}\n{paths}"; if (_terminalOpenButton is not null) { _terminalOpenButton.IsEnabled = snapshot.PowerShellInstalled || snapshot.WindowsTerminalInstalled; @@ -295,7 +311,8 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase } if (_terminalOpenButton is not null) { - _terminalOpenButton.IsEnabled = !busy && (_terminalSnapshot?.IsReady == true || _terminalSnapshot?.PowerShellInstalled == true); + _terminalOpenButton.IsEnabled = !busy && + (_terminalSnapshot?.WindowsTerminalInstalled == true || _terminalSnapshot?.PowerShellInstalled == true); } _terminalProgress.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; _terminalProgress.IsIndeterminate = busy && _terminalProgress.Value <= 0; @@ -542,32 +559,11 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase try { var script = Path.Combine(Path.GetTempPath(), $"ymhut-build-{plan.EnvironmentId}-{Guid.NewGuid():N}.cmd"); - File.WriteAllLines(script, [ - "@echo off", - "chcp 65001 > nul", - $"echo {plan.EnvironmentName} {plan.Version.Version}", - $"echo Source download target: {item.TargetPath}", - "echo.", - "if not exist \"%~dp0\" mkdir \"%~dp0\"", - $"set \"ARCHIVE={item.TargetPath}\"", - $"set \"WORKDIR=%USERPROFILE%\\YMhutBuilds\\{plan.EnvironmentId}-{plan.Version.Version}\"", - "echo Waiting for source archive...", - ":wait_download", - "if not exist \"%ARCHIVE%\" (timeout /t 2 > nul & goto wait_download)", - "mkdir \"%WORKDIR%\" 2>nul", - "cd /d \"%WORKDIR%\"", - "echo.", - "echo Build recipe:", - $"echo {plan.BuildRecipe.Replace(Environment.NewLine, " & echo ")}", - "echo.", - "echo Extract the archive here, review prerequisites, then run the vendor build commands above.", - "echo.", - "pause" - ]); + File.WriteAllLines(script, DevEnvironmentBuildScript.Create(plan, item.TargetPath)); Process.Start(new ProcessStartInfo { FileName = "cmd.exe", - Arguments = $"/k \"{script}\"", + Arguments = $"/d /c \"\"{script}\"\"", UseShellExecute = true }); }