完善开发环境工具、音乐服务和界面性能

This commit is contained in:
2026-08-17 03:09:59 +08:00
parent 8b2dd89d2f
commit 92e5c330f2
22 changed files with 1185 additions and 156 deletions
-1
View File
@@ -2,7 +2,6 @@
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<add key="local-feed" value="./.cache/nuget/feed" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" /> <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources> </packageSources>
</configuration> </configuration>
+2 -2
View File
@@ -151,7 +151,7 @@ Release 构建时:
常用测试命令: 常用测试命令:
```powershell ```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 dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore
``` ```
@@ -483,7 +483,7 @@ Everything up-to-date
```powershell ```powershell
git pull 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 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 dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore
``` ```
+2 -2
View File
@@ -151,7 +151,7 @@ Release 构建时:
常用测试命令: 常用测试命令:
```powershell ```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 dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore
``` ```
@@ -483,7 +483,7 @@ Everything up-to-date
```powershell ```powershell
git pull 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 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 dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore
``` ```
+8 -1
View File
@@ -2,6 +2,10 @@
setlocal setlocal
chcp 65001 >nul 2>&1 chcp 65001 >nul 2>&1
set "ROOT=%~dp0" 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%" if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%" cd /d "%ROOT%"
@@ -15,5 +19,8 @@ set "EXITCODE=%ERRORLEVEL%"
if not "%EXITCODE%"=="0" ( if not "%EXITCODE%"=="0" (
echo. echo.
echo [ERROR] WinUI build failed with exit code %EXITCODE%. 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 -1
View File
@@ -1,7 +1,7 @@
# Release Process # Release Process
1. Update `version.json`. 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`. 3. Run `dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Release --no-restore`.
4. Run `build.bat --target=both`. 4. Run `build.bat --target=both`.
5. Verify `installer_output/*.msix`, `installer_output/*.appinstaller`, and the Inno Setup EXE. 5. Verify `installer_output/*.msix`, `installer_output/*.appinstaller`, and the Inno Setup EXE.
+14 -8
View File
@@ -41,7 +41,6 @@ $ServerPublicRoot = Join-Path $Root 'server\update\public'
$ServerDownloadRoot = Join-Path $ServerPublicRoot 'downloads' $ServerDownloadRoot = Join-Path $ServerPublicRoot 'downloads'
$ToolStateRoot = Join-Path $Root '.cache\tool_state' $ToolStateRoot = Join-Path $Root '.cache\tool_state'
$NuGetRoot = Join-Path $Root '.cache\nuget' $NuGetRoot = Join-Path $Root '.cache\nuget'
$LocalNuGetFeedRoot = Join-Path $NuGetRoot 'feed'
$AppDataRoot = Join-Path $ToolStateRoot 'appdata' $AppDataRoot = Join-Path $ToolStateRoot 'appdata'
$PackageIdentityName = 'YMhut.Box' $PackageIdentityName = 'YMhut.Box'
@@ -668,7 +667,14 @@ function Write-LayoutManifests([string] $Directory) {
} }
function Get-FileSha256([string] $Path) { 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 { function Sync-UpdateServerPackages {
@@ -1468,7 +1474,6 @@ pause
New-Directory $AppDataRoot New-Directory $AppDataRoot
New-Directory $NuGetRoot New-Directory $NuGetRoot
New-Directory $LocalNuGetFeedRoot
New-Directory $OutputRoot New-Directory $OutputRoot
$env:APPDATA = $AppDataRoot $env:APPDATA = $AppDataRoot
$env:LOCALAPPDATA = $AppDataRoot $env:LOCALAPPDATA = $AppDataRoot
@@ -1490,22 +1495,23 @@ Invoke-DotNet @(
'restore', 'restore',
$Solution, $Solution,
'--configfile', $NuGetConfig, '--configfile', $NuGetConfig,
'--ignore-failed-sources',
'--disable-parallel' '--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)) { foreach ($ridProject in @($Project, $InstallerBootstrapProject)) {
Invoke-DotNet @( Invoke-DotNet @(
'restore', 'restore',
$ridProject, $ridProject,
'-r', 'win-x64', '-r', 'win-x64',
'--configfile', $NuGetConfig, '--configfile', $NuGetConfig,
'--ignore-failed-sources',
'--disable-parallel' '--disable-parallel'
) )
} }
if (-not $SkipTests) {
Invoke-DotNet @('test', $Solution, '-c', $Configuration, '--no-restore')
}
Publish-UnpackagedApp $versionInfo 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.IO.Compression;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Security.Principal;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Win32; using Microsoft.Win32;
using YMhut.Box.Core;
using YMhut.Box.Core.App; using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging; using YMhut.Box.Core.Logging;
@@ -23,7 +26,9 @@ public sealed record DevTerminalSnapshot(
bool IsPowerShellDefaultProfile, bool IsPowerShellDefaultProfile,
int WindowsBuild, int WindowsBuild,
string Architecture, string Architecture,
string Error = "") string Error = "",
bool IsAdministrator = false,
int WindowsRevision = 0)
{ {
public bool IsReady => WindowsTerminalInstalled && PowerShellInstalled; public bool IsReady => WindowsTerminalInstalled && PowerShellInstalled;
} }
@@ -49,8 +54,12 @@ public interface IDevTerminalPlatform
{ {
int WindowsBuild { get; } int WindowsBuild { get; }
int WindowsRevision { get; }
string Architecture { get; } string Architecture { get; }
bool IsAdministrator { get; }
Task<string> ResolveCommandAsync(string command, CancellationToken cancellationToken = default); Task<string> ResolveCommandAsync(string command, CancellationToken cancellationToken = default);
Task<DevTerminalCommandResult> RunAsync(string fileName, string arguments, 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 powerShellPath = await powerShellPathTask.ConfigureAwait(false);
var terminalVersion = string.IsNullOrWhiteSpace(terminalPath) var terminalVersion = string.IsNullOrWhiteSpace(terminalPath)
? string.Empty ? string.Empty
: await ReadVersionAsync("wt.exe", "--version", cancellationToken).ConfigureAwait(false); : await ReadVersionAsync(terminalPath, "--version", cancellationToken).ConfigureAwait(false);
var powerShellVersion = string.IsNullOrWhiteSpace(powerShellPath) var powerShellVersion = string.IsNullOrWhiteSpace(powerShellPath)
? string.Empty ? 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( return new DevTerminalSnapshot(
!string.IsNullOrWhiteSpace(await wingetPathTask.ConfigureAwait(false)), !string.IsNullOrWhiteSpace(await wingetPathTask.ConfigureAwait(false)),
@@ -109,11 +118,13 @@ public sealed class DevTerminalSetupService(
!string.IsNullOrWhiteSpace(powerShellPath), !string.IsNullOrWhiteSpace(powerShellPath),
powerShellVersion, powerShellVersion,
powerShellPath, powerShellPath,
SupportsDefaultTerminal(platform.WindowsBuild), SupportsDefaultTerminal(platform.WindowsBuild, platform.WindowsRevision),
platform.IsWindowsTerminalDefault(), platform.IsWindowsTerminalDefault(),
platform.IsPowerShellDefaultProfile(), platform.IsPowerShellDefaultProfile(),
platform.WindowsBuild, platform.WindowsBuild,
platform.Architecture); platform.Architecture,
IsAdministrator: platform.IsAdministrator,
WindowsRevision: platform.WindowsRevision);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -124,8 +135,8 @@ public sealed class DevTerminalSetupService(
await WriteLogAsync("Warning", "Terminal environment detection failed", exception.Message, cancellationToken).ConfigureAwait(false); await WriteLogAsync("Warning", "Terminal environment detection failed", exception.Message, cancellationToken).ConfigureAwait(false);
return new DevTerminalSnapshot( return new DevTerminalSnapshot(
false, false, string.Empty, string.Empty, false, string.Empty, string.Empty, false, false, string.Empty, string.Empty, false, string.Empty, string.Empty,
SupportsDefaultTerminal(platform.WindowsBuild), false, false, SupportsDefaultTerminal(platform.WindowsBuild, platform.WindowsRevision), false, false,
platform.WindowsBuild, platform.Architecture, exception.Message); platform.WindowsBuild, platform.Architecture, exception.Message, platform.IsAdministrator, platform.WindowsRevision);
} }
} }
@@ -142,7 +153,7 @@ public sealed class DevTerminalSetupService(
if (before.WingetAvailable) if (before.WingetAvailable)
{ {
progress?.Report(new DevTerminalInstallProgress("terminal", "Installing or upgrading Windows Terminal...", 18)); 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) if (!terminalSucceeded)
{ {
messages.Add("Windows Terminal could not be installed with winget; using the official package fallback."); 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)); 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) if (!powerShellSucceeded)
{ {
messages.Add("PowerShell 7 could not be installed with winget; using the official user-level fallback."); 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) public Task<bool> OpenTerminalAsync(CancellationToken cancellationToken = default)
=> platform.OpenTerminalAsync(cancellationToken); => platform.OpenTerminalAsync(cancellationToken);
public static bool SupportsDefaultTerminal(int windowsBuild) public static bool SupportsDefaultTerminal(int windowsBuild, int windowsRevision)
=> windowsBuild >= 22000 || windowsBuild >= 19045; => 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 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); var upgrade = await platform.RunAsync("winget.exe", $"upgrade {common}", cancellationToken).ConfigureAwait(false);
if (upgrade.Succeeded || ContainsNoUpgrade(upgrade)) if (upgrade.Succeeded || ContainsNoUpgrade(upgrade))
{
var installedCommand = await platform.ResolveCommandAsync(expectedCommand, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(installedCommand))
{ {
return true; return true;
} }
}
var install = await platform.RunAsync("winget.exe", $"install {common}", cancellationToken).ConfigureAwait(false); var install = await platform.RunAsync("winget.exe", $"install {common}", cancellationToken).ConfigureAwait(false);
if (!install.Succeeded) if (!install.Succeeded)
@@ -239,7 +263,12 @@ public sealed class DevTerminalSetupService(
} }
private Task WriteLogAsync(string level, string message, string detail, CancellationToken cancellationToken) 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")] [SupportedOSPlatform("windows")]
@@ -264,14 +293,70 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab
public int WindowsBuild => Environment.OSVersion.Version.Build; 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 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) public async Task<string> ResolveCommandAsync(string command, CancellationToken cancellationToken = default)
{ {
var result = await RunAsync("where.exe", command, cancellationToken).ConfigureAwait(false); 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 ? result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? string.Empty
: 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) 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) public async Task<bool> InstallPowerShellFallbackAsync(CancellationToken cancellationToken = default)
{ {
var architecture = RuntimeInformation.OSArchitecture == global::System.Runtime.InteropServices.Architecture.Arm64 ? "arm64" : "x64"; 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( var asset = await FindReleaseAssetAsync(
"https://api.github.com/repos/PowerShell/PowerShell/releases/latest", "https://api.github.com/repos/PowerShell/PowerShell/releases/latest",
name => name.EndsWith($"win-{architecture}.zip", StringComparison.OrdinalIgnoreCase) && 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) public async Task<bool> ConfigureDefaultsAsync(string powerShellPath, CancellationToken cancellationToken = default)
{ {
var configured = false; var configured = false;
if (DevTerminalSetupService.SupportsDefaultTerminal(WindowsBuild) && var terminalPath = await ResolveCommandAsync("wt.exe", cancellationToken).ConfigureAwait(false);
!string.IsNullOrWhiteSpace(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(); BackupDefaultTerminalRegistry();
using var key = Registry.CurrentUser.CreateSubKey(ConsoleStartupKey, writable: true); 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"); var directory = Path.Combine(_paths.Data, "terminal-backups");
Directory.CreateDirectory(directory); 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 catch
{ {
@@ -519,7 +640,7 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab
JsonObject root; JsonObject root;
if (File.Exists(path)) 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); File.Copy(path, backup, overwrite: false);
root = JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject(); 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) ?? var path = candidates.FirstOrDefault(File.Exists) ??
candidates.FirstOrDefault(candidate => Directory.Exists(Path.GetDirectoryName(candidate)!)) ?? candidates.FirstOrDefault(candidate => Directory.Exists(Path.GetDirectoryName(candidate)!)) ??
candidates[1]; candidates[0];
if (createDirectory) if (createDirectory)
{ {
Directory.CreateDirectory(Path.GetDirectoryName(path)!); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
@@ -579,19 +700,17 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab
private static void AddUserPath(string directory) private static void AddUserPath(string directory)
{ {
var current = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; var current = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty;
var entries = current.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); var updated = DevTerminalPathPolicy.Merge(current, directory);
if (!entries.Contains(directory, StringComparer.OrdinalIgnoreCase)) if (!string.Equals(current, updated, StringComparison.Ordinal))
{ {
entries.Add(directory); Environment.SetEnvironmentVariable("PATH", updated, EnvironmentVariableTarget.User);
Environment.SetEnvironmentVariable("PATH", string.Join(';', entries), EnvironmentVariableTarget.User);
} }
var processPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; var processPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
var processEntries = processPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); var updatedProcessPath = DevTerminalPathPolicy.Merge(processPath, directory);
if (!processEntries.Contains(directory, StringComparer.OrdinalIgnoreCase)) if (!string.Equals(processPath, updatedProcessPath, StringComparison.Ordinal))
{ {
processEntries.Add(directory); Environment.SetEnvironmentVariable("PATH", updatedProcessPath);
Environment.SetEnvironmentVariable("PATH", string.Join(';', processEntries));
} }
_ = SendMessageTimeout( _ = SendMessageTimeout(
@@ -614,3 +733,36 @@ public sealed class WindowsDevTerminalPlatform : IDevTerminalPlatform, IDisposab
uint timeout, uint timeout,
out nuint result); 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); 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) .ConfigureAwait(false)
?? []; ?? [];
return items.Select(DownloadOpenPolicy.Normalize).ToArray();
} }
catch catch
{ {
@@ -584,6 +584,7 @@ internal sealed class KugouApiClient : IDisposable
"/v2/special_recommend" => "获取酷狗推荐歌单", "/v2/special_recommend" => "获取酷狗推荐歌单",
"/v4/get_list_all_file" => "获取酷狗歌单歌曲", "/v4/get_list_all_file" => "获取酷狗歌单歌曲",
"/pubsongs/v2/get_other_list_file_nofilt" => "获取酷狗歌单歌曲", "/pubsongs/v2/get_other_list_file_nofilt" => "获取酷狗歌单歌曲",
"/v2/get_res_privilege/lite" => "获取酷狗歌曲权益",
"/v5/url" => "解析酷狗播放地址", "/v5/url" => "解析酷狗播放地址",
"/cloudlist.service/v6/add_song" => "收藏歌曲", "/cloudlist.service/v6/add_song" => "收藏歌曲",
"/v4/delete_songs" => "取消收藏歌曲", "/v4/delete_songs" => "取消收藏歌曲",
+151 -12
View File
@@ -24,12 +24,17 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
}; };
private static readonly (MusicPlaybackQuality Quality, string Value, int Bitrate)[] QualityOrder = private static readonly (MusicPlaybackQuality Quality, string Value, int Bitrate)[] QualityOrder =
[ [
(MusicPlaybackQuality.Master, "super", 1000), (MusicPlaybackQuality.Master, "viper_tape", 1000),
(MusicPlaybackQuality.HiRes, "high", 900), (MusicPlaybackQuality.HiRes, "high", 900),
(MusicPlaybackQuality.Lossless, "flac", 800), (MusicPlaybackQuality.Lossless, "flac", 800),
(MusicPlaybackQuality.High, "320", 320), (MusicPlaybackQuality.High, "320", 320),
(MusicPlaybackQuality.Standard, "128", 128) (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 IMusicCredentialStore _credentials;
private readonly HttpClient _publicClient; private readonly HttpClient _publicClient;
@@ -348,10 +353,11 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
var identity = SplitSongId(songId); var identity = SplitSongId(songId);
var start = global::System.Array.FindIndex(QualityOrder, item => item.Quality == quality); var start = global::System.Array.FindIndex(QualityOrder, item => item.Quality == quality);
if (start < 0) start = QualityOrder.Length - 1; if (start < 0) start = QualityOrder.Length - 1;
var metadata = SongDataFor(songId);
var candidates = await GetPlaybackCandidatesAsync(identity, metadata, start, cancellationToken).ConfigureAwait(false);
string? lastReason = null; string? lastReason = null;
for (var index = start; index < QualityOrder.Length; index++) foreach (var candidate in candidates)
{ {
var candidate = QualityOrder[index];
try try
{ {
var response = await _api.SendAndroidAsync( var response = await _api.SendAndroidAsync(
@@ -361,12 +367,12 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
{ {
["album_id"] = identity.AlbumId, ["album_id"] = identity.AlbumId,
["area_code"] = "1", ["area_code"] = "1",
["hash"] = identity.Hash.ToLowerInvariant(), ["hash"] = candidate.Hash.ToLowerInvariant(),
["ssa_flag"] = "is_fromtrack", ["ssa_flag"] = "is_fromtrack",
["version"] = "11430", ["version"] = "11430",
["page_id"] = "151369488", ["page_id"] = "151369488",
["quality"] = candidate.Value, ["quality"] = candidate.Value,
["album_audio_id"] = SongDataFor(songId)?.MixSongId.ToString(CultureInfo.InvariantCulture) ?? "0", ["album_audio_id"] = metadata?.MixSongId.ToString(CultureInfo.InvariantCulture) ?? "0",
["behavior"] = "play", ["behavior"] = "play",
["pid"] = "2", ["pid"] = "2",
["cmd"] = "26", ["cmd"] = "26",
@@ -382,14 +388,20 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
"trackercdn.kugou.com", "trackercdn.kugou.com",
cancellationToken, cancellationToken,
addTrackKey: true).ConfigureAwait(false); addTrackKey: true).ConfigureAwait(false);
var data = response.Json["data"] ?? response.Json;
if (data is JsonArray array) data = array.FirstOrDefault(); foreach (var data in PlaybackResponseNodes(response.Json))
var address = KugouApiClient.Text(data, "url") ?? KugouApiClient.Text(data, "play_url") ?? FirstText(data, "backup_url");
if (Uri.TryCreate(address, UriKind.Absolute, out var streamUri) && streamUri.Scheme == Uri.UriSchemeHttps)
{ {
var bitrate = KugouApiClient.Integer(data, "bitRate"); 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; 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( return new MusicStreamResult(
streamUri, streamUri,
true, true,
@@ -398,7 +410,10 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
bitrate * 1000L, bitrate * 1000L,
trial ? "提供方仅允许试听片段。" : candidate.Quality == quality ? null : $"已回退到 {candidate.Quality} 音质。"); 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) catch (KugouApiException exception) when (!exception.AuthenticationFailure)
{ {
@@ -409,6 +424,128 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
lastReason ?? "提供方未返回播放地址,歌曲可能需要登录、会员或受地区限制。"); 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) public async Task<MusicStreamProbe> ProbeStreamAsync(Uri uri, CancellationToken cancellationToken = default)
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, uri); 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 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); 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); 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) if (compact.Length == 0)
{ {
return []; return [];
@@ -77,7 +88,13 @@ public static class SerialPayloadCodec
} }
public static string Format(ReadOnlySpan<byte> data, bool hexadecimal) 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) if (!hexadecimal)
: Encoding.UTF8.GetString(data); {
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); 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] [TestMethod]
public async Task DownloadQueueStorePersistsSettings() public async Task DownloadQueueStorePersistsSettings()
{ {
+64 -6
View File
@@ -330,16 +330,28 @@ public sealed class KugouMusicProviderTests
public async Task AccountPlaybackFallsBackByQualityAndRejectsHttpAddress() public async Task AccountPlaybackFallsBackByQualityAndRejectsHttpAddress()
{ {
var qualities = new List<string>(); var qualities = new List<string>();
var hashes = new List<string>();
var handler = new StubHttpHandler((request, _) => var handler = new StubHttpHandler((request, _) =>
{ {
if (request.RequestUri!.AbsolutePath == "/v7/get_all_list") return Task.FromResult(JsonResponse(UserLists())); 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") if (request.RequestUri.AbsolutePath == "/v5/url")
{ {
var quality = ParseQuery(request.RequestUri)["quality"]; var query = ParseQuery(request.RequestUri);
var quality = query["quality"];
qualities.Add(quality); qualities.Add(quality);
return Task.FromResult(JsonResponse(quality == "super" hashes.Add(query["hash"]);
? "{\"status\":1,\"data\":{\"url\":\"http://media.test/insecure.flac\"}}" return Task.FromResult(JsonResponse(quality == "viper_tape"
: "{\"status\":1,\"data\":{\"url\":\"https://media.test/song.flac\",\"bitRate\":900}}")); ? "{\"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}")); return Task.FromResult(JsonResponse("{\"status\":1}"));
}); });
@@ -351,12 +363,54 @@ public sealed class KugouMusicProviderTests
Assert.IsTrue(stream.Playable); Assert.IsTrue(stream.Playable);
Assert.AreEqual(MusicPlaybackQuality.HiRes, stream.Quality); Assert.AreEqual(MusicPlaybackQuality.HiRes, stream.Quality);
Assert.AreEqual(new Uri("https://media.test/song.flac"), stream.Uri); 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, "回退"); StringAssert.Contains(stream.ProviderReason, "回退");
} }
[TestMethod] [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.HiRes, "high")]
[DataRow(MusicPlaybackQuality.Lossless, "flac")] [DataRow(MusicPlaybackQuality.Lossless, "flac")]
[DataRow(MusicPlaybackQuality.High, "320")] [DataRow(MusicPlaybackQuality.High, "320")]
@@ -366,6 +420,10 @@ public sealed class KugouMusicProviderTests
string? actualParameter = null; string? actualParameter = null;
using var provider = new KugouMusicProvider(DeviceStore(), new StubHttpHandler((request, _) => 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"]; actualParameter = ParseQuery(request.RequestUri!)["quality"];
return Task.FromResult(JsonResponse("{\"status\":1,\"data\":{\"url\":\"https://media.test/song\",\"bitRate\":320}}")); return Task.FromResult(JsonResponse("{\"status\":1,\"data\":{\"url\":\"https://media.test/song\",\"bitRate\":320}}"));
})); }));
+41
View File
@@ -1,4 +1,5 @@
using Microsoft.VisualStudio.TestTools.UnitTesting; using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Text.RegularExpressions;
using YMhut.Box.Core.Tools; using YMhut.Box.Core.Tools;
namespace YMhut.Box.Tests; namespace YMhut.Box.Tests;
@@ -43,6 +44,33 @@ public sealed class ToolCatalogTests
Assert.IsGreaterThanOrEqualTo(160, catalog.Modules.Count); 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] [TestMethod]
public void SearchFiltersByQueryAndCategory() public void SearchFiltersByQueryAndCategory()
{ {
@@ -54,6 +82,19 @@ public sealed class ToolCatalogTests
Assert.AreEqual("safe_browser", results[0].Id); 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] [TestMethod]
public void SearchFindsIntegratedToolboxSurfaces() public void SearchFindsIntegratedToolboxSurfaces()
{ {
@@ -13,6 +13,9 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\YMhut.Box.Core\YMhut.Box.Core.csproj" /> <ProjectReference Include="..\YMhut.Box.Core\YMhut.Box.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Compile Include="..\box-winUI\Services\UiPerformanceCoordinator.cs" Link="Shared\UiPerformanceCoordinator.cs" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="TestData\**\*" CopyToOutputDirectory="PreserveNewest" /> <None Include="TestData\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
@@ -778,6 +778,7 @@
const defaultTarget = new THREE.Vector3(0, 0, 0); const defaultTarget = new THREE.Vector3(0, 0, 0);
const responsive = { scale: 1, cameraDistance: 74, y: 7 }; const responsive = { scale: 1, cameraDistance: 74, y: 7 };
const visualState = detectVisualState(); const visualState = detectVisualState();
let hostLightMode = false;
const renderStats = { elapsed: 0, frames: 0, qualityReduced: false }; const renderStats = { elapsed: 0, frames: 0, qualityReduced: false };
const asyncTasks = new Map([ const asyncTasks = new Map([
['three', { label: 'Three.js 模块', progress: 0 }], ['three', { label: 'Three.js 模块', progress: 0 }],
@@ -2341,6 +2342,12 @@
return; return;
} }
if (payload.type === 'performanceMode') {
hostLightMode = Boolean(payload.lightMode);
clock.getDelta();
return;
}
if (payload.type === 'ephemeris:complete') { if (payload.type === 'ephemeris:complete') {
if (!acceptEphemerisRequest(payload)) { if (!acceptEphemerisRequest(payload)) {
return; return;
@@ -2484,6 +2491,10 @@
function animate() { function animate() {
requestAnimationFrame(animate); requestAnimationFrame(animate);
if (hostLightMode) {
clock.getDelta();
return;
}
const delta = Math.min(clock.getDelta(), 0.05); const delta = Math.min(clock.getDelta(), 0.05);
const elapsed = clock.elapsedTime; const elapsed = clock.elapsedTime;
const animationFactor = visualState.animationsEnabled ? 1 : 0; const animationFactor = visualState.animationsEnabled ? 1 : 0;
+127 -51
View File
@@ -17,6 +17,7 @@ public sealed class WeatherCapsuleControl : UserControl
private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>(); private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>();
private readonly AccessibilitySettings _accessibility = new(); private readonly AccessibilitySettings _accessibility = new();
private readonly UISettings _systemUiSettings = new(); private readonly UISettings _systemUiSettings = new();
private readonly IUiPerformanceCoordinator _uiPerformanceCoordinator = AppServices.GetRequiredService<IUiPerformanceCoordinator>();
private readonly Button _button; private readonly Button _button;
private readonly AnimatedWeatherIconControl _weatherIcon; private readonly AnimatedWeatherIconControl _weatherIcon;
private readonly ProgressRing _loadingRing; private readonly ProgressRing _loadingRing;
@@ -88,17 +89,27 @@ public sealed class WeatherCapsuleControl : UserControl
Grid.SetColumn(_tempText, 2); Grid.SetColumn(_tempText, 2);
visual.Children.Add(_tempText); 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(); _flyout.Opening += (_, _) => _flyout.Content = BuildFlyoutContent();
_button = new Button _button = new Button
{ {
Height = 38, Height = 40,
MinWidth = 168, MinWidth = 172,
MaxWidth = 242, MaxWidth = 248,
Margin = new Thickness(0, 4, 0, 4), Margin = new Thickness(0, 4, 0, 4),
Padding = new Thickness(12, 4, 12, 4), Padding = new Thickness(12, 4, 12, 4),
CornerRadius = new CornerRadius(19), CornerRadius = new CornerRadius(20),
Background = ModernUi.Surface, Background = ModernUi.Surface,
BorderBrush = ModernUi.Stroke, BorderBrush = ModernUi.Stroke,
BorderThickness = new Thickness(1), BorderThickness = new Thickness(1),
@@ -171,8 +182,8 @@ public sealed class WeatherCapsuleControl : UserControl
_locationText.Text = displaySnapshot.Location; _locationText.Text = displaySnapshot.Location;
_conditionText.Text = displaySnapshot.Condition; _conditionText.Text = displaySnapshot.Condition;
_tempText.Text = displaySnapshot.TemperatureText; _tempText.Text = displaySnapshot.TemperatureText;
_button.Background = displaySnapshot.IsAvailable ? ModernUi.Surface : ModernUi.SurfaceAlt; _button.Background = displaySnapshot.IsAvailable ? ModernUi.AccentSoft : ModernUi.SurfaceAlt;
_button.BorderBrush = displaySnapshot.IsAvailable ? ModernUi.Stroke : ModernUi.StrokeStrong; _button.BorderBrush = displaySnapshot.IsAvailable ? ModernUi.Accent : ModernUi.StrokeStrong;
ToolTipService.SetToolTip(_button, loading ? AppLocalizer.T("天气正在刷新", "Weather is refreshing") : BuildTooltip(displaySnapshot)); ToolTipService.SetToolTip(_button, loading ? AppLocalizer.T("天气正在刷新", "Weather is refreshing") : BuildTooltip(displaySnapshot));
AutomationProperties.SetName(_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() private UIElement BuildFlyoutContent()
{ {
var snapshot = _snapshot; 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; refresh.HorizontalAlignment = HorizontalAlignment.Right;
Grid.SetColumn(refresh, 2); Grid.SetColumn(refresh, 2);
return new StackPanel var heading = new Grid
{
Width = 280,
Padding = new Thickness(4),
Spacing = 12,
Children =
{
new Grid
{ {
ColumnSpacing = 12, ColumnSpacing = 12,
ColumnDefinitions = ColumnDefinitions =
@@ -213,33 +217,71 @@ public sealed class WeatherCapsuleControl : UserControl
BuildTitleBlock(snapshot), BuildTitleBlock(snapshot),
refresh refresh
} }
}, };
BuildDetailLine(AppLocalizer.T("体感", "Feels like"), snapshot.FeelsLikeText, "\uE706"),
BuildDetailLine(AppLocalizer.T("湿度", "Humidity"), snapshot.HumidityText, "\uE81F"), var metrics = new Grid { ColumnSpacing = 8, RowSpacing = 8 };
BuildDetailLine(AppLocalizer.T("风速", "Wind"), snapshot.WindText, "\uE9CA"), metrics.ColumnDefinitions.Add(new ColumnDefinition());
BuildDetailLine(AppLocalizer.T("今日温度", "Today"), snapshot.RangeText, "\uE787"), metrics.ColumnDefinitions.Add(new ColumnDefinition());
BuildDetailLine(AppLocalizer.T("更新时间", "Updated"), snapshot.UpdatedText, "\uE823"), metrics.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
snapshot.ErrorMessage is null metrics.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
? new Border { Height = 0 } AddMetric(metrics, 0, 0, AppLocalizer.T("体感", "Feels like"), snapshot.FeelsLikeText, "\uE706");
: ModernUi.Card( AddMetric(metrics, 0, 1, AppLocalizer.T("湿度", "Humidity"), snapshot.HumidityText, "\uE81F");
ModernUi.Text(snapshot.ErrorMessage, 12, foreground: ModernUi.TextSecondary, maxLines: 3), AddMetric(metrics, 1, 0, AppLocalizer.T("风速", "Wind"), snapshot.WindText, "\uE9CA");
new Thickness(10), AddMetric(metrics, 1, 1, AppLocalizer.T("今日温度", "Today"), snapshot.RangeText, "\uE787");
radius: 8,
background: ModernUi.SurfaceAlt) 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) private static StackPanel BuildTitleBlock(TitleWeatherSnapshot snapshot)
{ {
var panel = new StackPanel var panel = new StackPanel
{ {
Spacing = 1, Spacing = 2,
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
Children = Children =
{ {
ModernUi.Text(snapshot.Location, 17, FontWeights.SemiBold, maxLines: 1), ModernUi.Text(snapshot.TemperatureText, 30, FontWeights.SemiBold, maxLines: 1),
ModernUi.Text($"{snapshot.Condition} · {snapshot.TemperatureText} · {snapshot.QueryLevel}", 13, foreground: ModernUi.TextSecondary, 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); Grid.SetColumn(panel, 1);
@@ -252,11 +294,11 @@ public sealed class WeatherCapsuleControl : UserControl
icon.Update(snapshot, CanAnimate()); icon.Update(snapshot, CanAnimate());
return new Border return new Border
{ {
Width = 52, Width = 66,
Height = 52, Height = 66,
CornerRadius = new CornerRadius(8), CornerRadius = new CornerRadius(8),
Background = ModernUi.AccentSoft, Background = snapshot.IsAvailable ? ModernUi.AccentSoft : ModernUi.SurfaceAlt,
BorderBrush = ModernUi.Stroke, BorderBrush = snapshot.IsAvailable ? ModernUi.Accent : ModernUi.StrokeStrong,
BorderThickness = new Thickness(1), BorderThickness = new Thickness(1),
Child = icon Child = icon
}; };
@@ -270,27 +312,55 @@ public sealed class WeatherCapsuleControl : UserControl
text.VerticalAlignment = VerticalAlignment.Center; 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 }; var tile = new Border
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); {
grid.ColumnDefinitions.Add(new ColumnDefinition()); MinHeight = 62,
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); Padding = new Thickness(10, 8, 10, 8),
grid.Children.Add(ModernUi.IconTile(glyph, 30, ModernUi.SurfaceAlt, ModernUi.TextSecondary, 13)); 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); private static StackPanel BuildMetricCopy(string label, string value)
Grid.SetColumn(title, 1); {
grid.Children.Add(title); var copy = new StackPanel
{
var text = ModernUi.Text(value, 13, FontWeights.SemiBold, ModernUi.TextPrimary, maxLines: 1); Spacing = 1,
Grid.SetColumn(text, 2); VerticalAlignment = VerticalAlignment.Center,
grid.Children.Add(text); Children =
return grid; {
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() private bool CanAnimate()
{ {
if (!_settingsService.Current.AnimationsEnabled || _accessibility.HighContrast) if (!_settingsService.Current.AnimationsEnabled || _accessibility.HighContrast || _uiPerformanceCoordinator.IsLightMode)
{ {
return false; return false;
} }
@@ -309,6 +379,8 @@ public sealed class WeatherCapsuleControl : UserControl
{ {
_settingsService.PropertyChanged -= SettingsService_PropertyChanged; _settingsService.PropertyChanged -= SettingsService_PropertyChanged;
_settingsService.PropertyChanged += SettingsService_PropertyChanged; _settingsService.PropertyChanged += SettingsService_PropertyChanged;
_uiPerformanceCoordinator.LightModeChanged -= UiPerformanceCoordinator_LightModeChanged;
_uiPerformanceCoordinator.LightModeChanged += UiPerformanceCoordinator_LightModeChanged;
TrySubscribeHighContrastEvents(); TrySubscribeHighContrastEvents();
ApplyMotionPreference(); ApplyMotionPreference();
} }
@@ -316,6 +388,7 @@ public sealed class WeatherCapsuleControl : UserControl
private void WeatherCapsuleControl_Unloaded(object sender, RoutedEventArgs e) private void WeatherCapsuleControl_Unloaded(object sender, RoutedEventArgs e)
{ {
_settingsService.PropertyChanged -= SettingsService_PropertyChanged; _settingsService.PropertyChanged -= SettingsService_PropertyChanged;
_uiPerformanceCoordinator.LightModeChanged -= UiPerformanceCoordinator_LightModeChanged;
TryUnsubscribeHighContrastEvents(); TryUnsubscribeHighContrastEvents();
_loadingRing.IsActive = false; _loadingRing.IsActive = false;
} }
@@ -323,6 +396,9 @@ public sealed class WeatherCapsuleControl : UserControl
private void SettingsService_PropertyChanged(object? sender, PropertyChangedEventArgs e) private void SettingsService_PropertyChanged(object? sender, PropertyChangedEventArgs e)
=> DispatcherQueue.TryEnqueue(ApplyMotionPreference); => DispatcherQueue.TryEnqueue(ApplyMotionPreference);
private void UiPerformanceCoordinator_LightModeChanged(object? sender, UiPerformanceModeChangedEventArgs e)
=> DispatcherQueue.TryEnqueue(ApplyMotionPreference);
private void Accessibility_HighContrastChanged(AccessibilitySettings sender, object args) private void Accessibility_HighContrastChanged(AccessibilitySettings sender, object args)
=> DispatcherQueue.TryEnqueue(ApplyMotionPreference); => DispatcherQueue.TryEnqueue(ApplyMotionPreference);
+27 -1
View File
@@ -81,6 +81,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
private bool _lastResponsivePhone; private bool _lastResponsivePhone;
private double _lastResponsiveWidth; private double _lastResponsiveWidth;
private bool _windowMoveLoopActive; private bool _windowMoveLoopActive;
private bool _responsiveShellUpdateDeferred;
private AppShell? _shell; private AppShell? _shell;
private WeatherCapsuleControl? WeatherCapsule; private WeatherCapsuleControl? WeatherCapsule;
private bool _syncingQuickSettings; private bool _syncingQuickSettings;
@@ -135,11 +136,19 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
if (active) if (active)
{ {
_moveLightModeScope ??= _uiPerformanceCoordinator.EnterLightMode("main-window-move"); _moveLightModeScope ??= _uiPerformanceCoordinator.EnterLightMode("main-window-move");
_responsiveShellTimer?.Stop();
_pageTransitionStoryboard?.Stop();
_pageTransitionStoryboard = null;
} }
else else
{ {
_moveLightModeScope?.Dispose(); _moveLightModeScope?.Dispose();
_moveLightModeScope = null; _moveLightModeScope = null;
if (_responsiveShellUpdateDeferred)
{
_responsiveShellUpdateDeferred = false;
ScheduleResponsiveShellUpdate();
}
} }
ApplyShellTheme(); ApplyShellTheme();
@@ -278,6 +287,12 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
return; return;
} }
if (_windowMoveLoopActive)
{
_responsiveShellUpdateDeferred = true;
return;
}
_responsiveShellTimer ??= CreateOneShotTimer( _responsiveShellTimer ??= CreateOneShotTimer(
TimeSpan.FromMilliseconds(72), TimeSpan.FromMilliseconds(72),
ApplyResponsiveShell); ApplyResponsiveShell);
@@ -1960,7 +1975,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
private bool CanAnimatePageTransition() private bool CanAnimatePageTransition()
{ {
if (!_settingsService.Current.AnimationsEnabled || _accessibility.HighContrast) if (!_settingsService.Current.AnimationsEnabled || _accessibility.HighContrast || _uiPerformanceCoordinator.IsLightMode)
{ {
return false; return false;
} }
@@ -2341,6 +2356,17 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
{ {
var settings = _settingsService.Current; var settings = _settingsService.Current;
var isDark = ThemeService.ShouldUseDarkPalette(settings.Theme); 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 stableMaterial = _windowMoveLoopActive || _accessibility.HighContrast || !settings.HardwareAccelerationEnabled;
var transparentBackdrop = !stableMaterial && UsesTransparentBackdrop(settings.WindowBackdrop); var transparentBackdrop = !stableMaterial && UsesTransparentBackdrop(settings.WindowBackdrop);
var topBarMaterial = stableMaterial ? "solid" : settings.TopBarMaterial; var topBarMaterial = stableMaterial ? "solid" : settings.TopBarMaterial;
+41 -2
View File
@@ -32,6 +32,7 @@ public sealed class HomePage : Page
private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>(); private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>();
private readonly IAppInstallerUpdateService _updateService = AppServices.GetRequiredService<IAppInstallerUpdateService>(); private readonly IAppInstallerUpdateService _updateService = AppServices.GetRequiredService<IAppInstallerUpdateService>();
private readonly IAppVersionService _versionService = AppServices.GetRequiredService<IAppVersionService>(); private readonly IAppVersionService _versionService = AppServices.GetRequiredService<IAppVersionService>();
private readonly IUiPerformanceCoordinator _uiPerformanceCoordinator = AppServices.GetRequiredService<IUiPerformanceCoordinator>();
private readonly StackPanel _rootStack = new() private readonly StackPanel _rootStack = new()
{ {
Padding = new Thickness(24, 20, 24, 28), Padding = new Thickness(24, 20, 24, 28),
@@ -92,6 +93,7 @@ public sealed class HomePage : Page
Background = ModernUi.AppBackground; Background = ModernUi.AppBackground;
Content = BuildContent(); Content = BuildContent();
Loaded += HomePage_Loaded; Loaded += HomePage_Loaded;
Unloaded += HomePage_Unloaded;
SizeChanged += (_, _) => ArrangeHomeLayout(); SizeChanged += (_, _) => ArrangeHomeLayout();
_homeSearchBox.KeyDown += HomeSearchBox_KeyDown; _homeSearchBox.KeyDown += HomeSearchBox_KeyDown;
} }
@@ -823,6 +825,8 @@ public sealed class HomePage : Page
private async void HomePage_Loaded(object sender, RoutedEventArgs e) private async void HomePage_Loaded(object sender, RoutedEventArgs e)
{ {
_uiPerformanceCoordinator.LightModeChanged -= UiPerformanceCoordinator_LightModeChanged;
_uiPerformanceCoordinator.LightModeChanged += UiPerformanceCoordinator_LightModeChanged;
ArrangeHomeLayout(); ArrangeHomeLayout();
if (IsDashboardHome()) if (IsDashboardHome())
@@ -840,6 +844,23 @@ public sealed class HomePage : Page
await InitializeGlobeAsync(); 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() private async Task InitializeGlobeAsync()
{ {
try try
@@ -882,6 +903,7 @@ public sealed class HomePage : Page
_solarSystemReady = true; _solarSystemReady = true;
SendSolarSystemSettings(sender); SendSolarSystemSettings(sender);
SendPerformanceMode(sender);
await SendEphemerisAsync(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) private void ShowGlobeError(Exception exception)
{ {
_globeHost.Children.Clear(); _globeHost.Children.Clear();
@@ -1192,5 +1233,3 @@ public sealed class HomePage : Page
}; };
} }
} }
@@ -212,6 +212,7 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
return; return;
} }
_terminalProgress.Value = 0;
SetTerminalBusy(true, AppLocalizer.T("正在准备终端安装...", "Preparing terminal setup...")); SetTerminalBusy(true, AppLocalizer.T("正在准备终端安装...", "Preparing terminal setup..."));
try try
{ {
@@ -264,6 +265,17 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
private void ApplyTerminalSnapshot(DevTerminalSnapshot snapshot) private void ApplyTerminalSnapshot(DevTerminalSnapshot snapshot)
{ {
_terminalSnapshot = 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 var terminal = snapshot.WindowsTerminalInstalled
? $"Windows Terminal {ValueOrDash(snapshot.WindowsTerminalVersion)}" ? $"Windows Terminal {ValueOrDash(snapshot.WindowsTerminalVersion)}"
: AppLocalizer.T("Windows Terminal 未安装", "Windows Terminal not installed"); : AppLocalizer.T("Windows Terminal 未安装", "Windows Terminal not installed");
@@ -273,14 +285,18 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
_terminalStatus.Text = $"{terminal} · {powershell}"; _terminalStatus.Text = $"{terminal} · {powershell}";
var defaultState = !snapshot.SupportsDefaultTerminal 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 : snapshot.IsWindowsTerminalDefault
? AppLocalizer.T("Windows Terminal 已设为默认", "Windows Terminal is the default") ? AppLocalizer.T("Windows Terminal 已设为默认", "Windows Terminal is the default")
: AppLocalizer.T("尚未设为默认终端", "Not yet the default terminal"); : AppLocalizer.T("尚未设为默认终端", "Not yet the default terminal");
var profileState = snapshot.IsPowerShellDefaultProfile var profileState = snapshot.IsPowerShellDefaultProfile
? AppLocalizer.T("PowerShell 7 默认配置已启用", "PowerShell 7 default profile enabled") ? AppLocalizer.T("PowerShell 7 默认配置已启用", "PowerShell 7 default profile enabled")
: AppLocalizer.T("PowerShell 7 默认配置待设置", "PowerShell 7 default profile pending"); : 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) if (_terminalOpenButton is not null)
{ {
_terminalOpenButton.IsEnabled = snapshot.PowerShellInstalled || snapshot.WindowsTerminalInstalled; _terminalOpenButton.IsEnabled = snapshot.PowerShellInstalled || snapshot.WindowsTerminalInstalled;
@@ -295,7 +311,8 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
} }
if (_terminalOpenButton is not null) 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.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
_terminalProgress.IsIndeterminate = busy && _terminalProgress.Value <= 0; _terminalProgress.IsIndeterminate = busy && _terminalProgress.Value <= 0;
@@ -542,32 +559,11 @@ public sealed class DevEnvironmentConfigToolPage : ToolPageBase
try try
{ {
var script = Path.Combine(Path.GetTempPath(), $"ymhut-build-{plan.EnvironmentId}-{Guid.NewGuid():N}.cmd"); var script = Path.Combine(Path.GetTempPath(), $"ymhut-build-{plan.EnvironmentId}-{Guid.NewGuid():N}.cmd");
File.WriteAllLines(script, [ File.WriteAllLines(script, DevEnvironmentBuildScript.Create(plan, item.TargetPath));
"@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"
]);
Process.Start(new ProcessStartInfo Process.Start(new ProcessStartInfo
{ {
FileName = "cmd.exe", FileName = "cmd.exe",
Arguments = $"/k \"{script}\"", Arguments = $"/d /c \"\"{script}\"\"",
UseShellExecute = true UseShellExecute = true
}); });
} }