完善开发环境工具、音乐服务和界面性能
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,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,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
@@ -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" => "取消收藏歌曲",
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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}}"));
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user