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
});
}