完善开发环境工具、音乐服务和界面性能
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="local-feed" value="./.cache/nuget/feed" />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
+2
-2
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+14
-8
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
namespace YMhut.Box.Core.DevEnvironments;
|
||||
|
||||
public static class DevEnvironmentBuildScript
|
||||
{
|
||||
public static IReadOnlyList<string> 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<string>
|
||||
{
|
||||
"@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);
|
||||
}
|
||||
@@ -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<string> ResolveCommandAsync(string command, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<DevTerminalCommandResult> 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,17 +212,30 @@ public sealed class DevTerminalSetupService(
|
||||
public Task<bool> 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<bool> 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<bool> 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))
|
||||
{
|
||||
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);
|
||||
if (!install.Succeeded)
|
||||
@@ -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<string> 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<string> 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<DevTerminalCommandResult> RunAsync(string fileName, string arguments, CancellationToken cancellationToken = default)
|
||||
@@ -338,6 +423,34 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab
|
||||
public async Task<bool> 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<bool> 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<string>();
|
||||
var seen = new HashSet<string>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,10 @@ public sealed class DownloadQueueStore(AppPaths paths) : IDownloadQueueStore
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(_path);
|
||||
return await JsonSerializer.DeserializeAsync<List<DownloadItem>>(stream, JsonOptions, cancellationToken)
|
||||
var items = await JsonSerializer.DeserializeAsync<List<DownloadItem>>(stream, JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? [];
|
||||
return items.Select(DownloadOpenPolicy.Normalize).ToArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -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" => "取消收藏歌曲",
|
||||
|
||||
@@ -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<IReadOnlyList<KugouPlaybackCandidate>> GetPlaybackCandidatesAsync(
|
||||
(string Hash, string AlbumId) identity,
|
||||
KugouSongData? metadata,
|
||||
int start,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fallback = QualityOrder.Skip(start).ToArray();
|
||||
var variants = new Dictionary<string, string>(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<string, string> 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<JsonNode> 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<string> TextValues(JsonNode? node)
|
||||
{
|
||||
if (node is JsonValue value && value.TryGetValue<string>(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<MusicStreamProbe> 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);
|
||||
}
|
||||
|
||||
@@ -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<byte> data, bool hexadecimal)
|
||||
=> hexadecimal
|
||||
? Convert.ToHexString(data).Chunk(2).Select(chars => new string(chars)).Aggregate(string.Empty, (current, item) => string.IsNullOrEmpty(current) ? item : current + " " + item)
|
||||
: Encoding.UTF8.GetString(data);
|
||||
{
|
||||
if (!hexadecimal)
|
||||
{
|
||||
return Encoding.UTF8.GetString(data);
|
||||
}
|
||||
|
||||
var encoded = Convert.ToHexString(data);
|
||||
return string.Join(' ', encoded.Chunk(2).Select(chars => new string(chars)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DevTerminalInstallProgress>();
|
||||
|
||||
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<OperationCanceledException>(
|
||||
() => 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 <archive>\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<FormatException>(() => SerialPayloadCodec.Parse("GG", hexadecimal: true));
|
||||
Assert.ThrowsExactly<FormatException>(() => SerialPayloadCodec.Parse("ABC", hexadecimal: true));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UiPerformanceCoordinatorKeepsLightModeUntilLastScopeCloses()
|
||||
{
|
||||
var coordinator = new UiPerformanceCoordinator();
|
||||
var states = new List<bool>();
|
||||
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<T> : IProgress<T>
|
||||
{
|
||||
public List<T> Items { get; } = [];
|
||||
|
||||
public void Report(T value) => Items.Add(value);
|
||||
}
|
||||
|
||||
private sealed class FakeTerminalPlatform : IDevTerminalPlatform
|
||||
{
|
||||
public ConcurrentDictionary<string, string> Commands { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public List<string> 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<string> ResolveCommandAsync(string command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(Commands.TryGetValue(command, out var path) ? path : string.Empty);
|
||||
}
|
||||
|
||||
public Task<DevTerminalCommandResult> 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<bool> InstallWindowsTerminalFallbackAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
TerminalFallbackCalls++;
|
||||
if (TerminalFallbackResult)
|
||||
{
|
||||
Commands["wt.exe"] = @"C:\WindowsApps\wt.exe";
|
||||
}
|
||||
return Task.FromResult(TerminalFallbackResult);
|
||||
}
|
||||
|
||||
public Task<bool> 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<bool> 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<bool> OpenTerminalAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -330,16 +330,28 @@ public sealed class KugouMusicProviderTests
|
||||
public async Task AccountPlaybackFallsBackByQualityAndRejectsHttpAddress()
|
||||
{
|
||||
var qualities = new List<string>();
|
||||
var hashes = new List<string>();
|
||||
var handler = new StubHttpHandler((request, _) =>
|
||||
{
|
||||
if (request.RequestUri!.AbsolutePath == "/v7/get_all_list") return Task.FromResult(JsonResponse(UserLists()));
|
||||
if (request.RequestUri.AbsolutePath == "/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}}"));
|
||||
}));
|
||||
|
||||
@@ -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<[^>]+>\\(\"(?<id>[^\"]+)\"",
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\YMhut.Box.Core\YMhut.Box.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\box-winUI\Services\UiPerformanceCoordinator.cs" Link="Shared\UiPerformanceCoordinator.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="TestData\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class WeatherCapsuleControl : UserControl
|
||||
private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>();
|
||||
private readonly AccessibilitySettings _accessibility = new();
|
||||
private readonly UISettings _systemUiSettings = new();
|
||||
private readonly IUiPerformanceCoordinator _uiPerformanceCoordinator = AppServices.GetRequiredService<IUiPerformanceCoordinator>();
|
||||
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,18 +198,11 @@ 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
|
||||
{
|
||||
Width = 280,
|
||||
Padding = new Thickness(4),
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new Grid
|
||||
var heading = new Grid
|
||||
{
|
||||
ColumnSpacing = 12,
|
||||
ColumnDefinitions =
|
||||
@@ -213,33 +217,71 @@ public sealed class WeatherCapsuleControl : UserControl
|
||||
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)
|
||||
};
|
||||
|
||||
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 =
|
||||
{
|
||||
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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed class HomePage : Page
|
||||
private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>();
|
||||
private readonly IAppInstallerUpdateService _updateService = AppServices.GetRequiredService<IAppInstallerUpdateService>();
|
||||
private readonly IAppVersionService _versionService = AppServices.GetRequiredService<IAppVersionService>();
|
||||
private readonly IUiPerformanceCoordinator _uiPerformanceCoordinator = AppServices.GetRequiredService<IUiPerformanceCoordinator>();
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user