完善在线工具、插件运行时与安装卸载流程

This commit is contained in:
2026-08-19 16:15:52 +08:00
parent 80093f285e
commit 7a2af2646c
366 changed files with 6683 additions and 490 deletions
+143 -5
View File
@@ -41,6 +41,7 @@ $ServerPublicRoot = Join-Path $Root 'server\update\public'
$ServerDownloadRoot = Join-Path $ServerPublicRoot 'downloads'
$ToolStateRoot = Join-Path $Root '.cache\tool_state'
$NuGetRoot = Join-Path $Root '.cache\nuget'
$BuildTempRoot = Join-Path $Root '.cache\build-temp'
$TauriHostRoot = Join-Path $Root 'src\YMhut.Box.PluginTauriHost\src-tauri'
$RepositoryCargoHome = Join-Path $Root '.cache\rust-toolchain\cargo'
$RepositoryRustupHome = Join-Path $Root '.cache\rust-toolchain\rustup'
@@ -324,7 +325,10 @@ function Ensure-TauriPluginHost {
return $executable
}
Write-Warning 'Cargo was not found, so the optional Tauri plugin host will not be included. Install the Rust stable MSVC toolchain to build ExternalRuntime support.'
if ($Configuration -ieq 'Release') {
throw 'Cargo was not found and no previously built Release Tauri host is available. Release packages require the bundled plugin host.'
}
Write-Warning 'Cargo was not found, so the optional Debug Tauri plugin host will not be included.'
return $null
}
@@ -516,6 +520,12 @@ function Save-ScaledLogo {
}
function Ensure-MsixAssets {
$iconScript = Join-Path $Root 'scripts\generate-app-icons.ps1'
& $iconScript -Root $Root
if (-not $?) {
throw 'Application icon generation failed.'
}
$sourceIcon = Join-Path $Root 'assets\icons\app_icon.png'
if (-not (Test-Path -LiteralPath $sourceIcon)) {
throw "MSIX source icon was not found: $sourceIcon"
@@ -527,6 +537,18 @@ function Ensure-MsixAssets {
Save-ScaledLogo $sourceIcon (Join-Path $ProjectAssets 'Square150x150Logo.png') 150 150 16 '#00FFFFFF'
Save-ScaledLogo $sourceIcon (Join-Path $ProjectAssets 'LockScreenLogo.png') 70 70 8 '#00FFFFFF'
Save-ScaledLogo $sourceIcon (Join-Path $ProjectAssets 'Wide310x150Logo.png') 310 150 24 '#F5F6F7' -Wordmark
$iconPath = Join-Path $ProjectAssets 'app_icon.ico'
$bytes = [IO.File]::ReadAllBytes($iconPath)
$count = [BitConverter]::ToUInt16($bytes, 4)
$sizes = for ($index = 0; $index -lt $count; $index++) {
$value = $bytes[6 + ($index * 16)]
if ($value -eq 0) { 256 } else { [int] $value }
}
$missing = @(16, 20, 24, 32, 40, 48, 64, 96, 128, 256) | Where-Object { $_ -notin $sizes }
if ($missing.Count -gt 0) {
throw "Application icon is missing DPI frames: $($missing -join ', ')"
}
}
function Ensure-LocalDeveloperCertificate {
@@ -712,7 +734,13 @@ function Write-LayoutManifests([string] $Directory) {
$configDir = Join-Path $Directory 'config'
New-Directory $configDir
$required = @($files | Where-Object { $_ -in @('YMhutBox.exe', 'YMhutBox.dll', 'WebView2Loader.dll') })
$required = @($files | Where-Object { $_ -in @(
'YMhutBox.exe',
'YMhutBox.dll',
'WebView2Loader.dll',
'Assets/runtime-assets.dat',
'Assets/data/ymhut-data.ybin'
) })
if ($required.Count -eq 0) {
$required = @('YMhutBox.exe')
}
@@ -720,6 +748,7 @@ function Write-LayoutManifests([string] $Directory) {
$manifest = [System.Collections.Generic.List[string]]::new()
if ($versionInfo) {
$manifest.Add('[Release]')
$manifest.Add('ManifestVersion=2')
$manifest.Add("Version=$($versionInfo.Version)")
$manifest.Add("Build=$($versionInfo.Build)")
$manifest.Add("Channel=$($versionInfo.Channel)")
@@ -1097,6 +1126,7 @@ function Normalize-PublishLayout([string] $Directory) {
Remove-RootHelperDuplicates $Directory
Assert-UnpackagedLanguageResourcesInLang $Directory
Assert-NoDevelopmentArtifacts $Directory
Assert-AuthenticatedAssetLayout $Directory
}
function Assert-UnpackagedLanguageResourcesInLang([string] $Directory) {
@@ -1274,8 +1304,14 @@ function Assert-ToolUiPayload([string] $Directory) {
}
$files = @(Get-ChildItem -LiteralPath $folder -Filter '*.dat' -File -ErrorAction SilentlyContinue)
if ($files.Count -lt 202) {
throw "Release tool UI payload is incomplete in '$Directory': expected at least 202 .dat definitions, found $($files.Count)"
$expectedFolder = Join-Path $Root 'assets\data\tool-ui'
$expectedNames = @(Get-ChildItem -LiteralPath $expectedFolder -Filter '*.dat' -File -ErrorAction SilentlyContinue |
ForEach-Object Name | Sort-Object)
$actualNames = @($files | ForEach-Object Name | Sort-Object)
$difference = @(Compare-Object -ReferenceObject $expectedNames -DifferenceObject $actualNames)
if ($expectedNames.Count -eq 0 -or $difference.Count -gt 0) {
$details = $difference | ForEach-Object { "$($_.SideIndicator) $($_.InputObject)" }
throw "Release tool UI payload does not exactly match the repository definitions in '$Directory': $($details -join ', ')"
}
foreach ($file in $files) {
@@ -1286,6 +1322,47 @@ function Assert-ToolUiPayload([string] $Directory) {
}
}
function Assert-AuthenticatedAssetLayout([string] $Directory, [switch] $AllowMsixLogos) {
$assetsRoot = Join-Path $Directory 'Assets'
$runtimePackage = Join-Path $assetsRoot 'runtime-assets.dat'
$referencePackage = Join-Path $assetsRoot 'data\ymhut-data.ybin'
foreach ($package in @($runtimePackage, $referencePackage)) {
if (-not (Test-Path -LiteralPath $package -PathType Leaf)) {
throw "Authenticated asset package is missing from '$Directory': $package"
}
$bytes = [IO.File]::ReadAllBytes($package)
if ($bytes.Length -lt 97 -or [Text.Encoding]::ASCII.GetString($bytes, 0, 8) -ne 'YMHUTAS2' -or $bytes[8] -ne 2) {
throw "Authenticated asset package has an invalid header: $package"
}
}
$allowedPng = if ($AllowMsixLogos) {
@(
'Assets/StoreLogo.png',
'Assets/Square44x44Logo.png',
'Assets/Square150x150Logo.png',
'Assets/Wide310x150Logo.png'
)
} else { @() }
$blockedExtensions = @(
'.bmp', '.css', '.csv', '.gif', '.glb', '.gltf', '.htm', '.html', '.ico',
'.jpeg', '.jpg', '.js', '.json', '.mjs', '.otf', '.svg', '.ttf', '.wasm',
'.webp', '.woff', '.woff2', '.xls', '.xlsx'
)
$blocked = @(Get-ChildItem -LiteralPath $assetsRoot -Recurse -File -ErrorAction SilentlyContinue | Where-Object {
$relative = 'Assets/' + ($_.FullName.Substring($assetsRoot.Length).TrimStart('\', '/') -replace '\\', '/')
($blockedExtensions -contains $_.Extension.ToLowerInvariant()) -or
($_.Extension -ieq '.png' -and $allowedPng -notcontains $relative)
})
if ($blocked.Count -gt 0) {
$relativeList = $blocked | ForEach-Object {
'Assets/' + ($_.FullName.Substring($assetsRoot.Length).TrimStart('\', '/') -replace '\\', '/')
}
throw "Publish Assets contains replaceable plaintext static files: $($relativeList -join ', ')"
}
}
function Copy-PublishToLatest {
Stop-ProcessesFromDirectory $LatestRoot
Reset-DirectoryInsideRepo $LatestRoot
@@ -1293,6 +1370,7 @@ function Copy-PublishToLatest {
Normalize-PublishLayout $LatestRoot
Assert-MusicApiPayload $LatestRoot
Assert-ToolUiPayload $LatestRoot
Assert-AuthenticatedAssetLayout $LatestRoot
Write-LayoutManifests $LatestRoot
}
@@ -1305,6 +1383,7 @@ function Publish-UnpackagedApp([object] $VersionInfo) {
'--self-contained', 'true',
'--no-restore',
'-p:WindowsPackageType=None',
'-p:GenerateAppxPackageOnBuild=false',
'-p:PublishSingleFile=false',
"-p:AssemblyVersion=$($VersionInfo.PackageVersion)",
"-p:FileVersion=$($VersionInfo.PackageVersion)",
@@ -1314,6 +1393,7 @@ function Publish-UnpackagedApp([object] $VersionInfo) {
Normalize-PublishLayout $PublishRoot
Assert-MusicApiPayload $PublishRoot
Assert-ToolUiPayload $PublishRoot
Assert-AuthenticatedAssetLayout $PublishRoot
Assert-PerMonitorV2Manifest (Join-Path $PublishRoot 'YMhutBox.exe')
Write-LayoutManifests $PublishRoot
Copy-PublishToLatest
@@ -1387,6 +1467,39 @@ function Build-InstallerBootstrap([object] $VersionInfo, [string] $SignTool) {
Invoke-ToolQuiet $setupPath @('/WINDOWSELFTEST') 'WinUI installer window navigation self-test failed' " Installer window self-test: $setupPath"
}
function Build-UninstallerBootstrap([object] $VersionInfo, [string] $SignTool) {
$bootstrapRoot = Join-Path $Root 'build\winui\uninstaller-bootstrap'
Reset-DirectoryInsideRepo $bootstrapRoot
Invoke-DotNet @(
'publish', $InstallerBootstrapProject,
'-c', $Configuration,
'-r', 'win-x64',
'--self-contained', 'true',
'--no-restore',
'-p:BootstrapMode=Uninstall',
'-p:AssemblyName=unins000',
"-p:Version=$($VersionInfo.PackageVersion)",
"-p:FileVersion=$($VersionInfo.PackageVersion)",
"-p:InformationalVersion=$($VersionInfo.PackageVersion)",
'-o', $bootstrapRoot
)
$bootstrap = Join-Path $bootstrapRoot 'unins000.exe'
if (-not (Test-Path -LiteralPath $bootstrap -PathType Leaf)) {
throw "WinUI uninstaller bootstrap was not produced: $bootstrap"
}
if ($SignTool) {
Sign-Artifact $SignTool $bootstrap
}
Assert-PerMonitorV2Manifest $bootstrap
foreach ($payloadRoot in @($PublishRoot, $LatestRoot)) {
Copy-Item -LiteralPath $bootstrap -Destination (Join-Path $payloadRoot 'unins000.exe') -Force
Write-LayoutManifests $payloadRoot
}
Write-Host " Uninstaller bootstrap: $(Join-Path $LatestRoot 'unins000.exe')"
}
function Wait-FileWritable([string] $Path, [int] $Attempts = 20, [int] $DelayMilliseconds = 250) {
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
$stream = $null
@@ -1501,8 +1614,11 @@ function Build-MsixPackage([object] $VersionInfo, [string] $MakeAppx, [string] $
Reset-DirectoryInsideRepo $MsixStageRoot
Copy-Item -Path (Join-Path $PublishRoot '*') -Destination $MsixStageRoot -Recurse -Force
Remove-Item -LiteralPath (Join-Path $MsixStageRoot 'unins000.exe') -Force -ErrorAction SilentlyContinue
Write-LayoutManifests $MsixStageRoot
Assert-MusicApiPayload $MsixStageRoot
Assert-ToolUiPayload $MsixStageRoot
Assert-AuthenticatedAssetLayout $MsixStageRoot
Assert-PerMonitorV2Manifest (Join-Path $MsixStageRoot 'YMhutBox.exe')
Expand-SatelliteResourcePackages $MsixStageRoot -RemovePackages
Restore-LangResourceLayoutForMsix $MsixStageRoot
@@ -1516,7 +1632,10 @@ function Build-MsixPackage([object] $VersionInfo, [string] $MakeAppx, [string] $
$stageAssets = Join-Path $MsixStageRoot 'Assets'
New-Directory $stageAssets
Copy-Item -Path (Join-Path $ProjectAssets '*') -Destination $stageAssets -Recurse -Force
foreach ($logo in @('StoreLogo.png', 'Square44x44Logo.png', 'Square150x150Logo.png', 'Wide310x150Logo.png')) {
Copy-Item -LiteralPath (Join-Path $ProjectAssets $logo) -Destination (Join-Path $stageAssets $logo) -Force
}
Assert-AuthenticatedAssetLayout $MsixStageRoot -AllowMsixLogos
Repair-MsixStageThirdPartyPayloads
Invoke-ToolQuiet $MakeAppx @('pack', '/d', $MsixStageRoot, '/p', $msixPath, '/o') 'MakeAppx packaging failed' " MSIX package: $msixPath"
@@ -1599,10 +1718,15 @@ pause
New-Directory $NuGetRoot
New-Directory $OutputRoot
New-Directory $BuildTempRoot
$env:DOTNET_CLI_HOME = Join-Path $ToolStateRoot 'dotnet'
$env:NUGET_PACKAGES = $NuGetRoot
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
$env:TEMP = $BuildTempRoot
$env:TMP = $BuildTempRoot
$env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $BuildTempRoot 'dotnet-bundle'
New-Directory $env:DOTNET_CLI_HOME
New-Directory $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR
$versionInfo = Get-VersionInfo
$makeAppx = Find-WindowsSdkTool 'MakeAppx.exe'
@@ -1637,12 +1761,26 @@ foreach ($ridProject in @($Project, $InstallerBootstrapProject)) {
}
Publish-UnpackagedApp $versionInfo
Build-UninstallerBootstrap $versionInfo $signTool
if ($tauriHostExecutable) {
$publishedTauriHost = Join-Path $PublishRoot 'tauri-host\ymhut-box-plugin-tauri-host.exe'
if (-not (Test-Path -LiteralPath $publishedTauriHost)) {
throw "The Tauri plugin host was built but was not included in the publish payload: $publishedTauriHost"
}
}
$tauriRuntimeFiles = @(
'tauri-host\runtime.json',
'tauri-host\template.ymtemplate',
'tauri-host\LICENSES.txt',
'tauri-host\template\dist\index.html',
'tauri-host\template\README.md'
)
foreach ($relative in $tauriRuntimeFiles) {
$runtimeFile = Join-Path $PublishRoot $relative
if (-not (Test-Path -LiteralPath $runtimeFile -PathType Leaf)) {
throw "The bundled Tauri runtime dependency is missing from the publish payload: $runtimeFile"
}
}
if (($Target -in @('exe', 'both')) -and -not $SkipExe) {
$exeBuilt = Build-InnoInstaller $versionInfo $signTool -AllowMissingCompiler:($Target -eq 'both')
+187
View File
@@ -0,0 +1,187 @@
[CmdletBinding()]
param(
[string] $Root = (Split-Path -Parent $PSScriptRoot)
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$sourcePath = Join-Path $Root 'assets\icons\app_icon.png'
if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) {
throw "App icon source was not found: $sourcePath"
}
function New-ResizedBitmap {
param(
[Drawing.Image] $Source,
[int] $Width,
[int] $Height,
[int] $Padding = 0,
[Drawing.Color] $Background = [Drawing.Color]::Transparent,
[switch] $SharpenSmallAlpha
)
$bitmap = [Drawing.Bitmap]::new($Width, $Height, [Drawing.Imaging.PixelFormat]::Format32bppArgb)
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.CompositingMode = [Drawing.Drawing2D.CompositingMode]::SourceCopy
$graphics.CompositingQuality = [Drawing.Drawing2D.CompositingQuality]::HighQuality
$graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$graphics.PixelOffsetMode = [Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$graphics.SmoothingMode = [Drawing.Drawing2D.SmoothingMode]::HighQuality
$graphics.Clear($Background)
$availableWidth = $Width - (2 * $Padding)
$availableHeight = $Height - (2 * $Padding)
$scale = [Math]::Min($availableWidth / $Source.Width, $availableHeight / $Source.Height)
$drawWidth = [Math]::Max(1, [int][Math]::Round($Source.Width * $scale))
$drawHeight = [Math]::Max(1, [int][Math]::Round($Source.Height * $scale))
$drawX = [int][Math]::Floor(($Width - $drawWidth) / 2)
$drawY = [int][Math]::Floor(($Height - $drawHeight) / 2)
$attributes = [Drawing.Imaging.ImageAttributes]::new()
try {
$attributes.SetWrapMode([Drawing.Drawing2D.WrapMode]::TileFlipXY)
$graphics.DrawImage(
$Source,
[Drawing.Rectangle]::new($drawX, $drawY, $drawWidth, $drawHeight),
0,
0,
$Source.Width,
$Source.Height,
[Drawing.GraphicsUnit]::Pixel,
$attributes)
} finally {
$attributes.Dispose()
}
} finally {
$graphics.Dispose()
}
if ($SharpenSmallAlpha) {
for ($y = 0; $y -lt $Height; $y++) {
for ($x = 0; $x -lt $Width; $x++) {
$color = $bitmap.GetPixel($x, $y)
if ($color.A -le 10) {
$bitmap.SetPixel($x, $y, [Drawing.Color]::Transparent)
} else {
$alpha = if ($color.A -ge 244) { 255 } else { [Math]::Min(255, [int](($color.A - 10) * 255 / 234)) }
$bitmap.SetPixel($x, $y, [Drawing.Color]::FromArgb($alpha, 0, 114, 255))
}
}
}
}
return $bitmap
}
function Get-PngBytes([Drawing.Bitmap] $Bitmap) {
$stream = [IO.MemoryStream]::new()
try {
$Bitmap.Save($stream, [Drawing.Imaging.ImageFormat]::Png)
return $stream.ToArray()
} finally {
$stream.Dispose()
}
}
function Write-PngIconFile([string] $Path, [hashtable] $Frames) {
$orderedSizes = @($Frames.Keys | ForEach-Object { [int] $_ } | Sort-Object)
$headerSize = 6 + (16 * $orderedSizes.Count)
$offset = $headerSize
$stream = [IO.MemoryStream]::new()
$writer = [IO.BinaryWriter]::new($stream)
try {
$writer.Write([uint16] 0)
$writer.Write([uint16] 1)
$writer.Write([uint16] $orderedSizes.Count)
foreach ($size in $orderedSizes) {
$bytes = [byte[]] $Frames[$size]
$writer.Write([byte] $(if ($size -eq 256) { 0 } else { $size }))
$writer.Write([byte] $(if ($size -eq 256) { 0 } else { $size }))
$writer.Write([byte] 0)
$writer.Write([byte] 0)
$writer.Write([uint16] 1)
$writer.Write([uint16] 32)
$writer.Write([uint32] $bytes.Length)
$writer.Write([uint32] $offset)
$offset += $bytes.Length
}
foreach ($size in $orderedSizes) {
$writer.Write([byte[]] $Frames[$size])
}
$writer.Flush()
$directory = Split-Path -Parent $Path
if ($directory) { [IO.Directory]::CreateDirectory($directory) | Out-Null }
[IO.File]::WriteAllBytes($Path, $stream.ToArray())
} finally {
$writer.Dispose()
$stream.Dispose()
}
}
function Save-Logo {
param(
[Drawing.Image] $Source,
[string] $Path,
[int] $Width,
[int] $Height,
[int] $Padding,
[Drawing.Color] $Background = [Drawing.Color]::Transparent
)
$bitmap = New-ResizedBitmap $Source $Width $Height $Padding $Background
try {
[IO.Directory]::CreateDirectory((Split-Path -Parent $Path)) | Out-Null
$bitmap.Save($Path, [Drawing.Imaging.ImageFormat]::Png)
} finally {
$bitmap.Dispose()
}
}
$loaded = [Drawing.Bitmap]::FromFile($sourcePath)
try {
$master = [Drawing.Bitmap]::new($loaded)
} finally {
$loaded.Dispose()
}
try {
$frames = @{}
foreach ($size in @(16, 20, 24, 32, 40, 48, 64, 96, 128, 256)) {
$frame = New-ResizedBitmap $master $size $size 0 ([Drawing.Color]::Transparent) -SharpenSmallAlpha:($size -le 24)
try {
$frames[$size] = Get-PngBytes $frame
} finally {
$frame.Dispose()
}
}
$iconTargets = @(
(Join-Path $Root 'assets\icon.ico'),
(Join-Path $Root 'assets\icons\app_icon.ico'),
(Join-Path $Root 'src\box-winUI\Assets\app_icon.ico'),
(Join-Path $Root 'src\YMhut.Box.PluginTauriHost\src-tauri\icons\icon.ico')
)
foreach ($target in $iconTargets) {
Write-PngIconFile $target $frames
}
$normalizedSource = Join-Path ([IO.Path]::GetTempPath()) ("ymhut-app-icon-{0}.png" -f [Guid]::NewGuid().ToString('N'))
try {
$master.Save($normalizedSource, [Drawing.Imaging.ImageFormat]::Png)
Copy-Item -LiteralPath $normalizedSource -Destination (Join-Path $Root 'assets\icons\app_icon.png') -Force
Copy-Item -LiteralPath $normalizedSource -Destination (Join-Path $Root 'assets\icons\icon.png') -Force
} finally {
Remove-Item -LiteralPath $normalizedSource -Force -ErrorAction SilentlyContinue
}
$projectAssets = Join-Path $Root 'src\box-winUI\Assets'
Save-Logo $master (Join-Path $projectAssets 'StoreLogo.png') 50 50 5
Save-Logo $master (Join-Path $projectAssets 'Square44x44Logo.png') 44 44 4
Save-Logo $master (Join-Path $projectAssets 'Square150x150Logo.png') 150 150 16
Save-Logo $master (Join-Path $projectAssets 'LockScreenLogo.png') 70 70 8
Save-Logo $master (Join-Path $projectAssets 'Wide310x150Logo.png') 310 150 24 ([Drawing.Color]::FromArgb(255, 245, 246, 247))
} finally {
$master.Dispose()
}
Write-Host 'Generated DPI-aware YMhut Box application icons.'
+154
View File
@@ -0,0 +1,154 @@
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$OutputDirectory
)
$ErrorActionPreference = 'Stop'
$rootPath = (Resolve-Path -LiteralPath $Root).Path
$outputPath = [IO.Path]::GetFullPath($OutputDirectory)
New-Item -ItemType Directory -Force -Path $outputPath | Out-Null
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
function Get-Sha256([byte[]]$Bytes) {
$sha = [Security.Cryptography.SHA256]::Create()
try { return ([BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() }
finally { $sha.Dispose() }
}
function Get-KeyMaterial([byte[]]$Salt, [string]$Purpose) {
$purposeBytes = [Text.Encoding]::UTF8.GetBytes("YMhut.Box.AssetPackage.v2|$Purpose")
$material = [byte[]]::new($purposeBytes.Length + $Salt.Length)
[Array]::Copy($purposeBytes, 0, $material, 0, $purposeBytes.Length)
[Array]::Copy($Salt, 0, $material, $purposeBytes.Length, $Salt.Length)
$sha = [Security.Cryptography.SHA512]::Create()
try { return $sha.ComputeHash($material) }
finally { $sha.Dispose() }
}
function Write-AuthenticatedPackage([byte[]]$Plain, [string]$Purpose, [string]$Path) {
$salt = [byte[]]::new(16)
$iv = [byte[]]::new(16)
$random = [Security.Cryptography.RandomNumberGenerator]::Create()
try {
$random.GetBytes($salt)
$random.GetBytes($iv)
}
finally { $random.Dispose() }
$keys = Get-KeyMaterial $salt $Purpose
$aes = [Security.Cryptography.Aes]::Create()
try {
$aes.Key = $keys[0..31]
$aes.IV = $iv
$aes.Mode = [Security.Cryptography.CipherMode]::CBC
$aes.Padding = [Security.Cryptography.PaddingMode]::PKCS7
$encryptor = $aes.CreateEncryptor()
try { $cipher = $encryptor.TransformFinalBlock($Plain, 0, $Plain.Length) }
finally { $encryptor.Dispose() }
}
finally { $aes.Dispose() }
$magic = [Text.Encoding]::ASCII.GetBytes('YMHUTAS2')
$header = [IO.MemoryStream]::new()
$writer = [IO.BinaryWriter]::new($header, [Text.Encoding]::UTF8, $true)
try {
$writer.Write($magic)
$writer.Write([byte]2)
$writer.Write($salt)
$writer.Write($iv)
$writer.Write([long]$Plain.Length)
$writer.Write([long]$cipher.Length)
$writer.Flush()
}
finally { $writer.Dispose() }
$signed = [byte[]]::new($header.Length + $cipher.Length)
[Array]::Copy($header.ToArray(), 0, $signed, 0, $header.Length)
[Array]::Copy($cipher, 0, $signed, $header.Length, $cipher.Length)
$hmac = [Security.Cryptography.HMACSHA256]::new($keys[32..63])
try { $tag = $hmac.ComputeHash($signed) }
finally { $hmac.Dispose() }
[IO.File]::WriteAllBytes($Path, $signed + $tag)
$header.Dispose()
}
function Add-SourceFile([hashtable]$Entries, [string]$Source, [string]$EntryName) {
if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) { throw "Missing authenticated asset source: $Source" }
$normalized = $EntryName.Replace('\', '/').TrimStart('/')
if ($normalized -eq '' -or $normalized.Contains('../') -or $normalized.StartsWith('../')) { throw "Unsafe authenticated asset entry: $EntryName" }
if ($Entries.ContainsKey($normalized)) { throw "Duplicate authenticated asset entry: $normalized" }
$bytes = [IO.File]::ReadAllBytes($Source)
$Entries[$normalized] = [pscustomobject]@{ Path = $normalized; Bytes = $bytes; Sha256 = Get-Sha256 $bytes }
}
function Add-SourceTree([hashtable]$Entries, [string]$SourceRoot, [string]$Prefix, [scriptblock]$Include = $null) {
if (-not (Test-Path -LiteralPath $SourceRoot -PathType Container)) { throw "Missing authenticated asset directory: $SourceRoot" }
foreach ($file in Get-ChildItem -LiteralPath $SourceRoot -Recurse -File) {
$relative = $file.FullName.Substring($SourceRoot.TrimEnd('\', '/').Length).TrimStart('\', '/').Replace('\', '/')
if ($Include -and -not (& $Include $relative)) { continue }
Add-SourceFile $Entries $file.FullName (("$Prefix/$relative").TrimStart('/'))
}
}
function New-AssetPackage([hashtable]$Entries, [string]$BundleId, [string]$Purpose, [string]$Path) {
$memory = [IO.MemoryStream]::new()
$archive = [IO.Compression.ZipArchive]::new($memory, [IO.Compression.ZipArchiveMode]::Create, $true)
try {
foreach ($item in ($Entries.Values | Sort-Object Path)) {
$entry = $archive.CreateEntry($item.Path, [IO.Compression.CompressionLevel]::Optimal)
$stream = $entry.Open()
try { $stream.Write($item.Bytes, 0, $item.Bytes.Length) }
finally { $stream.Dispose() }
}
$manifest = [ordered]@{
schemaVersion = 2
bundleId = $BundleId
files = @($Entries.Values | Sort-Object Path | ForEach-Object {
[ordered]@{ path = $_.Path; length = $_.Bytes.Length; sha256 = $_.Sha256 }
})
}
$manifestBytes = [Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Depth 5 -Compress))
$manifestEntry = $archive.CreateEntry('_manifest.json', [IO.Compression.CompressionLevel]::Optimal)
$manifestStream = $manifestEntry.Open()
try { $manifestStream.Write($manifestBytes, 0, $manifestBytes.Length) }
finally { $manifestStream.Dispose() }
}
finally { $archive.Dispose() }
$plain = $memory.ToArray()
$memory.Dispose()
Write-AuthenticatedPackage $plain $Purpose $Path
}
$runtimeEntries = @{}
$projectAssets = Join-Path $rootPath 'src\box-winUI\Assets'
$repoAssets = Join-Path $rootPath 'assets'
Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'startup-splash') 'startup-splash'
Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'home-globe') 'home-globe' {
param($relative)
return $relative -notlike 'textures/solar/source/*' -and
$relative -notlike 'textures/solar/optimized/*' -and
$relative -ne 'textures/solar/solar-texture-manifest.json'
}
Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'tool-pages') 'tool-pages'
Add-SourceTree $runtimeEntries (Join-Path $projectAssets 'tool-results') 'tool-results'
Add-SourceTree $runtimeEntries (Join-Path $repoAssets 'icons') 'icons' { param($relative) return $relative -ne '.gitkeep' }
Add-SourceTree $runtimeEntries (Join-Path $repoAssets 'images') 'images' { param($relative) return $relative -ne 'loading.gif' }
Add-SourceFile $runtimeEntries (Join-Path $repoAssets 'download.svg') 'download.svg'
Add-SourceFile $runtimeEntries (Join-Path $repoAssets 'icons\app_icon.ico') 'app_icon.ico'
New-AssetPackage $runtimeEntries 'runtime-assets' 'runtime-assets' (Join-Path $outputPath 'runtime-assets.dat')
$dataEntries = @{}
$dataRoot = Join-Path $repoAssets 'data'
foreach ($file in Get-ChildItem -LiteralPath $dataRoot -Recurse -File) {
if ($file.Extension -ieq '.dat' -or $file.Name -ieq 'ymhut-data.ybin') { continue }
$relative = $file.FullName.Substring($repoAssets.TrimEnd('\', '/').Length).TrimStart('\', '/').Replace('\', '/')
Add-SourceFile $dataEntries $file.FullName $relative
}
$rankingRoot = Join-Path $rootPath 'server\unified-management\internal\reference\data'
if (Test-Path -LiteralPath $rankingRoot -PathType Container) {
foreach ($file in Get-ChildItem -LiteralPath $rankingRoot -Filter '*.json' -File) {
Add-SourceFile $dataEntries $file.FullName ("data/rankings/$($file.Name)")
}
}
New-AssetPackage $dataEntries 'reference-data' 'reference-data' (Join-Path $outputPath 'ymhut-data.ybin')
+64
View File
@@ -0,0 +1,64 @@
param(
[string]$Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
)
$ErrorActionPreference = 'Stop'
$catalogPath = Join-Path $Root 'src\YMhut.Box.Core\Api\PearApiCatalog.cs'
$outputPath = Join-Path $Root 'src\box-winUI\Views\Tools\GeneratedPearApiToolPages.cs'
$pattern = 'D\("(?<id>[^"]+)",\s*\d+,'
$ids = [Collections.Generic.List[string]]::new()
foreach ($line in (Get-Content -LiteralPath $catalogPath -Encoding UTF8)) {
$match = [regex]::Match($line, $pattern)
if ($match.Success) { $ids.Add($match.Groups['id'].Value) }
}
if ($ids.Count -ne 74) { throw "Expected 74 PearAPI definitions, found $($ids.Count)." }
if (($ids | Select-Object -Unique).Count -ne $ids.Count) { throw 'PearAPI tool IDs must be unique.' }
$legacyAliases = @('baidu_hot', 'bili_hot', 'zhihu_hot')
function Get-ClassStem([string]$id) {
$parts = $id -split '[^A-Za-z0-9]+'
return (($parts | Where-Object { $_ } | ForEach-Object {
if ($_.Length -eq 1) { $_.ToUpperInvariant() }
else { $_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1) }
}) -join '')
}
$lines = [Collections.Generic.List[string]]::new()
$lines.Add('// <auto-generated />')
$lines.Add('#nullable enable')
$lines.Add('using YMhut.Box.Core.Api;')
$lines.Add('using YMhut.Box.Core.Tools;')
$lines.Add('using YMhut.Box.WinUI.ViewModels.Tools;')
$lines.Add('')
$lines.Add('namespace YMhut.Box.WinUI.Views.Tools;')
$lines.Add('')
$lines.Add('public sealed partial class ToolPageRegistry')
$lines.Add('{')
$lines.Add(' private static partial void RegisterPearApi(ToolPageRegistry registry)')
$lines.Add(' {')
foreach ($id in $ids) {
$stem = Get-ClassStem $id
$lines.Add(" registry.Register<${stem}PearApiToolPage, ${stem}PearApiToolViewModel>(`"$id`", (module, goBack) => new ${stem}PearApiToolPage(module, goBack));")
}
foreach ($id in $legacyAliases) {
$stem = (($id -split '_') | ForEach-Object { if ($_.Length -gt 0) { $_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1) } }) -join ''
$lines.Add(" registry.Register<${stem}PearApiToolPage, ${stem}PearApiToolViewModel>(`"$id`", (module, goBack) => new ${stem}PearApiToolPage(module, goBack));")
}
$lines.Add(' }')
$lines.Add('}')
$lines.Add('')
foreach ($id in $ids) {
$stem = Get-ClassStem $id
$lines.Add("public sealed class ${stem}PearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired(`"$id`"));")
$lines.Add("public sealed class ${stem}PearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new ${stem}PearApiToolViewModel(module), goBack);")
$lines.Add('')
}
foreach ($id in $legacyAliases) {
$stem = (($id -split '_') | ForEach-Object { if ($_.Length -gt 0) { $_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1) } }) -join ''
$lines.Add("public sealed class ${stem}PearApiToolViewModel(IToolModule module) : PearApiToolViewModel(module, PearApiCatalog.GetRequired(`"$id`"));")
$lines.Add("public sealed class ${stem}PearApiToolPage(IToolModule module, Action? goBack = null) : PearApiToolPageBase(module, new ${stem}PearApiToolViewModel(module), goBack);")
$lines.Add('')
}
Set-Content -LiteralPath $outputPath -Value $lines -Encoding UTF8
Write-Host "Generated $($ids.Count) PearAPI pages and $($legacyAliases.Count) legacy aliases in $outputPath"
+54 -9
View File
@@ -5,6 +5,7 @@
$ErrorActionPreference = 'Stop'
$catalogPath = Join-Path $Root 'src\YMhut.Box.Core\Tools\ToolCatalog.cs'
$pearCatalogPath = Join-Path $Root 'src\YMhut.Box.Core\Api\PearApiCatalog.cs'
$nexPath = Join-Path $Root 'src\YMhut.Box.Core\Tools\NexNativeTools.cs'
$outputRoot = Join-Path $Root 'assets\data\tool-ui'
New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null
@@ -32,6 +33,35 @@ foreach ($line in ($rawMatch.Groups['data'].Value -split "`r?`n")) {
}
}
$pearPattern = 'D\("(?<id>[^"]+)",\s*(?<apiId>\d+),\s*"(?<name>[^"]+)",\s*"(?<description>[^"]*)",\s*"(?<category>[^"]+)",\s*"(?<path>[^"]+)",\s*PearApiRequestMethod\.(?<method>\w+),\s*PearApiPrivacyClass\.(?<privacy>\w+),\s*PearApiResultKind\.(?<result>\w+)'
foreach ($line in (Get-Content -LiteralPath $pearCatalogPath -Encoding UTF8)) {
$match = [regex]::Match($line, $pearPattern)
if (-not $match.Success) { continue }
$id = $match.Groups['id'].Value
[void]$ids.Add($id)
$metadata[$id] = [ordered]@{
zh = $match.Groups['name'].Value
en = $match.Groups['name'].Value
descriptionZh = $match.Groups['description'].Value
descriptionEn = $match.Groups['description'].Value
category = 'network'
offline = $false
pear = $true
pearCategory = $match.Groups['category'].Value
privacy = $match.Groups['privacy'].Value
result = $match.Groups['result'].Value
apiId = $match.Groups['apiId'].Value
}
}
foreach ($alias in @{ baidu_hot = '百度'; bili_hot = '哔哩哔哩'; zhihu_hot = '知乎' }.GetEnumerator()) {
if (-not $metadata.ContainsKey($alias.Key)) { continue }
$metadata[$alias.Key].pear = $true
$metadata[$alias.Key].pearCategory = '网络'
$metadata[$alias.Key].privacy = 'Public'
$metadata[$alias.Key].result = 'RankedList'
$metadata[$alias.Key].apiId = '141'
}
foreach ($match in [regex]::Matches((Get-Content -LiteralPath $nexPath -Raw -Encoding UTF8), 'Tool\("(?<id>[^"]+)"')) {
$id = $match.Groups['id'].Value
[void]$ids.Add($id)
@@ -59,7 +89,13 @@ try {
$meta = $metadata[$id]
$category = [string]$meta.category
$risk = if ($category -in @('security', 'system', 'plugin', 'external')) { 'high' } else { 'normal' }
$primaryKind = switch ($category) {
$primaryKind = if ($meta.pear) {
switch ([string]$meta.privacy) {
'Public' { 'Output'; break }
'FileUpload' { 'FilePicker'; break }
default { 'Query' }
}
} else { switch ($category) {
'image' { 'FilePicker'; break }
'calculator' { 'Number'; break }
'network' { 'Query'; break }
@@ -69,12 +105,12 @@ try {
'plugin' { 'WebView'; break }
'external' { 'ExternalSurface'; break }
default { 'Text' }
}
} }
$maxLength = 4096
if ($category -eq 'text' -or $category -eq 'dev' -or $category -eq 'security') { $maxLength = 200000 }
$placeholder = '输入此工具所需的值'
if ($category -eq 'network') { $placeholder = '关键词、URL、域名或 IP' }
if ($category -eq 'network') { $placeholder = '按此接口页面的字段填写查询条件' }
$primaryControls = [Collections.Generic.List[object]]::new()
$primaryControls.Add([ordered]@{
id = 'primary'
@@ -88,7 +124,9 @@ try {
})
$sections = [Collections.Generic.List[object]]::new()
$primaryTitle = switch ($category) {
$primaryTitle = if ($meta.pear) {
[ordered]@{ zh = "PearAPI · $([string]$meta.pearCategory)"; en = "PearAPI · $([string]$meta.pearCategory)" }
} else { switch ($category) {
'calculator' { [ordered]@{ zh = '计算参数'; en = 'Calculation parameters' } }
'network' { [ordered]@{ zh = '查询条件'; en = 'Query criteria' } }
'image' { [ordered]@{ zh = '文件与媒体'; en = 'Files and media' } }
@@ -99,14 +137,14 @@ try {
'plugin' { [ordered]@{ zh = '插件安全容器'; en = 'Plugin security container' } }
'external' { [ordered]@{ zh = '外部程序运行'; en = 'External program launch' } }
default { [ordered]@{ zh = '主要操作'; en = 'Primary operation' } }
}
} }
if ($category -eq 'calculator') {
$primaryControls.Clear()
foreach ($numberId in @('number-primary', 'number-secondary', 'number-third', 'number-fourth')) {
$primaryControls.Add([ordered]@{ id = $numberId; kind = 'Number'; label = [ordered]@{ zh = '数值参数'; en = 'Numeric parameter' }; defaultValue = '1'; required = $true; isReadOnly = $false; validation = [ordered]@{ minimum = -100000000; maximum = 100000000 } })
}
} elseif ($category -eq 'network') {
} elseif ($category -eq 'network' -and -not $meta.pear) {
$primaryControls.Add([ordered]@{ id = 'parameter'; kind = 'ComboBox'; label = [ordered]@{ zh = '来源或分类'; en = 'Source or category' }; defaultValue = ''; required = $false; isReadOnly = $false })
} elseif ($category -eq 'image') {
$primaryControls.Add([ordered]@{ id = 'file'; kind = 'FilePicker'; label = [ordered]@{ zh = '文件'; en = 'File' }; placeholder = '选择本地文件'; defaultValue = ''; required = $false; isReadOnly = $false })
@@ -121,7 +159,7 @@ try {
layout = $(if ($category -eq 'calculator') { 'grid' } else { 'stack' })
})
if ($category -in @('text', 'dev', 'security', 'network', 'design')) {
if (-not $meta.pear -and $category -in @('text', 'dev', 'security', 'network', 'design')) {
$advancedControls = [Collections.Generic.List[object]]::new()
if ($category -in @('text', 'dev', 'security')) {
$advancedControls.Add([ordered]@{ id = 'rules'; kind = 'ComboBox'; label = [ordered]@{ zh = '处理规则'; en = 'Processing rules' }; defaultValue = ''; required = $false; isReadOnly = $false })
@@ -142,7 +180,9 @@ try {
})
}
$resultKind = switch ($category) {
$resultKind = if ($meta.pear) {
"pear-$(([string]$meta.result).ToLowerInvariant())"
} else { switch ($category) {
'calculator' { 'calculator-table' }
'image' { 'image-preview' }
'data' { 'ranked-list' }
@@ -153,7 +193,7 @@ try {
'plugin' { 'status-list' }
'external' { 'status-list' }
default { 'text' }
}
} }
$actions = [Collections.Generic.List[object]]::new()
$actionLabel = switch ($category) {
'calculator' { [ordered]@{ zh = '计算'; en = 'Calculate' } }
@@ -217,5 +257,10 @@ try {
} finally {
$aes.Dispose()
}
foreach ($stale in Get-ChildItem -LiteralPath $outputRoot -Filter '*.dat' -File -ErrorAction SilentlyContinue) {
if (-not $ids.Contains($stale.BaseName)) {
Remove-Item -LiteralPath $stale.FullName -Force
}
}
Write-Host "Generated $($ids.Count) tool UI definitions in $outputRoot"